frontend phase 4: catalog browse (Home A5) & nurse service builder (B7)
Light up the two faces of the configurable service catalog over a new cached services/catalog domain (consumes the b5 contract; unlocks f6 search). - services/catalog: types/keys/constants/apis(client+mock+seam)/hooks/index + names.ts. Categories & option groups are session-cached reference data (Infinity staleTime, like geography); variant mutations invalidate myVariantsLists() and setQueryData the edited row. Mock-primary (USE_CATALOG_MOCK), one-line swap; mock reproduces the 400 missing-required and (nurse,category,option-set) 409 duplicate rules. - Customer Home (A5): greeting+avatar, search bar (navigates toward f6), data-driven category grid (loading/empty/error), patient nudge from the cached f2 query. Deferred /search placeholder stub. - Nurse Services & prices (B7) at /nurse/services: offerings list (active vs deactivated, edit, soft-deactivate w/ confirm, reactivate, no delete) and a 3-step variant builder (category -> required/optional options -> price+unit+ duration). Required-group gate; Toman->IRR digit-string at the field boundary (no float); live unit-aware estimated total (never from price alone); editable auto display_name; inline 409 duplicate warning; locked category edit form. - Shared, tested components: CategoryTile, PriceDisplay, VariantCard. Money util: tomanToRial + multiplyIrr (integer-safe) + tests. - i18n: catalog/services/search namespaces + home additions + nav.services (both locales, in sync). Icons, routes (SEARCH, NURSE_SERVICES), nurse nav. Gate: npm run check green; npm run test:ci green (147 tests, +18 across 4 suites); npm run build green with NEXT_PUBLIC_API_URL set. Docs: client/CLAUDE.md (Project Structure, caching note, namespaces), STATUS, for-backend REQ-010 (pagination param casing), phase report, mocks registry. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,14 @@
|
||||
'use client';
|
||||
import { FunctionComponent, useEffect } from 'react';
|
||||
import { FormEvent, FunctionComponent, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Box, Paper, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, AppLoading } from '@/components';
|
||||
import { Avatar, Box, InputAdornment, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, AppLoading, CategoryTile } from '@/components';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { useMe } from '@/services/auth';
|
||||
import { usePatients } from '@/services/patients';
|
||||
import { useServiceCategories } from '@/services/catalog';
|
||||
import { pickCatalogName } from '@/services/catalog/names';
|
||||
|
||||
interface NudgeCardProps {
|
||||
icon: string;
|
||||
@@ -39,10 +41,13 @@ const NudgeCard: FunctionComponent<NudgeCardProps> = ({ icon, title, body, ctaLa
|
||||
);
|
||||
|
||||
/**
|
||||
* A5 — the family Home. First-login gate: a customer with no patients is sent into onboarding
|
||||
* (A3). Once a patient exists it shows the "complete patient record" nudge (and a profile
|
||||
* nudge until the profile is complete). The redirect waits for a settled list so a post-create
|
||||
* refetch never bounces the user back to onboarding.
|
||||
* A5 — the family Home: the front door of the app. Greeting + avatar, the search bar (which hands a
|
||||
* query / chosen `service_category_id` toward the f6 search flow — results are not built here), the
|
||||
* **data-driven** service-category grid (from the cached `services/catalog` reference data), and the
|
||||
* complete-patient-record nudge (derived from the f2 patient cache — no extra fetch).
|
||||
*
|
||||
* First-login gate: a customer with no patients is sent into onboarding (A3). The redirect waits for
|
||||
* a settled list so a post-create refetch never bounces the user back to onboarding.
|
||||
*/
|
||||
export default function CustomerHomePage() {
|
||||
const t = useTranslations('home');
|
||||
@@ -52,9 +57,6 @@ export default function CustomerHomePage() {
|
||||
const { data: me } = useMe();
|
||||
const { data } = usePatients();
|
||||
|
||||
// A customer with no patients is a first-login user → onboarding. `useCreatePatient` primes
|
||||
// the list cache on success, so a just-onboarded user never transiently reads total===0 here
|
||||
// (no bounce back); a genuinely empty list always renders loading, never a flash of Home.
|
||||
const isEmpty = data?.total === 0;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -67,17 +69,29 @@ export default function CustomerHomePage() {
|
||||
|
||||
const href = (path: string) => `/${locale}${path}`;
|
||||
const profileComplete = me?.hasCustomerProfile ?? false;
|
||||
const firstName = me?.firstName?.trim() || null;
|
||||
const greeting = firstName ? t('greeting_named', { name: firstName }) : t('greeting_plain');
|
||||
const avatarInitial = firstName ? firstName.charAt(0).toUpperCase() : null;
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<Box>
|
||||
<Typography variant="h5" component="h1">
|
||||
{t('greeting')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
|
||||
<Avatar sx={{ width: 48, height: 48, bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 700 }}>
|
||||
{avatarInitial ?? <AppIcon icon="account" size={28} color="var(--bal-primary)" />}
|
||||
</Avatar>
|
||||
<Box>
|
||||
<Typography variant="h5" component="h1">
|
||||
{greeting}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<HomeSearchBar />
|
||||
|
||||
<CategoryGrid onSelect={(categoryId) => router.push(`${href(ROUTES.SEARCH)}?category_id=${categoryId}`)} />
|
||||
|
||||
<NudgeCard
|
||||
icon="patients"
|
||||
@@ -98,3 +112,99 @@ export default function CustomerHomePage() {
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Home search field. Rendering + query capture live here; **execution is f6** — submitting
|
||||
* navigates toward the (future) search route carrying the typed query. Results/filters are DEFERRED
|
||||
* → frontend-phase-6-b7.
|
||||
*/
|
||||
const HomeSearchBar: FunctionComponent = () => {
|
||||
const t = useTranslations('home');
|
||||
const router = useRouter();
|
||||
const locale = useLocale();
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const submit = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const q = query.trim();
|
||||
router.push(`/${locale}${ROUTES.SEARCH}${q ? `?q=${encodeURIComponent(q)}` : ''}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box component="form" onSubmit={submit} role="search">
|
||||
<TextField
|
||||
fullWidth
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={t('search_placeholder')}
|
||||
aria-label={t('search_action')}
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<AppIcon icon="search" size={22} color="var(--bal-text-secondary)" />
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
/** The data-driven service-category grid — one tile per `service_category`, with all four states. */
|
||||
const CategoryGrid: FunctionComponent<{ onSelect: (categoryId: number) => void }> = ({ onSelect }) => {
|
||||
const t = useTranslations('home');
|
||||
const tc = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
const { data, isLoading, isError, refetch } = useServiceCategories();
|
||||
const categories = data?.items ?? [];
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('categories_title')}
|
||||
</Typography>
|
||||
|
||||
{isLoading ? (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'repeat(2, 1fr)', sm: 'repeat(3, 1fr)' }, gap: 1.5 }}>
|
||||
{[0, 1, 2, 3].map((key) => (
|
||||
<Skeleton key={key} variant="rounded" height={116} sx={{ borderRadius: 2 }} />
|
||||
))}
|
||||
</Box>
|
||||
) : isError ? (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{ p: 3, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 1 }}>
|
||||
{t('categories_error')}
|
||||
</Typography>
|
||||
<AppButton variant="outlined" color="primary" onClick={() => refetch()} sx={{ m: 0 }}>
|
||||
{tc('retry')}
|
||||
</AppButton>
|
||||
</Paper>
|
||||
) : categories.length === 0 ? (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{ p: 3, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('categories_empty')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
) : (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'repeat(2, 1fr)', sm: 'repeat(3, 1fr)' }, gap: 1.5 }}>
|
||||
{categories.map((category) => (
|
||||
<CategoryTile
|
||||
key={category.id}
|
||||
label={pickCatalogName(category, locale)}
|
||||
iconKey={category.iconKey}
|
||||
onClick={() => onSelect(category.id)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
'use client';
|
||||
import { Suspense } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { AppLoading, PlaceholderScreen } from '@/components';
|
||||
|
||||
/**
|
||||
* Search landing — **DEFERRED → frontend-phase-6-b7**. The A5 Home search bar and category tiles
|
||||
* navigate here carrying a `q` / `category_id`; f6 builds the actual results, filters, and nurse
|
||||
* cards. This placeholder just acknowledges the intent so the Home CTAs don't dead-end. `useSearchParams`
|
||||
* needs a Suspense boundary under static rendering.
|
||||
*/
|
||||
export default function SearchPage() {
|
||||
return (
|
||||
<Suspense fallback={<AppLoading />}>
|
||||
<SearchDeferred />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchDeferred() {
|
||||
const t = useTranslations('search');
|
||||
const params = useSearchParams();
|
||||
const query = params.get('q');
|
||||
const categoryId = params.get('category_id');
|
||||
const echo = query ? t('query_echo', { query }) : categoryId ? t('category_echo') : undefined;
|
||||
|
||||
return (
|
||||
<PlaceholderScreen
|
||||
icon="search"
|
||||
title={t('title')}
|
||||
description={[t('deferred'), echo].filter(Boolean).join(' ')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
'use client';
|
||||
import { FunctionComponent, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import {
|
||||
Box,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Paper,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { AppButton, AppIcon, VariantCard } from '@/components';
|
||||
import { useMyVariants, useSetVariantActive } from '@/services/catalog';
|
||||
import type { NurseServiceVariant } from '@/services/catalog/types';
|
||||
|
||||
interface MyServicesListProps {
|
||||
onAdd: () => void;
|
||||
onEdit: (variant: NurseServiceVariant) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The nurse's offerings list (`ListMyVariants`) — active + inactive variants as `VariantCard`s, with
|
||||
* loading skeletons and a prominent empty state. Deactivate opens a confirm dialog (soft only — the
|
||||
* variant becomes unbookable and drops out of search, never deleted); reactivating an inactive row is
|
||||
* non-destructive, so it fires directly. Mutations invalidate `myVariants` via the hook.
|
||||
*/
|
||||
const MyServicesList: FunctionComponent<MyServicesListProps> = ({ onAdd, onEdit }) => {
|
||||
const t = useTranslations('services');
|
||||
const tc = useTranslations('common');
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
|
||||
const { data, isLoading } = useMyVariants();
|
||||
const setActive = useSetVariantActive();
|
||||
const [deactivateTarget, setDeactivateTarget] = useState<NurseServiceVariant | null>(null);
|
||||
|
||||
const variants = data?.items ?? [];
|
||||
const isEmpty = !isLoading && variants.length === 0;
|
||||
|
||||
const toggleActive = (variant: NurseServiceVariant) => {
|
||||
// Deactivating is guarded by a confirm; reactivating is safe, so it fires immediately.
|
||||
if (variant.isActive) {
|
||||
setDeactivateTarget(variant);
|
||||
return;
|
||||
}
|
||||
setActive.mutate(
|
||||
{ id: variant.id, isActive: true },
|
||||
{
|
||||
onSuccess: () => enqueueSnackbar(t('activated_toast'), { variant: 'success' }),
|
||||
onError: () => enqueueSnackbar(t('toggle_error'), { variant: 'error' }),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const confirmDeactivate = () => {
|
||||
if (!deactivateTarget) return;
|
||||
const id = deactivateTarget.id;
|
||||
setDeactivateTarget(null);
|
||||
setActive.mutate(
|
||||
{ id, isActive: false },
|
||||
{
|
||||
onSuccess: () => enqueueSnackbar(t('deactivated_toast'), { variant: 'success' }),
|
||||
onError: () => enqueueSnackbar(t('toggle_error'), { variant: 'error' }),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 640 }}>
|
||||
<Stack direction="row" sx={{ alignItems: 'flex-start', justifyContent: 'space-between', gap: 2 }}>
|
||||
<Box>
|
||||
<Typography variant="h5" component="h1">
|
||||
{t('title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
{!isEmpty && !isLoading ? (
|
||||
<AppButton color="primary" variant="contained" startIcon="add" onClick={onAdd} sx={{ m: 0, flexShrink: 0 }}>
|
||||
{t('add')}
|
||||
</AppButton>
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
{isLoading ? (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
{[0, 1].map((key) => (
|
||||
<Skeleton key={key} variant="rounded" height={150} sx={{ borderRadius: 2 }} />
|
||||
))}
|
||||
</Stack>
|
||||
) : isEmpty ? (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 4,
|
||||
textAlign: 'center',
|
||||
border: '1px dashed',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
}}
|
||||
>
|
||||
<AppIcon icon="services" size={40} color="var(--bal-primary)" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('empty_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', maxWidth: 420 }}>
|
||||
{t('empty_body')}
|
||||
</Typography>
|
||||
<AppButton color="primary" variant="contained" startIcon="add" onClick={onAdd} sx={{ mt: 1 }}>
|
||||
{t('add')}
|
||||
</AppButton>
|
||||
</Paper>
|
||||
) : (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
{variants.map((variant) => (
|
||||
<VariantCard
|
||||
key={variant.id}
|
||||
variant={variant}
|
||||
onEdit={() => onEdit(variant)}
|
||||
onToggleActive={() => toggleActive(variant)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Dialog open={Boolean(deactivateTarget)} onClose={() => setDeactivateTarget(null)}>
|
||||
<DialogTitle>{t('deactivate_title')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('deactivate_body')}
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<AppButton variant="text" onClick={() => setDeactivateTarget(null)}>
|
||||
{tc('cancel')}
|
||||
</AppButton>
|
||||
<AppButton color="error" variant="contained" onClick={confirmDeactivate}>
|
||||
{t('deactivate_confirm')}
|
||||
</AppButton>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MyServicesList;
|
||||
@@ -0,0 +1,490 @@
|
||||
'use client';
|
||||
import { FunctionComponent, useMemo, useState } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import {
|
||||
Box,
|
||||
Chip,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Skeleton,
|
||||
Stack,
|
||||
TextField,
|
||||
ToggleButton,
|
||||
ToggleButtonGroup,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { AppButton, AppLoading, CategoryTile, PriceDisplay, StepperHeader } from '@/components';
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { digitsOnly, rialToToman, tomanToRial } from '@/utils';
|
||||
import {
|
||||
useCategoryOptionGroups,
|
||||
useCreateVariant,
|
||||
useServiceCategories,
|
||||
useUpdateVariant,
|
||||
} from '@/services/catalog';
|
||||
import { pickCatalogName } from '@/services/catalog/names';
|
||||
import {
|
||||
PRICE_UNITS,
|
||||
type NurseServiceVariant,
|
||||
type PriceUnit,
|
||||
type VariantOptionSelection,
|
||||
} from '@/services/catalog/types';
|
||||
|
||||
interface VariantBuilderProps {
|
||||
/** `null` = create (3-step stepper); a variant = edit (category/options locked, price form only). */
|
||||
initial: NurseServiceVariant | null;
|
||||
onDone: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const DEFAULT_UNIT: PriceUnit = 'per_hour';
|
||||
const MAX_PRICE_DIGITS = 12;
|
||||
const MAX_DURATION_DIGITS = 4;
|
||||
|
||||
/**
|
||||
* The nurse variant builder (`CreateVariant` / `UpdateVariant`).
|
||||
*
|
||||
* **Create** is a 3-step stepper: pick category → answer required/optional option groups → price +
|
||||
* unit + duration. Every `is_required` group must be answered before advancing; the price is entered
|
||||
* in **Toman** and converted to an IRR digit-string at the field boundary (`tomanToRial`, integer-safe,
|
||||
* never a float); the estimated total is shown only from `price` × `sessionCount`, never `price` alone;
|
||||
* `display_name` auto-generates from the chosen labels and is editable (left blank ⇒ the server
|
||||
* generates it). A duplicate identical listing (`409`) shows a friendly inline warning.
|
||||
*
|
||||
* **Edit** locks the category + option-set (changing them would change identity) and edits only
|
||||
* price/unit/duration/display via `update`.
|
||||
*/
|
||||
const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDone, onCancel }) => {
|
||||
const t = useTranslations('services');
|
||||
const tCatalog = useTranslations('catalog');
|
||||
const tc = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
|
||||
const isEdit = initial !== null;
|
||||
|
||||
const createVariant = useCreateVariant();
|
||||
const updateVariant = useUpdateVariant();
|
||||
const submitting = createVariant.isPending || updateVariant.isPending;
|
||||
|
||||
// --- Create-only state (category → options) ---
|
||||
const [activeStep, setActiveStep] = useState(0);
|
||||
const [categoryId, setCategoryId] = useState<number | null>(initial?.serviceCategoryId ?? null);
|
||||
const [selectedOptions, setSelectedOptions] = useState<Record<number, number>>({});
|
||||
const [optionsError, setOptionsError] = useState(false);
|
||||
|
||||
// --- Shared price state (both create step 3 and edit) ---
|
||||
// Pre-fill the price field in Toman (the wire carries IRR Rials); the money util does the ÷10.
|
||||
const [priceToman, setPriceToman] = useState(initial ? String(rialToToman(initial.price)) : '');
|
||||
const [priceUnit, setPriceUnit] = useState<PriceUnit>(initial?.priceUnit ?? DEFAULT_UNIT);
|
||||
const [durationStr, setDurationStr] = useState(initial?.sessionCount ? String(initial.sessionCount) : '');
|
||||
const [displayNameOverride, setDisplayNameOverride] = useState<string | null>(null);
|
||||
const [priceError, setPriceError] = useState(false);
|
||||
const [duplicate, setDuplicate] = useState(false);
|
||||
|
||||
const categoriesQuery = useServiceCategories();
|
||||
const categories = categoriesQuery.data?.items ?? [];
|
||||
const optionGroupsQuery = useCategoryOptionGroups(isEdit ? null : categoryId);
|
||||
// Stable reference so the auto-name useMemo below isn't invalidated on every render by a fresh `[]`.
|
||||
const groups = useMemo(() => optionGroupsQuery.data ?? [], [optionGroupsQuery.data]);
|
||||
|
||||
const selectedCategory = categories.find((category) => category.id === categoryId) ?? null;
|
||||
const missingRequiredGroups = groups.filter((group) => group.isRequired && selectedOptions[group.id] == null);
|
||||
|
||||
// Auto-generated display name preview (create): category + chosen value labels, in the active locale.
|
||||
const autoName = useMemo(() => {
|
||||
if (isEdit) return initial.displayName;
|
||||
if (!selectedCategory) return '';
|
||||
const valueLabels = groups
|
||||
.map((group) => {
|
||||
const valueId = selectedOptions[group.id];
|
||||
const value = valueId == null ? null : group.values.find((candidate) => candidate.id === valueId);
|
||||
return value ? pickCatalogName(value, locale) : null;
|
||||
})
|
||||
.filter((label): label is string => label !== null);
|
||||
return [pickCatalogName(selectedCategory, locale), ...valueLabels].join(' · ');
|
||||
}, [isEdit, initial, selectedCategory, groups, selectedOptions, locale]);
|
||||
|
||||
const displayValue = displayNameOverride ?? autoName;
|
||||
|
||||
const priceValid = priceToman.length > 0 && BigInt(priceToman) > BigInt(0);
|
||||
const irr = priceValid ? tomanToRial(priceToman) : null;
|
||||
const durationInt = durationStr ? Number(durationStr) : 0;
|
||||
const sessionCount = durationInt > 0 ? durationInt : null;
|
||||
|
||||
const selectCategory = (id: number) => {
|
||||
if (id === categoryId) return;
|
||||
// Switching category invalidates the previous category's option answers + auto-name.
|
||||
setCategoryId(id);
|
||||
setSelectedOptions({});
|
||||
setDisplayNameOverride(null);
|
||||
setOptionsError(false);
|
||||
};
|
||||
|
||||
const changeOption = (groupId: number, valueId: number | null) => {
|
||||
setOptionsError(false);
|
||||
// A manual displayName override is intentionally left untouched; the auto-name preview tracks
|
||||
// option changes only while the field hasn't been overridden (displayValue = override ?? autoName).
|
||||
setSelectedOptions((prev) => {
|
||||
const next = { ...prev };
|
||||
if (valueId == null) delete next[groupId];
|
||||
else next[groupId] = valueId;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const goNextFromOptions = () => {
|
||||
if (missingRequiredGroups.length > 0) {
|
||||
setOptionsError(true);
|
||||
return;
|
||||
}
|
||||
setActiveStep(2);
|
||||
};
|
||||
|
||||
const validatePrice = () => {
|
||||
if (!priceValid) {
|
||||
setPriceError(true);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
if (!validatePrice() || irr == null) return;
|
||||
const displayName = displayNameOverride?.trim() ? displayNameOverride.trim() : undefined;
|
||||
|
||||
if (isEdit) {
|
||||
updateVariant.mutate(
|
||||
{ id: initial.id, input: { price: irr, priceUnit, sessionCount, displayName } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
enqueueSnackbar(t('saved_toast'), { variant: 'success' });
|
||||
onDone();
|
||||
},
|
||||
onError: () => enqueueSnackbar(t('create_error'), { variant: 'error' }),
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const options: VariantOptionSelection[] = Object.entries(selectedOptions).map(([groupId, valueId]) => ({
|
||||
optionGroupId: Number(groupId),
|
||||
optionValueId: valueId,
|
||||
}));
|
||||
createVariant.mutate(
|
||||
{ serviceCategoryId: categoryId as number, options, price: irr, priceUnit, sessionCount, displayName },
|
||||
{
|
||||
onSuccess: () => {
|
||||
enqueueSnackbar(t('created_toast'), { variant: 'success' });
|
||||
onDone();
|
||||
},
|
||||
onError: (error) => {
|
||||
// The duplicate-listing conflict is a friendly inline warning, never a generic toast.
|
||||
if (error instanceof ApiError && error.status === 409) setDuplicate(true);
|
||||
else enqueueSnackbar(t('create_error'), { variant: 'error' });
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const priceStep = (
|
||||
<Stack sx={{ gap: 2.5 }}>
|
||||
<TextField
|
||||
label={t('price_label')}
|
||||
value={priceToman}
|
||||
onChange={(event) => {
|
||||
setPriceToman(digitsOnly(event.target.value).slice(0, MAX_PRICE_DIGITS));
|
||||
if (priceError) setPriceError(false);
|
||||
if (duplicate) setDuplicate(false);
|
||||
}}
|
||||
error={priceError}
|
||||
helperText={priceError ? t('price_required') : t('price_hint')}
|
||||
slotProps={{ htmlInput: { dir: 'ltr', inputMode: 'numeric', style: { textAlign: 'start' } } }}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} sx={{ gap: 2 }}>
|
||||
<TextField
|
||||
select
|
||||
label={t('unit_label')}
|
||||
value={priceUnit}
|
||||
onChange={(event) => setPriceUnit(event.target.value as PriceUnit)}
|
||||
fullWidth
|
||||
>
|
||||
{PRICE_UNITS.map((unit) => (
|
||||
<MenuItem key={unit} value={unit}>
|
||||
{tCatalog(`unit_${unit}`)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
|
||||
<TextField
|
||||
label={t('duration_label')}
|
||||
value={durationStr}
|
||||
onChange={(event) => setDurationStr(digitsOnly(event.target.value).slice(0, MAX_DURATION_DIGITS))}
|
||||
helperText={t('duration_hint')}
|
||||
slotProps={{ htmlInput: { dir: 'ltr', inputMode: 'numeric', style: { textAlign: 'start' } } }}
|
||||
fullWidth
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{irr ? (
|
||||
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, bgcolor: 'var(--bal-primary-soft)' }}>
|
||||
<PriceDisplay price={irr} priceUnit={priceUnit} sessionCount={sessionCount} showEstimate />
|
||||
{!sessionCount ? (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block', mt: 0.5 }}>
|
||||
{t('rate_note')}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
<TextField
|
||||
label={t('display_name_label')}
|
||||
value={displayValue}
|
||||
onChange={(event) => setDisplayNameOverride(event.target.value)}
|
||||
helperText={t('display_name_hint')}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
{duplicate ? (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderInlineStartWidth: 4,
|
||||
borderInlineStartColor: 'var(--bal-warning)',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: 'var(--bal-warning)', fontWeight: 600 }}>
|
||||
{t('duplicate_warning')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
// --- Edit mode: locked category + options summary, then the price form ---
|
||||
if (isEdit) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 560 }}>
|
||||
<Typography variant="h5" component="h1">
|
||||
{t('builder_edit_title')}
|
||||
</Typography>
|
||||
|
||||
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{pickCatalogName({ nameFa: initial.categoryNameFa, nameEn: initial.categoryNameEn }, locale)}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('category_locked')}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, mt: 0.5 }}>
|
||||
{initial.options.length > 0 ? (
|
||||
initial.options.map((option) => (
|
||||
<Chip
|
||||
key={option.optionGroupId}
|
||||
size="small"
|
||||
label={`${pickCatalogName({ nameFa: option.groupNameFa, nameEn: option.groupNameEn }, locale)}: ${pickCatalogName({ nameFa: option.valueNameFa, nameEn: option.valueNameEn }, locale)}`}
|
||||
sx={{ bgcolor: 'var(--bal-primary-soft)' }}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('summary_none')}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{priceStep}
|
||||
|
||||
<Stack direction="row" sx={{ gap: 1, justifyContent: 'flex-end' }}>
|
||||
<AppButton variant="text" onClick={onCancel} disabled={submitting} sx={{ m: 0 }}>
|
||||
{tc('cancel')}
|
||||
</AppButton>
|
||||
<AppButton color="primary" variant="contained" onClick={submit} disabled={submitting} sx={{ m: 0 }}>
|
||||
{submitting ? tc('saving') : t('submit_save')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Create mode: the 3-step stepper ---
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, maxWidth: 560 }}>
|
||||
<Typography variant="h5" component="h1">
|
||||
{t('builder_add_title')}
|
||||
</Typography>
|
||||
|
||||
<StepperHeader
|
||||
steps={[t('step_category'), t('step_options'), t('step_price')]}
|
||||
activeStep={activeStep}
|
||||
/>
|
||||
|
||||
{activeStep === 0 ? (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('category_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('category_subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
{categoriesQuery.isLoading ? (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 1.5 }}>
|
||||
{[0, 1, 2, 3].map((key) => (
|
||||
<Skeleton key={key} variant="rounded" height={116} sx={{ borderRadius: 2 }} />
|
||||
))}
|
||||
</Box>
|
||||
) : categoriesQuery.isError ? (
|
||||
<Stack sx={{ gap: 1, alignItems: 'flex-start' }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('categories_error')}
|
||||
</Typography>
|
||||
<AppButton variant="outlined" color="primary" onClick={() => categoriesQuery.refetch()} sx={{ m: 0 }}>
|
||||
{tc('retry')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
) : (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 1.5 }}>
|
||||
{categories.map((category) => (
|
||||
<CategoryTile
|
||||
key={category.id}
|
||||
label={pickCatalogName(category, locale)}
|
||||
iconKey={category.iconKey}
|
||||
selected={categoryId === category.id}
|
||||
onClick={() => selectCategory(category.id)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
) : null}
|
||||
|
||||
{activeStep === 1 ? (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('options_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('options_subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{optionGroupsQuery.isLoading ? (
|
||||
<AppLoading />
|
||||
) : groups.length === 0 ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('options_none')}
|
||||
</Typography>
|
||||
) : (
|
||||
groups.map((group) => {
|
||||
const isMissing = optionsError && group.isRequired && selectedOptions[group.id] == null;
|
||||
return (
|
||||
<Stack key={group.id} sx={{ gap: 1 }}>
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{pickCatalogName(group, locale)}
|
||||
</Typography>
|
||||
{/* The required badge turns red on a blocked advance to point at the unanswered group. */}
|
||||
<Chip
|
||||
size="small"
|
||||
label={group.isRequired ? t('required_badge') : t('optional_badge')}
|
||||
sx={{
|
||||
bgcolor: isMissing
|
||||
? 'var(--bal-error)'
|
||||
: group.isRequired
|
||||
? 'var(--bal-primary-soft)'
|
||||
: 'var(--bal-divider)',
|
||||
color: isMissing
|
||||
? 'var(--bal-error-contrast)'
|
||||
: group.isRequired
|
||||
? 'var(--bal-primary)'
|
||||
: 'text.secondary',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
<ToggleButtonGroup
|
||||
exclusive
|
||||
size="small"
|
||||
color="primary"
|
||||
value={selectedOptions[group.id] ?? null}
|
||||
onChange={(_event, valueId: number | null) => changeOption(group.id, valueId)}
|
||||
sx={{ flexWrap: 'wrap' }}
|
||||
>
|
||||
{group.values.map((value) => (
|
||||
<ToggleButton key={value.id} value={value.id}>
|
||||
{pickCatalogName(value, locale)}
|
||||
</ToggleButton>
|
||||
))}
|
||||
</ToggleButtonGroup>
|
||||
</Stack>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{optionsError && missingRequiredGroups.length > 0 ? (
|
||||
<Typography variant="body2" sx={{ color: 'var(--bal-error)', fontWeight: 600 }}>
|
||||
{t('options_incomplete')}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Stack>
|
||||
) : null}
|
||||
|
||||
{activeStep === 2 ? (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('price_title')}
|
||||
</Typography>
|
||||
</Box>
|
||||
{priceStep}
|
||||
</Stack>
|
||||
) : null}
|
||||
|
||||
<Stack direction="row" sx={{ gap: 1, justifyContent: 'space-between', mt: 1 }}>
|
||||
<AppButton
|
||||
variant="text"
|
||||
onClick={activeStep === 0 ? onCancel : () => setActiveStep((step) => step - 1)}
|
||||
disabled={submitting}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{activeStep === 0 ? tc('cancel') : tc('back')}
|
||||
</AppButton>
|
||||
|
||||
{activeStep === 0 ? (
|
||||
<AppButton
|
||||
color="primary"
|
||||
variant="contained"
|
||||
onClick={() => setActiveStep(1)}
|
||||
disabled={categoryId == null}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{t('next')}
|
||||
</AppButton>
|
||||
) : activeStep === 1 ? (
|
||||
<AppButton color="primary" variant="contained" onClick={goNextFromOptions} sx={{ m: 0 }}>
|
||||
{t('next')}
|
||||
</AppButton>
|
||||
) : (
|
||||
<AppButton color="primary" variant="contained" onClick={submit} disabled={submitting} sx={{ m: 0 }}>
|
||||
{submitting ? tc('saving') : t('submit_create')}
|
||||
</AppButton>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default VariantBuilder;
|
||||
@@ -0,0 +1,35 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import type { NurseServiceVariant } from '@/services/catalog/types';
|
||||
import MyServicesList from './MyServicesList';
|
||||
import VariantBuilder from './VariantBuilder';
|
||||
|
||||
type BuilderState = { open: false } | { open: true; editing: NurseServiceVariant | null };
|
||||
|
||||
/**
|
||||
* Nurse **Services & prices** (the services half of B7). Switches in-page between the offerings list
|
||||
* and the variant builder (create/edit) — a stepper is roomier than a dialog, and colocating the
|
||||
* mode keeps the flow (list → build → back to list) simple with no extra routes. The `key` remounts
|
||||
* the builder so create/edit/another-variant each start from clean state.
|
||||
*/
|
||||
export default function NurseServicesPage() {
|
||||
const [builder, setBuilder] = useState<BuilderState>({ open: false });
|
||||
|
||||
if (builder.open) {
|
||||
return (
|
||||
<VariantBuilder
|
||||
key={builder.editing?.id ?? 'new'}
|
||||
initial={builder.editing}
|
||||
onDone={() => setBuilder({ open: false })}
|
||||
onCancel={() => setBuilder({ open: false })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MyServicesList
|
||||
onAdd={() => setBuilder({ open: true, editing: null })}
|
||||
onEdit={(variant) => setBuilder({ open: true, editing: variant })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user