diff --git a/client/CLAUDE.md b/client/CLAUDE.md index 421b984..edcd2d9 100644 --- a/client/CLAUDE.md +++ b/client/CLAUDE.md @@ -118,7 +118,8 @@ client/ │ │ ├── select-role/page.tsx # /select-role — first-use role picker (no public role yet); role router lands here │ │ ├── (customer)/ # Customer (family) app — mobile-first, bottom-tab nav; no URL segment │ │ │ ├── layout.tsx # 'use client' — wraps CustomerLayout - │ │ │ ├── page.tsx # / (A5 home — 'use client'; first-login onboarding gate + record/profile nudges) + │ │ │ ├── page.tsx # / (A5 home — 'use client'; greeting+avatar, search bar, data-driven category grid, first-login onboarding gate + record/profile nudges) + │ │ │ ├── search/page.tsx # /search — DEFERRED→f6 stub (PlaceholderScreen; Home search bar + category tiles land here carrying q/category_id) │ │ │ ├── onboarding/page.tsx # /onboarding — A3→A4 wizard (relation → first patient) │ │ │ ├── bookings/page.tsx # /bookings │ │ │ ├── patients/page.tsx # /patients — E1 list/CRUD (add/edit dialog reusing PatientForm, soft-archive) @@ -129,6 +130,7 @@ client/ │ │ │ ├── layout.tsx # 'use client' — wraps NurseLayout │ │ │ ├── page.tsx # /nurse (dashboard) │ │ │ ├── profile/page.tsx # /nurse/profile — B7 profile bootstrap (avatar+bio+years; unverified placeholder) + │ │ │ ├── services/ # /nurse/services — B7 services half: offerings list ↔ variant builder (page.tsx switches mode; MyServicesList + VariantBuilder co-located) │ │ │ ├── coverage/page.tsx # /nurse/coverage — F3 coverage-area editor (whole-city/district areas, dup-blocked) │ │ │ ├── bank/page.tsx # /nurse/bank — payout IBAN + ownership states (pending/verified/mismatch) │ │ │ ├── verification/page.tsx # /nurse/verification @@ -153,6 +155,9 @@ client/ │ ├── PatientForm/ # A4 patient form (name/age/gender/conditions/relation) — reused create+edit │ ├── PatientCard/ # E1 patient summary card + edit/archive actions │ ├── BankStatusPanel/ # Nurse bank-account ownership state (pending/verified/mismatch), masked IBAN + │ ├── CategoryTile/ # f4 tappable service-category tile (icon+label; `selected` state for the builder) — Home grid + builder step 1 (tested) + │ ├── PriceDisplay/ # f4 price renderer: money-util Toman + i18n unit label + unit-aware estimated total (never a total from price alone) (tested) + │ ├── VariantCard/ # f4 nurse offering card: display_name, PriceDisplay, active/deactivated distinction, edit/deactivate (no delete) (tested) │ ├── geography/ # F3 geo composites: CascadingRegionSelect, AddressMapPicker (map-pin stand-in), AddressForm, AddressCard (each tested) │ └── auth/ # Auth-flow composites: LoginFlow, PhoneStep, OtpStep, RoleRouter, SelectRole, AuthCard, BrandMark, AuthSplash, useCountdown ├── i18n/ @@ -201,6 +206,7 @@ client/ │ ├── geography/ # F3 cached province→city→district reference lookups (Infinity staleTime, shared geographyKeys; reused by addresses, coverage & later search) │ ├── addresses/ # F3 customer address book CRUD + set-primary (single-primary invariant; invalidate-on-mutation) │ ├── serviceAreas/ # F3 nurse coverage areas add/remove (areaExists dup-guard; districtId=null = whole city) + │ ├── catalog/ # F4 catalog skeleton + nurse pricing variants (b5). Reference data (categories, category option groups) cached session-long like geography (Infinity staleTime); myVariants invalidated on mutation. useServiceCategories/useCategoryOptionGroups/useMyVariants/useCreateVariant/useUpdateVariant/useSetVariantActive; seam+mock+client; names.ts locale-label helper │ └── {domain}/ │ ├── types.ts # Request/response types + the domain's Api interface (the seam) │ ├── keys.ts # React Query key factory (hierarchical) @@ -284,13 +290,16 @@ async function MyServerComponent() { - `'shell'` — actor-shell titles + the not-yet-built placeholder body - `'patients'` — the E1 patient list/CRUD (list, card, add/edit dialog, archive) - `'onboarding'` — the A3→A4 wizard + the shared enum labels (relation/condition/gender codes → labels) -- `'home'` — the A5 family home (greeting + record/profile nudges) +- `'home'` — the A5 family home (greeting + avatar, search bar, category grid, record/profile nudges) - `'profile'` — the customer profile + emergency contact - `'nurseProfile'` — the nurse B7 profile bootstrap (photo/bio/years + unverified placeholder) - `'bank'` — the nurse payout bank settings (IBAN form + the three ownership states) - `'geo'` — the shared cascading province→city→district dropdowns (`CascadingRegionSelect`: level labels, "whole city", cascade hints) - `'address'` — the customer address book + add/edit form (title/street, map-pin helper, set-primary, empty/delete states) + the profile-hub link - `'coverage'` — the nurse coverage-area editor (whole-city/specific-district scope, chips, duplicate + "won't appear in search" warnings) +- `'catalog'` — **shared** catalog vocabulary: the five `price_unit` labels + count nouns + the estimated-total label (read by `PriceDisplay`; f6 reuses it customer-side) +- `'services'` — the f4 nurse Services & prices surface (offerings list, the variant builder steps/fields/validation, the duplicate-listing warning, deactivate confirm) +- `'search'` — the f4 deferred `/search` placeholder (title + "arrives next phase" + query/category echo); f6 fills it out - `'auth'` — the phone-OTP login flow, role router, and SelectRole screen (`common.brand`/`brand_tagline` for the wordmark) **Namespace conventions for the phases to come** (seed each when its feature lands, in both locale @@ -532,7 +541,10 @@ Every domain follows the same shape: `types.ts` (wire types + the domain's `Api` level is fetched **once** and served from cache across every consumer (the address form, the coverage editor, and later search) — never refetched on a dropdown open. Contrast with mutable lists (addresses, coverage areas) which invalidate on every mutation. See `services/geography/*`. Reuse this pattern for future - reference data; do not reinvent per-consumer fetching. + reference data; do not reinvent per-consumer fetching. **`services/catalog` (f4) is the second long-lived + cached reference domain:** admin-seeded categories + a category's option groups/values use the same Infinite + `staleTime`/`gcTime` (`CATALOG_REFERENCE_*`) so the Home grid and every builder step read them from cache; + the nurse's own **variant list** is the mutable side — mutations invalidate `catalogKeys.myVariantsLists()`. - **Mock behind a seam:** when the backend endpoint isn't live, implement the domain's `Api` interface twice — a real `clientApi.ts` and an in-memory `mockApi.ts` — and select in `apis/index.ts` by a config flag (`USE_{DOMAIN}_MOCK`). Hooks import the selected `api`; the swap is one line. Record every mock in diff --git a/client/messages/en.json b/client/messages/en.json index cb62e4a..67005eb 100644 --- a/client/messages/en.json +++ b/client/messages/en.json @@ -7,6 +7,7 @@ "profile": "Profile", "bank": "Bank account", "coverage": "Coverage", + "services": "Services", "dashboard": "Dashboard", "verification": "Verification", "visits": "Visits", @@ -43,8 +44,14 @@ "placeholder_body": "This area will be built in a later phase." }, "home": { - "greeting": "Welcome to Balinyaar", + "greeting_named": "Hi, {name}", + "greeting_plain": "Hi there", "subtitle": "Manage care for the people you love.", + "search_placeholder": "Search a service or nurse…", + "search_action": "Search", + "categories_title": "Services", + "categories_error": "Couldn't load services.", + "categories_empty": "No services have been set up yet.", "nudge_patient_title": "Complete the patient record", "nudge_patient_body": "Add conditions, medications and routine so nurses arrive prepared.", "nudge_patient_cta": "Open patients", @@ -134,7 +141,7 @@ "unverified_title": "Your profile isn't active yet", "unverified_body": "Complete identity verification to appear to families and receive bookings.", "unverified_cta": "Complete verification", - "deferred_services": "You'll add your services & prices and available days in a later step." + "deferred_services": "Manage your services & prices in the Services section; available days come in a later step." }, "bank": { "title": "Bank account", @@ -235,6 +242,79 @@ "remove_confirm": "Remove", "removed": "Coverage area removed" }, + "catalog": { + "unit_per_hour": "hourly", + "unit_per_session": "per session", + "unit_per_half_day": "per half-day", + "unit_per_day": "daily", + "unit_per_24h": "24-hour (live-in)", + "count_per_hour": "hours", + "count_per_session": "sessions", + "count_per_half_day": "half-days", + "count_per_day": "days", + "count_per_24h": "days (24h)", + "estimate_label": "Estimated total", + "estimate_for": "for {count} {unit}" + }, + "services": { + "title": "Services & prices", + "subtitle": "The services you offer and what each costs.", + "add": "Add service", + "empty_title": "No services yet", + "empty_body": "Add at least one active, priced service so families can find and book you.", + "active_chip": "Active", + "inactive_chip": "Deactivated", + "inactive_hint": "This service is deactivated and can't be booked.", + "edit": "Edit", + "deactivate": "Deactivate", + "activate": "Reactivate", + "deactivate_title": "Deactivate this service?", + "deactivate_body": "It becomes unbookable and drops out of search. You can reactivate it anytime — it is never deleted.", + "deactivate_confirm": "Deactivate", + "deactivated_toast": "Service deactivated", + "activated_toast": "Service reactivated", + "toggle_error": "That couldn't be done. Please try again.", + "builder_add_title": "Add a new service", + "builder_edit_title": "Edit service", + "step_category": "Category", + "step_options": "Options", + "step_price": "Price", + "category_title": "Choose the service category", + "category_subtitle": "Every service belongs to one category.", + "categories_error": "Couldn't load categories. Please try again.", + "category_locked": "The category can't be changed after a service is created.", + "options_title": "Set the service options", + "options_subtitle": "Pick one option for each required item.", + "options_none": "This category has no options to configure.", + "required_badge": "Required", + "optional_badge": "Optional", + "options_incomplete": "Answer every required item to continue.", + "price_title": "Set the price and unit", + "price_label": "Price (Toman)", + "price_hint": "Enter the price in Toman.", + "price_required": "Enter a valid price greater than zero.", + "unit_label": "Price unit", + "duration_label": "Duration / count", + "duration_hint": "Optional — add a duration to preview the estimated total.", + "rate_note": "This is the base rate per unit, not the full total.", + "display_name_label": "Display name", + "display_name_hint": "Auto-generated from your options — you can edit it.", + "duplicate_warning": "You already have a service with these exact details. Change an option or the category.", + "summary_options": "Options", + "summary_none": "No options", + "next": "Next", + "submit_create": "Add service", + "submit_save": "Save changes", + "create_error": "The service couldn't be saved. Check your inputs and try again.", + "created_toast": "Service added", + "saved_toast": "Changes saved" + }, + "search": { + "title": "Search", + "deferred": "Search and results arrive in the next phase.", + "query_echo": "You searched for “{query}”.", + "category_echo": "Filtered to the selected service category." + }, "auth": { "customer_title": "Sign in to Balinyaar", "customer_subtitle": "Sign in with your mobile number", diff --git a/client/messages/fa.json b/client/messages/fa.json index 483d6fd..2edd221 100644 --- a/client/messages/fa.json +++ b/client/messages/fa.json @@ -7,6 +7,7 @@ "profile": "پروفایل", "bank": "حساب بانکی", "coverage": "پوشش", + "services": "خدمات", "dashboard": "داشبورد", "verification": "احراز هویت", "visits": "ویزیت‌ها", @@ -43,8 +44,14 @@ "placeholder_body": "این بخش در فازهای بعدی تکمیل می‌شود." }, "home": { - "greeting": "به بلینیار خوش آمدید", + "greeting_named": "سلام، {name}", + "greeting_plain": "سلام", "subtitle": "مراقبت از عزیزانتان را مدیریت کنید.", + "search_placeholder": "جستجوی خدمت یا پرستار…", + "search_action": "جستجو", + "categories_title": "خدمات", + "categories_error": "خدمات بارگذاری نشد.", + "categories_empty": "هنوز خدمتی تعریف نشده است.", "nudge_patient_title": "تکمیل پروندهٔ بیمار", "nudge_patient_body": "وضعیت‌ها، داروها و روتین را اضافه کنید تا پرستار آماده حاضر شود.", "nudge_patient_cta": "مشاهدهٔ بیماران", @@ -134,7 +141,7 @@ "unverified_title": "پروفایل شما هنوز فعال نیست", "unverified_body": "برای نمایش به خانواده‌ها و دریافت رزرو، احراز هویت را تکمیل کنید.", "unverified_cta": "تکمیل احراز هویت", - "deferred_services": "خدمات و قیمت‌ها و روزهای کاری را در مرحله بعد اضافه می‌کنید." + "deferred_services": "خدمات و قیمت‌های خود را در بخش «خدمات و قیمت‌ها» مدیریت کنید؛ روزهای کاری در مرحله بعد اضافه می‌شود." }, "bank": { "title": "حساب بانکی", @@ -235,6 +242,79 @@ "remove_confirm": "حذف", "removed": "منطقهٔ تحت پوشش حذف شد" }, + "catalog": { + "unit_per_hour": "ساعتی", + "unit_per_session": "هر جلسه", + "unit_per_half_day": "نیم‌روزی", + "unit_per_day": "روزانه", + "unit_per_24h": "شبانه‌روزی", + "count_per_hour": "ساعت", + "count_per_session": "جلسه", + "count_per_half_day": "نیم‌روز", + "count_per_day": "روز", + "count_per_24h": "شبانه‌روز", + "estimate_label": "برآورد کل", + "estimate_for": "برای {count} {unit}" + }, + "services": { + "title": "خدمات و قیمت‌ها", + "subtitle": "خدماتی که ارائه می‌دهید و قیمت هرکدام.", + "add": "افزودن خدمت", + "empty_title": "هنوز خدمتی اضافه نکرده‌اید", + "empty_body": "برای دیده‌شدن و دریافت رزرو، حداقل یک خدمت فعال و دارای قیمت اضافه کنید.", + "active_chip": "فعال", + "inactive_chip": "غیرفعال", + "inactive_hint": "این خدمت غیرفعال است و قابل رزرو نیست.", + "edit": "ویرایش", + "deactivate": "غیرفعال‌سازی", + "activate": "فعال‌سازی دوباره", + "deactivate_title": "این خدمت غیرفعال شود؟", + "deactivate_body": "این خدمت قابل رزرو نخواهد بود و از نتایج جستجو حذف می‌شود. هر زمان می‌توانید دوباره فعالش کنید — هرگز حذف نمی‌شود.", + "deactivate_confirm": "غیرفعال کن", + "deactivated_toast": "خدمت غیرفعال شد", + "activated_toast": "خدمت دوباره فعال شد", + "toggle_error": "این عملیات انجام نشد. دوباره تلاش کنید.", + "builder_add_title": "افزودن خدمت جدید", + "builder_edit_title": "ویرایش خدمت", + "step_category": "دسته", + "step_options": "گزینه‌ها", + "step_price": "قیمت", + "category_title": "دستهٔ خدمت را انتخاب کنید", + "category_subtitle": "هر خدمت به یک دسته تعلق دارد.", + "categories_error": "دسته‌ها بارگذاری نشد. دوباره تلاش کنید.", + "category_locked": "دسته پس از ایجاد خدمت قابل تغییر نیست.", + "options_title": "گزینه‌های خدمت را مشخص کنید", + "options_subtitle": "برای هر مورد الزامی یک گزینه انتخاب کنید.", + "options_none": "این دسته گزینه‌ای برای تنظیم ندارد.", + "required_badge": "الزامی", + "optional_badge": "اختیاری", + "options_incomplete": "برای ادامه، همهٔ موارد الزامی را انتخاب کنید.", + "price_title": "قیمت و واحد را تعیین کنید", + "price_label": "قیمت (تومان)", + "price_hint": "قیمت را به تومان وارد کنید.", + "price_required": "قیمتی معتبر و بزرگ‌تر از صفر وارد کنید.", + "unit_label": "واحد قیمت", + "duration_label": "مدت / تعداد", + "duration_hint": "اختیاری — برای نمایش برآورد هزینهٔ کل، مدت را وارد کنید.", + "rate_note": "این نرخ پایه به ازای هر واحد است، نه هزینهٔ کل.", + "display_name_label": "نام نمایشی", + "display_name_hint": "به‌صورت خودکار از گزینه‌ها ساخته می‌شود؛ می‌توانید تغییرش دهید.", + "duplicate_warning": "شما قبلاً خدمتی با همین مشخصات دارید. یک گزینه یا دسته را تغییر دهید.", + "summary_options": "گزینه‌ها", + "summary_none": "بدون گزینه", + "next": "بعدی", + "submit_create": "ثبت خدمت", + "submit_save": "ذخیره تغییرات", + "create_error": "ثبت خدمت انجام نشد. ورودی‌ها را بررسی کرده و دوباره تلاش کنید.", + "created_toast": "خدمت اضافه شد", + "saved_toast": "تغییرات ذخیره شد" + }, + "search": { + "title": "جستجو", + "deferred": "جستجو و نتایج در فاز بعدی اضافه می‌شود.", + "query_echo": "جستجوی شما: «{query}».", + "category_echo": "محدود به دستهٔ خدمت انتخاب‌شده." + }, "auth": { "customer_title": "ورود به بلینیار", "customer_subtitle": "با شماره موبایل خود وارد شوید", diff --git a/client/src/app/[locale]/(private-routes)/(customer)/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/page.tsx index a3813fe..2c8344d 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/page.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/page.tsx @@ -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 = ({ 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 ( - - - {t('greeting')} - - - {t('subtitle')} - - + + + {avatarInitial ?? } + + + + {greeting} + + + {t('subtitle')} + + + + + + + router.push(`${href(ROUTES.SEARCH)}?category_id=${categoryId}`)} /> ); } + +/** + * 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 ( + + setQuery(event.target.value)} + placeholder={t('search_placeholder')} + aria-label={t('search_action')} + slotProps={{ + input: { + startAdornment: ( + + + + ), + }, + }} + /> + + ); +}; + +/** 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 ( + + + {t('categories_title')} + + + {isLoading ? ( + + {[0, 1, 2, 3].map((key) => ( + + ))} + + ) : isError ? ( + + + {t('categories_error')} + + refetch()} sx={{ m: 0 }}> + {tc('retry')} + + + ) : categories.length === 0 ? ( + + + {t('categories_empty')} + + + ) : ( + + {categories.map((category) => ( + onSelect(category.id)} + /> + ))} + + )} + + ); +}; diff --git a/client/src/app/[locale]/(private-routes)/(customer)/search/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/search/page.tsx new file mode 100644 index 0000000..2584344 --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/(customer)/search/page.tsx @@ -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 ( + }> + + + ); +} + +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 ( + + ); +} diff --git a/client/src/app/[locale]/(private-routes)/nurse/services/MyServicesList.tsx b/client/src/app/[locale]/(private-routes)/nurse/services/MyServicesList.tsx new file mode 100644 index 0000000..bdcbbab --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/nurse/services/MyServicesList.tsx @@ -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 = ({ onAdd, onEdit }) => { + const t = useTranslations('services'); + const tc = useTranslations('common'); + const { enqueueSnackbar } = useSnackbar(); + + const { data, isLoading } = useMyVariants(); + const setActive = useSetVariantActive(); + const [deactivateTarget, setDeactivateTarget] = useState(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 ( + + + + + {t('title')} + + + {t('subtitle')} + + + {!isEmpty && !isLoading ? ( + + {t('add')} + + ) : null} + + + {isLoading ? ( + + {[0, 1].map((key) => ( + + ))} + + ) : isEmpty ? ( + + + + {t('empty_title')} + + + {t('empty_body')} + + + {t('add')} + + + ) : ( + + {variants.map((variant) => ( + onEdit(variant)} + onToggleActive={() => toggleActive(variant)} + /> + ))} + + )} + + setDeactivateTarget(null)}> + {t('deactivate_title')} + + + {t('deactivate_body')} + + + + setDeactivateTarget(null)}> + {tc('cancel')} + + + {t('deactivate_confirm')} + + + + + ); +}; + +export default MyServicesList; diff --git a/client/src/app/[locale]/(private-routes)/nurse/services/VariantBuilder.tsx b/client/src/app/[locale]/(private-routes)/nurse/services/VariantBuilder.tsx new file mode 100644 index 0000000..aa0ec69 --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/nurse/services/VariantBuilder.tsx @@ -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 = ({ 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(initial?.serviceCategoryId ?? null); + const [selectedOptions, setSelectedOptions] = useState>({}); + 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(initial?.priceUnit ?? DEFAULT_UNIT); + const [durationStr, setDurationStr] = useState(initial?.sessionCount ? String(initial.sessionCount) : ''); + const [displayNameOverride, setDisplayNameOverride] = useState(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 = ( + + { + 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 + /> + + + setPriceUnit(event.target.value as PriceUnit)} + fullWidth + > + {PRICE_UNITS.map((unit) => ( + + {tCatalog(`unit_${unit}`)} + + ))} + + + setDurationStr(digitsOnly(event.target.value).slice(0, MAX_DURATION_DIGITS))} + helperText={t('duration_hint')} + slotProps={{ htmlInput: { dir: 'ltr', inputMode: 'numeric', style: { textAlign: 'start' } } }} + fullWidth + /> + + + {irr ? ( + + + {!sessionCount ? ( + + {t('rate_note')} + + ) : null} + + ) : null} + + setDisplayNameOverride(event.target.value)} + helperText={t('display_name_hint')} + fullWidth + /> + + {duplicate ? ( + + + {t('duplicate_warning')} + + + ) : null} + + ); + + // --- Edit mode: locked category + options summary, then the price form --- + if (isEdit) { + return ( + + + {t('builder_edit_title')} + + + + + + {pickCatalogName({ nameFa: initial.categoryNameFa, nameEn: initial.categoryNameEn }, locale)} + + + {t('category_locked')} + + + {initial.options.length > 0 ? ( + initial.options.map((option) => ( + + )) + ) : ( + + {t('summary_none')} + + )} + + + + + {priceStep} + + + + {tc('cancel')} + + + {submitting ? tc('saving') : t('submit_save')} + + + + ); + } + + // --- Create mode: the 3-step stepper --- + return ( + + + {t('builder_add_title')} + + + + + {activeStep === 0 ? ( + + + + {t('category_title')} + + + {t('category_subtitle')} + + + {categoriesQuery.isLoading ? ( + + {[0, 1, 2, 3].map((key) => ( + + ))} + + ) : categoriesQuery.isError ? ( + + + {t('categories_error')} + + categoriesQuery.refetch()} sx={{ m: 0 }}> + {tc('retry')} + + + ) : ( + + {categories.map((category) => ( + selectCategory(category.id)} + /> + ))} + + )} + + ) : null} + + {activeStep === 1 ? ( + + + + {t('options_title')} + + + {t('options_subtitle')} + + + + {optionGroupsQuery.isLoading ? ( + + ) : groups.length === 0 ? ( + + {t('options_none')} + + ) : ( + groups.map((group) => { + const isMissing = optionsError && group.isRequired && selectedOptions[group.id] == null; + return ( + + + + {pickCatalogName(group, locale)} + + {/* The required badge turns red on a blocked advance to point at the unanswered group. */} + + + changeOption(group.id, valueId)} + sx={{ flexWrap: 'wrap' }} + > + {group.values.map((value) => ( + + {pickCatalogName(value, locale)} + + ))} + + + ); + }) + )} + + {optionsError && missingRequiredGroups.length > 0 ? ( + + {t('options_incomplete')} + + ) : null} + + ) : null} + + {activeStep === 2 ? ( + + + + {t('price_title')} + + + {priceStep} + + ) : null} + + + setActiveStep((step) => step - 1)} + disabled={submitting} + sx={{ m: 0 }} + > + {activeStep === 0 ? tc('cancel') : tc('back')} + + + {activeStep === 0 ? ( + setActiveStep(1)} + disabled={categoryId == null} + sx={{ m: 0 }} + > + {t('next')} + + ) : activeStep === 1 ? ( + + {t('next')} + + ) : ( + + {submitting ? tc('saving') : t('submit_create')} + + )} + + + ); +}; + +export default VariantBuilder; diff --git a/client/src/app/[locale]/(private-routes)/nurse/services/page.tsx b/client/src/app/[locale]/(private-routes)/nurse/services/page.tsx new file mode 100644 index 0000000..f76a48b --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/nurse/services/page.tsx @@ -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({ open: false }); + + if (builder.open) { + return ( + setBuilder({ open: false })} + onCancel={() => setBuilder({ open: false })} + /> + ); + } + + return ( + setBuilder({ open: true, editing: null })} + onEdit={(variant) => setBuilder({ open: true, editing: variant })} + /> + ); +} diff --git a/client/src/components/CategoryTile/CategoryTile.test.tsx b/client/src/components/CategoryTile/CategoryTile.test.tsx new file mode 100644 index 0000000..1fc8886 --- /dev/null +++ b/client/src/components/CategoryTile/CategoryTile.test.tsx @@ -0,0 +1,39 @@ +import { FunctionComponent } from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { ThemeProvider } from '../../theme'; +import CategoryTile, { CategoryTileProps } from './CategoryTile'; + +const ComponentToTest: FunctionComponent = (props) => ( + + + +); + +describe(' component', () => { + it('renders the localised label', () => { + render(); + expect(screen.getByText('Elderly Care')).toBeInTheDocument(); + }); + + it('renders a known category icon for a mapped iconKey', () => { + const { container } = render(); + expect(container.querySelector('[data-icon="elderly"]')).toBeInTheDocument(); + }); + + it('falls back to the generic category icon for an unknown or missing iconKey', () => { + const { container } = render(); + expect(container.querySelector('[data-icon="category"]')).toBeInTheDocument(); + }); + + it('calls onClick when tapped', () => { + const onClick = jest.fn(); + render(); + fireEvent.click(screen.getByRole('button')); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('exposes its selected state for the builder category step', () => { + render(); + expect(screen.getByRole('button')).toHaveAttribute('aria-pressed', 'true'); + }); +}); diff --git a/client/src/components/CategoryTile/CategoryTile.tsx b/client/src/components/CategoryTile/CategoryTile.tsx new file mode 100644 index 0000000..8e3fc86 --- /dev/null +++ b/client/src/components/CategoryTile/CategoryTile.tsx @@ -0,0 +1,80 @@ +import { FunctionComponent } from 'react'; +import { Box, ButtonBase, Typography } from '@mui/material'; +import AppIcon from '../common/AppIcon'; + +/** + * Service-category `iconKey`s we render with a dedicated icon. Any other key (or a missing one from + * real data whose icons we don't control) falls back to the generic `category` icon, so the tile is + * robust regardless of what the backend seeds — never an AppIcon "not found" warning. + */ +const KNOWN_CATEGORY_ICONS = new Set(['elderly', 'post_surgery', 'infant', 'chronic', 'companionship']); + +function resolveIcon(iconKey?: string | null): string { + return iconKey && KNOWN_CATEGORY_ICONS.has(iconKey) ? iconKey : 'category'; +} + +export interface CategoryTileProps { + /** Category name, already localised by the caller (`nameFa`/`nameEn` picked by locale). */ + label: string; + /** Optional backend icon hint; unknown/missing keys fall back to a generic category icon. */ + iconKey?: string | null; + /** Tapping the tile carries the category into the (future f6) search flow, or selects it in the builder. */ + onClick?: () => void; + /** Selected state (the nurse builder's category step); the Home grid leaves it unset. */ + selected?: boolean; +} + +/** + * A tappable tile for one service category — the customer Home grid (data-driven, one per + * `service_category`) and the nurse builder's category step (with `selected`). Icon in a soft-teal + * disc over the localised label; the whole tile is a button so it's keyboard- and screen-reader- + * accessible. RTL-safe (no directional hard-coding). + * @component CategoryTile + */ +const CategoryTile: FunctionComponent = ({ label, iconKey, onClick, selected = false }) => ( + + + + + + {label} + + +); + +export default CategoryTile; diff --git a/client/src/components/CategoryTile/index.tsx b/client/src/components/CategoryTile/index.tsx new file mode 100644 index 0000000..8bec1a5 --- /dev/null +++ b/client/src/components/CategoryTile/index.tsx @@ -0,0 +1,2 @@ +export { default } from './CategoryTile'; +export type { CategoryTileProps } from './CategoryTile'; diff --git a/client/src/components/PriceDisplay/PriceDisplay.test.tsx b/client/src/components/PriceDisplay/PriceDisplay.test.tsx new file mode 100644 index 0000000..e0c6883 --- /dev/null +++ b/client/src/components/PriceDisplay/PriceDisplay.test.tsx @@ -0,0 +1,40 @@ +import { render, screen } from '@testing-library/react'; +import { ThemeProvider } from '../../theme'; + +// next-intl is mocked to echo keys (and honour {count} in estimate_for), locale = en so the money +// util groups with ASCII digits we can assert on. +jest.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, + useLocale: () => 'en', +})); + +import PriceDisplay from './PriceDisplay'; + +function renderPrice(props: React.ComponentProps) { + return render( + + + , + ); +} + +describe(' component', () => { + it('renders the price as grouped Toman with the unit label off the price_unit code', () => { + // 2,800,000 IRR = 280,000 Toman, per hour. + renderPrice({ price: '2800000', priceUnit: 'per_hour' }); + expect(screen.getByText(/280,000/)).toBeInTheDocument(); + expect(screen.getByText('unit_per_hour')).toBeInTheDocument(); + }); + + it('does not render an estimated total from price alone (no sessionCount)', () => { + renderPrice({ price: '2800000', priceUnit: 'per_hour', showEstimate: true }); + expect(screen.queryByText('estimate_label', { exact: false })).not.toBeInTheDocument(); + }); + + it('renders the unit-aware estimated total = price × sessionCount when asked', () => { + // 2,800,000 IRR/hr × 6 = 16,800,000 IRR = 1,680,000 Toman. + renderPrice({ price: '2800000', priceUnit: 'per_hour', sessionCount: 6, showEstimate: true }); + expect(screen.getByText(/estimate_label/)).toBeInTheDocument(); + expect(screen.getByText(/1,680,000/)).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/PriceDisplay/PriceDisplay.tsx b/client/src/components/PriceDisplay/PriceDisplay.tsx new file mode 100644 index 0000000..a43927d --- /dev/null +++ b/client/src/components/PriceDisplay/PriceDisplay.tsx @@ -0,0 +1,64 @@ +'use client'; +import { FunctionComponent } from 'react'; +import { useLocale, useTranslations } from 'next-intl'; +import { Stack, Typography } from '@mui/material'; +import { formatIrrToToman, multiplyIrr } from '@/utils'; +import type { PriceUnit } from '@/services/catalog/types'; + +export interface PriceDisplayProps { + /** IRR Rials as a digit-string (wire shape). Rendered as grouped Toman via the money util. */ + price: string; + /** Drives the unit label — an i18n key off the code, **never** a label hardcoded in the component. */ + priceUnit: PriceUnit; + /** Duration/count. Required for the estimated total — a total is never derived from `price` alone. */ + sessionCount?: number | null; + /** When true and a `sessionCount` is present, also render the unit-aware estimated total line. */ + showEstimate?: boolean; + align?: 'start' | 'center'; +} + +/** + * Renders a variant's price as `{Toman} {unit}` (e.g. «۲۸۰٬۰۰۰ تومان ساعتی») and, when asked, the + * unit-aware estimated total. The bare price is a **unit rate**: the estimated total is only ever + * `price` × `sessionCount`, computed integer-safe (BigInt) — never `price` alone. Money is formatted + * only through the f0 money util; the unit label is an i18n key off `price_unit`. + * @component PriceDisplay + */ +const PriceDisplay: FunctionComponent = ({ + price, + priceUnit, + sessionCount, + showEstimate = false, + align = 'start', +}) => { + const t = useTranslations('catalog'); + const tc = useTranslations('common'); + const locale = useLocale(); + + const amount = formatIrrToToman(price, locale); + const unitLabel = t(`unit_${priceUnit}`); + const hasEstimate = Boolean(showEstimate && sessionCount && sessionCount > 0); + const total = hasEstimate ? formatIrrToToman(multiplyIrr(price, sessionCount as number), locale) : null; + const countLabel = hasEstimate + ? new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US').format(sessionCount as number) + : ''; + + return ( + + + {amount} {tc('currency_toman')}{' '} + + {unitLabel} + + + {hasEstimate ? ( + + {t('estimate_label')}: {total} {tc('currency_toman')} ·{' '} + {t('estimate_for', { count: countLabel, unit: t(`count_${priceUnit}`) })} + + ) : null} + + ); +}; + +export default PriceDisplay; diff --git a/client/src/components/PriceDisplay/index.tsx b/client/src/components/PriceDisplay/index.tsx new file mode 100644 index 0000000..ab508cb --- /dev/null +++ b/client/src/components/PriceDisplay/index.tsx @@ -0,0 +1,2 @@ +export { default } from './PriceDisplay'; +export type { PriceDisplayProps } from './PriceDisplay'; diff --git a/client/src/components/VariantCard/VariantCard.test.tsx b/client/src/components/VariantCard/VariantCard.test.tsx new file mode 100644 index 0000000..fbc9992 --- /dev/null +++ b/client/src/components/VariantCard/VariantCard.test.tsx @@ -0,0 +1,68 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { ThemeProvider } from '../../theme'; +import type { NurseServiceVariant } from '@/services/catalog/types'; + +jest.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, + useLocale: () => 'en', +})); + +import VariantCard from './VariantCard'; + +const baseVariant: NurseServiceVariant = { + id: 1, + serviceCategoryId: 1, + categoryNameFa: 'مراقبت از سالمند', + categoryNameEn: 'Elderly Care', + price: '2800000', // 280,000 Toman + priceUnit: 'per_hour', + sessionCount: null, + displayName: 'Elderly Care · Live-in', + isActive: true, + options: [], +}; + +function renderCard(variant: NurseServiceVariant, onEdit = jest.fn(), onToggleActive = jest.fn()) { + render( + + + , + ); + return { onEdit, onToggleActive }; +} + +describe(' component', () => { + it('renders the display name, category, and price', () => { + renderCard(baseVariant); + expect(screen.getByText('Elderly Care · Live-in')).toBeInTheDocument(); + expect(screen.getByText('Elderly Care')).toBeInTheDocument(); + expect(screen.getByText(/280,000/)).toBeInTheDocument(); + }); + + it('shows the active chip and the deactivate action for an active variant', () => { + renderCard(baseVariant); + expect(screen.getByText('active_chip')).toBeInTheDocument(); + expect(screen.getByText('deactivate')).toBeInTheDocument(); + expect(screen.queryByText('inactive_hint')).not.toBeInTheDocument(); + }); + + it('shows the deactivated distinction (chip, hint, reactivate action) for an inactive variant', () => { + renderCard({ ...baseVariant, isActive: false }); + expect(screen.getByText('inactive_chip')).toBeInTheDocument(); + expect(screen.getByText('inactive_hint')).toBeInTheDocument(); + expect(screen.getByText('activate')).toBeInTheDocument(); + }); + + it('never renders a delete affordance', () => { + renderCard(baseVariant); + expect(screen.queryByText('delete')).not.toBeInTheDocument(); + }); + + it('fires onEdit and onToggleActive from the row actions', () => { + const { onEdit, onToggleActive } = renderCard(baseVariant); + fireEvent.click(screen.getByText('edit')); + expect(onEdit).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByText('deactivate')); + expect(onToggleActive).toHaveBeenCalledTimes(1); + }); +}); diff --git a/client/src/components/VariantCard/VariantCard.tsx b/client/src/components/VariantCard/VariantCard.tsx new file mode 100644 index 0000000..43435d1 --- /dev/null +++ b/client/src/components/VariantCard/VariantCard.tsx @@ -0,0 +1,97 @@ +'use client'; +import { FunctionComponent } from 'react'; +import { useLocale, useTranslations } from 'next-intl'; +import { Box, Paper, Stack, Typography } from '@mui/material'; +import { pickCatalogName } from '@/services/catalog/names'; +import type { NurseServiceVariant } from '@/services/catalog/types'; +import AppButton from '../common/AppButton'; +import StatusChip from '../StatusChip'; +import PriceDisplay from '../PriceDisplay'; + +export interface VariantCardProps { + variant: NurseServiceVariant; + /** Open the builder in edit mode for this variant. */ + onEdit: () => void; + /** Deactivate (when active) or reactivate (when inactive) — soft only, never a delete. */ + onToggleActive: () => void; +} + +/** + * One nurse offering (variant) in the services list: its `display_name`, the category, the price via + * the money util (`PriceDisplay`), and an active/deactivated distinction (dimmed + a neutral chip + + * an "can't be booked" hint on inactive rows). Row actions are Edit and Deactivate/Reactivate — there + * is **no delete affordance**. RTL-safe. + * @component VariantCard + */ +const VariantCard: FunctionComponent = ({ variant, onEdit, onToggleActive }) => { + const t = useTranslations('services'); + const locale = useLocale(); + const active = variant.isActive; + const categoryName = pickCatalogName( + { nameFa: variant.categoryNameFa, nameEn: variant.categoryNameEn }, + locale, + ); + + return ( + + + + + + {variant.displayName} + + + {categoryName} + + + + + + + + {!active ? ( + + {t('inactive_hint')} + + ) : null} + + + + {t('edit')} + + + {active ? t('deactivate') : t('activate')} + + + + + ); +}; + +export default VariantCard; diff --git a/client/src/components/VariantCard/index.tsx b/client/src/components/VariantCard/index.tsx new file mode 100644 index 0000000..fbdf4ed --- /dev/null +++ b/client/src/components/VariantCard/index.tsx @@ -0,0 +1,2 @@ +export { default } from './VariantCard'; +export type { VariantCardProps } from './VariantCard'; diff --git a/client/src/components/common/AppIcon/config.ts b/client/src/components/common/AppIcon/config.ts index 431d260..80fe620 100644 --- a/client/src/components/common/AppIcon/config.ts +++ b/client/src/components/common/AppIcon/config.ts @@ -40,6 +40,14 @@ import WarningIcon from '@mui/icons-material/WarningAmberOutlined'; import LocationIcon from '@mui/icons-material/LocationOnOutlined'; import DeleteIcon from '@mui/icons-material/DeleteOutlined'; import CoverageIcon from '@mui/icons-material/MapOutlined'; +// Catalog — nurse services surface + the customer Home service-category grid (f4/b5) +import ServicesIcon from '@mui/icons-material/LocalOfferOutlined'; +import CategoryIcon from '@mui/icons-material/CategoryOutlined'; +import ElderlyIcon from '@mui/icons-material/ElderlyOutlined'; +import PostSurgeryIcon from '@mui/icons-material/HealingOutlined'; +import InfantIcon from '@mui/icons-material/ChildCareOutlined'; +import ChronicIcon from '@mui/icons-material/MonitorHeartOutlined'; +import CompanionshipIcon from '@mui/icons-material/VolunteerActivismOutlined'; /** * List of all available Icon names @@ -95,4 +103,11 @@ export const ICONS /* Note: Setting type disables property autocomplete :( was - location: LocationIcon, delete: DeleteIcon, coverage: CoverageIcon, + services: ServicesIcon, + category: CategoryIcon, + elderly: ElderlyIcon, + post_surgery: PostSurgeryIcon, + infant: InfantIcon, + chronic: ChronicIcon, + companionship: CompanionshipIcon, }; diff --git a/client/src/components/index.tsx b/client/src/components/index.tsx index 15a2870..3ed1cd7 100644 --- a/client/src/components/index.tsx +++ b/client/src/components/index.tsx @@ -12,6 +12,9 @@ import RelationSelect from './RelationSelect'; import PatientCard from './PatientCard'; import PatientForm from './PatientForm'; import BankStatusPanel from './BankStatusPanel'; +import CategoryTile from './CategoryTile'; +import PriceDisplay from './PriceDisplay'; +import VariantCard from './VariantCard'; export { UserInfo, @@ -26,6 +29,9 @@ export { PatientCard, PatientForm, BankStatusPanel, + CategoryTile, + PriceDisplay, + VariantCard, }; export type { PlaceholderScreenProps } from './PlaceholderScreen'; export type { OtpInputProps } from './OtpInput'; @@ -38,3 +44,6 @@ export type { RelationSelectProps, RelationOption } from './RelationSelect'; export type { PatientCardProps } from './PatientCard'; export type { PatientFormProps } from './PatientForm'; export type { BankStatusPanelProps } from './BankStatusPanel'; +export type { CategoryTileProps } from './CategoryTile'; +export type { PriceDisplayProps } from './PriceDisplay'; +export type { VariantCardProps } from './VariantCard'; diff --git a/client/src/constants/routes.ts b/client/src/constants/routes.ts index 91a07a8..82b9845 100644 --- a/client/src/constants/routes.ts +++ b/client/src/constants/routes.ts @@ -7,6 +7,8 @@ export const ROUTES = { HOME: '/', // First-login "who is care for?" flow (A3→A4); re-enterable from the patient list. ONBOARDING: '/onboarding', + // Search & discovery — the Home search bar + category tiles navigate here (results built in f6). + SEARCH: '/search', BOOKINGS: '/bookings', PATIENTS: '/patients', // Address book — cascading region dropdowns + map-pin picker; reached from the profile hub. @@ -17,6 +19,8 @@ export const ROUTES = { // Nurse app NURSE: '/nurse', NURSE_PROFILE: '/nurse/profile', + // Services & prices — the nurse variant builder + offerings list (B7 services half). + NURSE_SERVICES: '/nurse/services', // Coverage-area editor — the cities/districts the nurse will travel to (feeds f6 search). NURSE_COVERAGE: '/nurse/coverage', NURSE_BANK: '/nurse/bank', diff --git a/client/src/layout/NurseLayout.tsx b/client/src/layout/NurseLayout.tsx index 06e4e58..bbe4d8d 100644 --- a/client/src/layout/NurseLayout.tsx +++ b/client/src/layout/NurseLayout.tsx @@ -19,6 +19,7 @@ const NurseLayout: FunctionComponent = ({ children }) => { () => [ { title: t('dashboard'), path: ROUTES.NURSE, icon: 'dashboard' }, { title: t('profile'), path: ROUTES.NURSE_PROFILE, icon: 'profile' }, + { title: t('services'), path: ROUTES.NURSE_SERVICES, icon: 'services' }, { title: t('coverage'), path: ROUTES.NURSE_COVERAGE, icon: 'coverage' }, { title: t('bank'), path: ROUTES.NURSE_BANK, icon: 'bank' }, { title: t('verification'), path: ROUTES.NURSE_VERIFICATION, icon: 'verification' }, diff --git a/client/src/services/catalog/apis/clientApi.ts b/client/src/services/catalog/apis/clientApi.ts new file mode 100644 index 0000000..ba7b2cd --- /dev/null +++ b/client/src/services/catalog/apis/clientApi.ts @@ -0,0 +1,76 @@ +import { clientFetch } from '@/lib/api/client'; +import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types'; +import { CATEGORIES_PAGE_SIZE, MY_VARIANTS_PAGE_SIZE } from '../constants'; +import type { + CatalogApi, + CreateVariantInput, + NurseServiceVariant, + ServiceCategory, + ServiceOptionGroup, + UpdateVariantInput, +} from '../types'; + +const CATALOG_BASE = '/api/v1/catalog'; +const VARIANTS_BASE = '/api/v1/nurse_variants'; + +/** + * Real HTTP implementation of the CatalogApi seam (b5 contract `dev/contracts/domains/catalog.md`). + * Routes are action-style + snake_case; JSON bodies/fields are camelCase; ids for edit/toggle come + * from the route, never the body. Mutations use POST. The duplicate-listing conflict surfaces as + * `409` — `clientFetch` throws `ApiError(409)` (no toast; "other 4xx"), which the builder maps to + * the inline duplicate warning. Selected once USE_CATALOG_MOCK is false. + * + * Pagination binds to the server's `PageSize` property (camelCase `pageSize`, case-insensitive), not + * snake_case `page_size` — matching the proven b4 `serviceAreas` client. The domain query param + * `category_id` is snake_case per the contract. + */ +export const catalogClientApi: CatalogApi = { + listCategories: async (params) => { + const query = new URLSearchParams(); + query.set('page', String(params?.page ?? 1)); + query.set('pageSize', String(params?.pageSize ?? CATEGORIES_PAGE_SIZE)); + return unwrap( + await clientFetch>>(`${CATALOG_BASE}/categories?${query.toString()}`), + ); + }, + + getCategoryOptionGroups: async (categoryId) => + unwrap( + await clientFetch>(`${CATALOG_BASE}/option_groups?category_id=${categoryId}`), + ), + + listMyVariants: async (params) => { + const query = new URLSearchParams(); + query.set('page', String(params?.page ?? 1)); + query.set('pageSize', String(params?.pageSize ?? MY_VARIANTS_PAGE_SIZE)); + return unwrap( + await clientFetch>>(`${VARIANTS_BASE}/list?${query.toString()}`), + ); + }, + + getVariant: async (id) => + unwrap(await clientFetch>(`${VARIANTS_BASE}/get/${id}`)), + + createVariant: async (input: CreateVariantInput) => + unwrap( + await clientFetch>(`${VARIANTS_BASE}/create`, { + method: 'POST', + body: JSON.stringify(input), + }), + ), + + updateVariant: async (id, input: UpdateVariantInput) => + unwrap( + await clientFetch>(`${VARIANTS_BASE}/update/${id}`, { + method: 'POST', + body: JSON.stringify(input), + }), + ), + + setVariantActive: async (id, isActive) => { + await clientFetch>(`${VARIANTS_BASE}/set_active/${id}`, { + method: 'POST', + body: JSON.stringify({ isActive }), + }); + }, +}; diff --git a/client/src/services/catalog/apis/index.ts b/client/src/services/catalog/apis/index.ts new file mode 100644 index 0000000..a29446e --- /dev/null +++ b/client/src/services/catalog/apis/index.ts @@ -0,0 +1,10 @@ +import { USE_CATALOG_MOCK } from '../constants'; +import type { CatalogApi } from '../types'; +import { catalogClientApi } from './clientApi'; +import { catalogMockApi } from './mockApi'; + +/** + * The selected CatalogApi implementation — the single seam the hooks import. Selection is by config + * (USE_CATALOG_MOCK), never by scattered `if (mock)` checks. + */ +export const catalogApi: CatalogApi = USE_CATALOG_MOCK ? catalogMockApi : catalogClientApi; diff --git a/client/src/services/catalog/apis/mockApi.ts b/client/src/services/catalog/apis/mockApi.ts new file mode 100644 index 0000000..a2fdf4a --- /dev/null +++ b/client/src/services/catalog/apis/mockApi.ts @@ -0,0 +1,208 @@ +import { sleep } from '@/utils'; +import { ApiError } from '@/lib/api/errors'; +import type { PageParams, Paginated } from '@/lib/api/types'; +import { CATEGORIES_PAGE_SIZE, MY_VARIANTS_PAGE_SIZE } from '../constants'; +import { + isGroupApplicable, + optionSetSignature, + type CatalogApi, + type CreateVariantInput, + type NurseServiceVariant, + type ServiceCategory, + type ServiceOptionGroup, + type ServiceOptionValue, + type UpdateVariantInput, + type VariantOption, + type VariantOptionSelection, +} from '../types'; +import { SEED_CATEGORIES, SEED_OPTION_GROUPS } from './seed'; + +const MOCK_LATENCY_MS = 250; +const DIGITS_ONLY = /^\d+$/; + +const bySortOrder = (a: T, b: T) => a.sortOrder - b.sortOrder; + +// The nurse's own offerings, seeded **empty** so the offerings empty-state demos; the nurse builds +// variants live (across price units), and the duplicate 409 is reachable by repeating a create. +let store: NurseServiceVariant[] = []; +let nextVariantId = 1; + +/** Active-first (contract list order), then most-recent within each group. */ +const orderedVariants = () => + [...store].sort((a, b) => Number(b.isActive) - Number(a.isActive) || b.id - a.id); + +function paginate(all: T[], params?: PageParams, defaultSize = 50): Paginated { + const page = params?.page ?? 1; + const pageSize = params?.pageSize ?? defaultSize; + const start = (page - 1) * pageSize; + return { items: all.slice(start, start + pageSize), total: all.length, page, pageSize }; +} + +function applicableGroups(categoryId: number): ServiceOptionGroup[] { + return SEED_OPTION_GROUPS.filter((group) => group.isActive && isGroupApplicable(group, categoryId)).sort( + bySortOrder, + ); +} + +function findValue(group: ServiceOptionGroup, valueId: number): ServiceOptionValue | undefined { + return group.values.find((value) => value.id === valueId && value.isActive); +} + +/** Builds the denormalised `options` array (group + value labels) from the chosen value ids. */ +function buildOptions(groups: ServiceOptionGroup[], selections: VariantOptionSelection[]): VariantOption[] { + return selections + .map((selection) => { + const group = groups.find((candidate) => candidate.id === selection.optionGroupId); + const value = group && findValue(group, selection.optionValueId); + if (!group || !value) return null; + return { + optionGroupId: group.id, + groupNameFa: group.nameFa, + groupNameEn: group.nameEn, + optionValueId: value.id, + valueNameFa: value.nameFa, + valueNameEn: value.nameEn, + } satisfies VariantOption; + }) + .filter((option): option is VariantOption => option !== null) + // Present in the group's configured order (mirrors how the server denormalises). + .sort((a, b) => { + const orderOf = (groupId: number) => groups.find((g) => g.id === groupId)?.sortOrder ?? 0; + return orderOf(a.optionGroupId) - orderOf(b.optionGroupId); + }); +} + +/** Auto-generates `displayName` from the category + chosen value labels (fa primary, contract style). */ +function autoDisplayName(category: ServiceCategory, options: VariantOption[]): string { + if (options.length === 0) return category.nameFa; + return [category.nameFa, ...options.map((option) => option.valueNameFa)].join(' · '); +} + +/** + * Validates a create request exactly as the server does and throws the matching `ApiError` so the + * builder exercises the real failure copy: 400 for a bad price/unit/session, a missing required + * dimension (named), a value not in its group, a group answered twice, or an unknown/inapplicable + * group/value; 409 for a duplicate identical listing. + */ +function assertValidCreate(category: ServiceCategory, groups: ServiceOptionGroup[], input: CreateVariantInput): void { + if (typeof input.price !== 'string' || !DIGITS_ONLY.test(input.price) || BigInt(input.price) <= BigInt(0)) { + throw new ApiError(400, 'Invalid price', 'invalid_price'); + } + if (input.sessionCount != null && (!Number.isInteger(input.sessionCount) || input.sessionCount <= 0)) { + throw new ApiError(400, 'Invalid session count', 'invalid_session_count'); + } + + const answeredGroupIds = new Set(); + for (const selection of input.options) { + const group = groups.find((candidate) => candidate.id === selection.optionGroupId); + if (!group) throw new ApiError(400, 'Unknown or inapplicable option group', 'unknown_group'); + if (answeredGroupIds.has(group.id)) throw new ApiError(400, 'A dimension was answered twice', 'duplicate_group'); + if (!findValue(group, selection.optionValueId)) { + throw new ApiError(400, 'Value does not belong to its group', 'invalid_value'); + } + answeredGroupIds.add(group.id); + } + + const missingRequired = groups.find((group) => group.isRequired && !answeredGroupIds.has(group.id)); + if (missingRequired) { + throw new ApiError(400, `Missing required dimension: ${missingRequired.nameFa}`, 'missing_required_dimension'); + } + + const signature = optionSetSignature( + category.id, + input.options.map((option) => option.optionValueId), + ); + const duplicate = store.some( + (variant) => optionSetSignature(variant.serviceCategoryId, variant.options.map((o) => o.optionValueId)) === signature, + ); + if (duplicate) { + throw new ApiError(409, 'A variant with these details already exists', 'duplicate_listing'); + } +} + +/** + * In-memory mock behind the CatalogApi seam. Enforces the server's required-dimension validation + * and the `(nurse, category, option-set)` duplicate 409 in-memory, so the builder's inline + * validation + duplicate warning are demonstrable end-to-end. Mirrors the real shapes for a one-line swap. + */ +export const catalogMockApi: CatalogApi = { + listCategories: async (params) => { + await sleep(MOCK_LATENCY_MS); + const active = SEED_CATEGORIES.filter((category) => category.isActive).sort(bySortOrder); + return paginate(active, params, CATEGORIES_PAGE_SIZE); + }, + + getCategoryOptionGroups: async (categoryId) => { + await sleep(MOCK_LATENCY_MS); + // Return only active values, ordered — mirror the server's projection. + return applicableGroups(categoryId).map((group) => ({ + ...group, + values: group.values.filter((value) => value.isActive).sort(bySortOrder), + })); + }, + + listMyVariants: async (params) => { + await sleep(MOCK_LATENCY_MS); + return paginate(orderedVariants(), params, MY_VARIANTS_PAGE_SIZE); + }, + + getVariant: async (id) => { + await sleep(MOCK_LATENCY_MS); + const variant = store.find((candidate) => candidate.id === id); + if (!variant) throw new ApiError(404, 'Variant not found', 'not_found'); + return variant; + }, + + createVariant: async (input) => { + await sleep(MOCK_LATENCY_MS); + const category = SEED_CATEGORIES.find((candidate) => candidate.isActive && candidate.id === input.serviceCategoryId); + if (!category) throw new ApiError(400, 'Missing or inactive category', 'invalid_category'); + + const groups = applicableGroups(category.id); + assertValidCreate(category, groups, input); + + const options = buildOptions(groups, input.options); + const displayName = input.displayName?.trim() ? input.displayName.trim() : autoDisplayName(category, options); + const variant: NurseServiceVariant = { + id: nextVariantId++, + serviceCategoryId: category.id, + categoryNameFa: category.nameFa, + categoryNameEn: category.nameEn, + price: input.price, + priceUnit: input.priceUnit, + sessionCount: input.sessionCount ?? null, + displayName, + isActive: true, + options, + }; + store = [variant, ...store]; + return variant; + }, + + updateVariant: async (id, input: UpdateVariantInput) => { + await sleep(MOCK_LATENCY_MS); + const existing = store.find((candidate) => candidate.id === id); + if (!existing) throw new ApiError(404, 'Variant not found', 'not_found'); + if (!DIGITS_ONLY.test(input.price) || BigInt(input.price) <= BigInt(0)) { + throw new ApiError(400, 'Invalid price', 'invalid_price'); + } + // The option-set is immutable on update — only price/unit/session/display change. A blank + // displayName leaves the current one unchanged. + const updated: NurseServiceVariant = { + ...existing, + price: input.price, + priceUnit: input.priceUnit, + sessionCount: input.sessionCount ?? null, + displayName: input.displayName?.trim() ? input.displayName.trim() : existing.displayName, + }; + store = store.map((candidate) => (candidate.id === id ? updated : candidate)); + return updated; + }, + + setVariantActive: async (id, isActive) => { + await sleep(MOCK_LATENCY_MS); + const existing = store.find((candidate) => candidate.id === id); + if (!existing) throw new ApiError(404, 'Variant not found', 'not_found'); + store = store.map((candidate) => (candidate.id === id ? { ...candidate, isActive } : candidate)); + }, +}; diff --git a/client/src/services/catalog/apis/seed.ts b/client/src/services/catalog/apis/seed.ts new file mode 100644 index 0000000..ad6f3a1 --- /dev/null +++ b/client/src/services/catalog/apis/seed.ts @@ -0,0 +1,98 @@ +import type { ServiceCategory, ServiceOptionGroup } from '../types'; + +/** + * Canned catalog skeleton for the client-side mock. The **categories mirror the b5 seed exactly** + * (ids 1–5, `sortOrder` 0–4, `nameFa`/`nameEn` from the contract "Seed" section) so swapping to the + * live endpoint returns the same set. `iconKey`s map to registered AppIcon names (the tile falls + * back gracefully for unknown/missing keys, matching real data whose `iconKey` we don't control). + * + * **Option groups/values are NOT seeded on a fresh real DB** (an admin authors them per category — + * see the contract). The mock seeds a representative set anyway so the builder's required/optional + * validation, cross-category groups, and the duplicate-listing 409 are all demonstrable end-to-end. + * This is the one place the mock is intentionally *richer* than a fresh backend; recorded in the + * mock registry. Real data comes from the server; this file is imported nowhere in the production path. + */ + +export const SEED_CATEGORIES: ServiceCategory[] = [ + { id: 1, nameFa: 'مراقبت از سالمند', nameEn: 'Elderly Care', descriptionFa: null, descriptionEn: null, iconKey: 'elderly', sortOrder: 0, isActive: true }, + { id: 2, nameFa: 'مراقبت پس از جراحی', nameEn: 'Post-Surgery Recovery', descriptionFa: null, descriptionEn: null, iconKey: 'post_surgery', sortOrder: 1, isActive: true }, + { id: 3, nameFa: 'مراقبت از نوزاد', nameEn: 'Infant Care', descriptionFa: null, descriptionEn: null, iconKey: 'infant', sortOrder: 2, isActive: true }, + { id: 4, nameFa: 'مدیریت بیماری مزمن', nameEn: 'Chronic Illness Management', descriptionFa: null, descriptionEn: null, iconKey: 'chronic', sortOrder: 3, isActive: true }, + { id: 5, nameFa: 'همراهی و مراقبت روزمره', nameEn: 'Companionship', descriptionFa: null, descriptionEn: null, iconKey: 'companionship', sortOrder: 4, isActive: true }, +]; + +/** + * Option groups keyed to categories, plus one cross-category (`serviceCategoryId: null`) group that + * applies to every category. Every row is active; the mock never returns inactive rows (the real + * server filters them). Values carry `isActive` so an inactive value would be dropped downstream. + */ +export const SEED_OPTION_GROUPS: ServiceOptionGroup[] = [ + { + id: 11, + serviceCategoryId: 1, + nameFa: 'نوع شیفت', + nameEn: 'Shift type', + isRequired: true, + sortOrder: 1, + isActive: true, + values: [ + { id: 101, nameFa: 'روزانه', nameEn: 'Daytime', sortOrder: 1, isActive: true }, + { id: 102, nameFa: 'شبانه', nameEn: 'Overnight', sortOrder: 2, isActive: true }, + { id: 103, nameFa: 'شبانه‌روزی', nameEn: 'Live-in', sortOrder: 3, isActive: true }, + ], + }, + { + id: 12, + serviceCategoryId: 1, + nameFa: 'تعداد بیمار', + nameEn: 'Number of patients', + isRequired: false, + sortOrder: 2, + isActive: true, + values: [ + { id: 111, nameFa: 'یک نفر', nameEn: 'One', sortOrder: 1, isActive: true }, + { id: 112, nameFa: 'دو نفر', nameEn: 'Two', sortOrder: 2, isActive: true }, + ], + }, + { + id: 21, + serviceCategoryId: 2, + nameFa: 'نوع مراقبت', + nameEn: 'Care type', + isRequired: true, + sortOrder: 1, + isActive: true, + values: [ + { id: 201, nameFa: 'پانسمان و مراقبت زخم', nameEn: 'Wound care & dressing', sortOrder: 1, isActive: true }, + { id: 202, nameFa: 'تزریقات و سرم', nameEn: 'Injections & IV', sortOrder: 2, isActive: true }, + { id: 203, nameFa: 'فیزیوتراپی سبک', nameEn: 'Light physiotherapy', sortOrder: 3, isActive: true }, + ], + }, + { + id: 31, + serviceCategoryId: 3, + nameFa: 'نوع خدمت', + nameEn: 'Service type', + isRequired: true, + sortOrder: 1, + isActive: true, + values: [ + { id: 301, nameFa: 'مراقبت روزانه نوزاد', nameEn: 'Daytime infant care', sortOrder: 1, isActive: true }, + { id: 302, nameFa: 'مراقبت شبانه نوزاد', nameEn: 'Overnight infant care', sortOrder: 2, isActive: true }, + ], + }, + { + // Cross-category: applies to every category (Chronic Illness & Companionship have only this one). + id: 90, + serviceCategoryId: null, + nameFa: 'محل ارائه خدمت', + nameEn: 'Service location', + isRequired: false, + sortOrder: 5, + isActive: true, + values: [ + { id: 901, nameFa: 'منزل', nameEn: 'Home', sortOrder: 1, isActive: true }, + { id: 902, nameFa: 'بیمارستان', nameEn: 'Hospital', sortOrder: 2, isActive: true }, + ], + }, +]; diff --git a/client/src/services/catalog/constants.ts b/client/src/services/catalog/constants.ts new file mode 100644 index 0000000..062b63e --- /dev/null +++ b/client/src/services/catalog/constants.ts @@ -0,0 +1,24 @@ +/** + * When true, the catalog domain is served by the in-memory mock (apis/mockApi.ts) behind the + * CatalogApi seam — the b5 `catalog` + `nurse_variants` routes exist, but the mock lets the Home + * grid and the nurse builder (incl. the required-option validation and the duplicate-listing 409) + * demo standalone before the backend is reachable in this environment. Flip to false to hit the + * live endpoints — no hook/component changes (see dev/shared-working-context/reports/mocks-registry.md). + */ +export const USE_CATALOG_MOCK = true; + +/** + * Categories and a category's option groups/values are **admin-seeded reference data** that changes + * rarely, so they are cached **for the whole session** (Infinite `staleTime`) exactly like the geo + * hierarchy — fetched once and served from cache across the Home grid and every builder step, never + * refetched per screen. A generous `gcTime` keeps them warm after the last consumer unmounts. + */ +export const CATALOG_REFERENCE_STALE_TIME = Infinity; +export const CATALOG_REFERENCE_GC_TIME = 24 * 60 * 60 * 1000; // 24h + +/** The nurse's own offerings change on mutation; keep warm across remounts, invalidate on write. */ +export const MY_VARIANTS_STALE_TIME = 60_000; + +/** api-conventions default/max page sizes. A nurse has a handful of offerings; categories are few. */ +export const CATEGORIES_PAGE_SIZE = 50; +export const MY_VARIANTS_PAGE_SIZE = 50; diff --git a/client/src/services/catalog/hooks/useCategoryOptionGroups.ts b/client/src/services/catalog/hooks/useCategoryOptionGroups.ts new file mode 100644 index 0000000..b10074b --- /dev/null +++ b/client/src/services/catalog/hooks/useCategoryOptionGroups.ts @@ -0,0 +1,20 @@ +import { useQuery } from '@tanstack/react-query'; +import { catalogApi } from '../apis'; +import { catalogKeys } from '../keys'; +import { CATALOG_REFERENCE_GC_TIME, CATALOG_REFERENCE_STALE_TIME } from '../constants'; + +/** + * A category's applicable option groups (its own + every cross-category group), each with its + * values. Reference data — cached per category for the whole session, so re-selecting a category in + * the builder never refetches. Disabled until a category is chosen. An **empty list is valid** (a + * category with no dimensions yet), not an error. + */ +export function useCategoryOptionGroups(categoryId: number | null | undefined) { + return useQuery({ + queryKey: catalogKeys.categoryOptionGroups(categoryId), + queryFn: () => catalogApi.getCategoryOptionGroups(categoryId as number), + enabled: categoryId != null, + staleTime: CATALOG_REFERENCE_STALE_TIME, + gcTime: CATALOG_REFERENCE_GC_TIME, + }); +} diff --git a/client/src/services/catalog/hooks/useCreateVariant.ts b/client/src/services/catalog/hooks/useCreateVariant.ts new file mode 100644 index 0000000..1848965 --- /dev/null +++ b/client/src/services/catalog/hooks/useCreateVariant.ts @@ -0,0 +1,20 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { catalogApi } from '../apis'; +import { catalogKeys } from '../keys'; +import type { CreateVariantInput } from '../types'; + +/** + * Creates a priced variant, then invalidates the nurse's offerings list. A duplicate identical + * listing returns `409` — surfaced via `mutation.error` so the builder shows the inline + * duplicate-listing warning (never a generic toast). A missing required dimension / bad price + * returns `400`, likewise surfaced inline. + */ +export function useCreateVariant() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (input: CreateVariantInput) => catalogApi.createVariant(input), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: catalogKeys.myVariantsLists() }); + }, + }); +} diff --git a/client/src/services/catalog/hooks/useMyVariants.ts b/client/src/services/catalog/hooks/useMyVariants.ts new file mode 100644 index 0000000..ab116d3 --- /dev/null +++ b/client/src/services/catalog/hooks/useMyVariants.ts @@ -0,0 +1,21 @@ +import { useQuery } from '@tanstack/react-query'; +import { useIsAuthenticated } from '@/hooks'; +import type { PageParams } from '@/lib/api/types'; +import { catalogApi } from '../apis'; +import { catalogKeys } from '../keys'; +import { MY_VARIANTS_STALE_TIME } from '../constants'; + +/** + * The signed-in nurse's own offerings (active and inactive, active-first, paginated). Self-scoped + * server-side — a nurse never sees another nurse's variants. A deliberate staleTime keeps the list + * warm across remounts; every variant mutation invalidates it. + */ +export function useMyVariants(params?: PageParams) { + const isAuthenticated = useIsAuthenticated(); + return useQuery({ + queryKey: catalogKeys.myVariants(params), + queryFn: () => catalogApi.listMyVariants(params), + enabled: isAuthenticated, + staleTime: MY_VARIANTS_STALE_TIME, + }); +} diff --git a/client/src/services/catalog/hooks/useServiceCategories.ts b/client/src/services/catalog/hooks/useServiceCategories.ts new file mode 100644 index 0000000..e914216 --- /dev/null +++ b/client/src/services/catalog/hooks/useServiceCategories.ts @@ -0,0 +1,19 @@ +import { useQuery } from '@tanstack/react-query'; +import type { PageParams } from '@/lib/api/types'; +import { catalogApi } from '../apis'; +import { catalogKeys } from '../keys'; +import { CATALOG_REFERENCE_GC_TIME, CATALOG_REFERENCE_STALE_TIME } from '../constants'; + +/** + * The active service categories (ordered by `sortOrder`). Admin-seeded **reference data** — cached + * for the whole session (Infinite `staleTime`), so the Home grid and the builder's step 1 populate + * once and are served from cache on every revisit. Public (no auth) — always enabled. + */ +export function useServiceCategories(params?: PageParams) { + return useQuery({ + queryKey: catalogKeys.categories(), + queryFn: () => catalogApi.listCategories(params), + staleTime: CATALOG_REFERENCE_STALE_TIME, + gcTime: CATALOG_REFERENCE_GC_TIME, + }); +} diff --git a/client/src/services/catalog/hooks/useSetVariantActive.ts b/client/src/services/catalog/hooks/useSetVariantActive.ts new file mode 100644 index 0000000..9ad4f94 --- /dev/null +++ b/client/src/services/catalog/hooks/useSetVariantActive.ts @@ -0,0 +1,19 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { catalogApi } from '../apis'; +import { catalogKeys } from '../keys'; + +/** + * Soft-deactivates or reactivates a variant (`set_active`) — **never a hard delete**. A deactivated + * variant is unbookable and drops out of search. The offerings list shows inactive rows, so this + * hook drives both the deactivate-with-confirm action and the reactivate affordance on an inactive + * row. On success it invalidates the offerings list so the row flips to its new visual state. + */ +export function useSetVariantActive() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, isActive }: { id: number; isActive: boolean }) => catalogApi.setVariantActive(id, isActive), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: catalogKeys.myVariantsLists() }); + }, + }); +} diff --git a/client/src/services/catalog/hooks/useUpdateVariant.ts b/client/src/services/catalog/hooks/useUpdateVariant.ts new file mode 100644 index 0000000..dd34e9c --- /dev/null +++ b/client/src/services/catalog/hooks/useUpdateVariant.ts @@ -0,0 +1,20 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { catalogApi } from '../apis'; +import { catalogKeys } from '../keys'; +import type { NurseServiceVariant, UpdateVariantInput } from '../types'; + +/** + * Edits a variant's price/unit/session/display (the option-set is immutable). On success it primes + * the single-variant cache with the returned row and invalidates the offerings list — the edited + * row reflects immediately without a wasteful full refetch of every page. + */ +export function useUpdateVariant() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, input }: { id: number; input: UpdateVariantInput }) => catalogApi.updateVariant(id, input), + onSuccess: (variant: NurseServiceVariant) => { + queryClient.setQueryData(catalogKeys.variant(variant.id), variant); + queryClient.invalidateQueries({ queryKey: catalogKeys.myVariantsLists() }); + }, + }); +} diff --git a/client/src/services/catalog/index.ts b/client/src/services/catalog/index.ts new file mode 100644 index 0000000..3b40d97 --- /dev/null +++ b/client/src/services/catalog/index.ts @@ -0,0 +1,6 @@ +export { useServiceCategories } from './hooks/useServiceCategories'; +export { useCategoryOptionGroups } from './hooks/useCategoryOptionGroups'; +export { useMyVariants } from './hooks/useMyVariants'; +export { useCreateVariant } from './hooks/useCreateVariant'; +export { useUpdateVariant } from './hooks/useUpdateVariant'; +export { useSetVariantActive } from './hooks/useSetVariantActive'; diff --git a/client/src/services/catalog/keys.ts b/client/src/services/catalog/keys.ts new file mode 100644 index 0000000..b5d826d --- /dev/null +++ b/client/src/services/catalog/keys.ts @@ -0,0 +1,22 @@ +import type { PageParams } from '@/lib/api/types'; + +/** + * React Query key factory for the catalog domain. Reference data (categories, a category's option + * groups) is keyed so each is fetched **once** per session and served from cache across the Home + * grid and the nurse builder. The nurse's own variant list invalidates on every mutation via the + * `myVariantsLists()` prefix; a single edited variant is refreshed by `variant(id)`. + */ +export const catalogKeys = { + all: ['catalog'] as const, + + // Reference data — cached for the whole session, never invalidated by this phase. + categories: () => [...catalogKeys.all, 'categories'] as const, + categoryOptionGroups: (categoryId?: number | null) => + [...catalogKeys.all, 'option-groups', categoryId ?? null] as const, + + // The nurse's offerings — mutable; mutations invalidate the `myVariantsLists()` prefix. + variants: () => [...catalogKeys.all, 'variants'] as const, + myVariantsLists: () => [...catalogKeys.variants(), 'mine'] as const, + myVariants: (params?: PageParams) => [...catalogKeys.myVariantsLists(), params ?? {}] as const, + variant: (id: number) => [...catalogKeys.variants(), 'detail', id] as const, +}; diff --git a/client/src/services/catalog/names.ts b/client/src/services/catalog/names.ts new file mode 100644 index 0000000..51549c0 --- /dev/null +++ b/client/src/services/catalog/names.ts @@ -0,0 +1,10 @@ +import type { CatalogName } from './types'; + +/** + * Picks the locale-appropriate name for any catalog row (category, option group, option value). + * `fa` is the default locale; any non-`en` locale reads the Persian name. Kept here (not in a + * component) so the Home grid, the builder, and later search all label catalog rows identically. + */ +export function pickCatalogName(row: CatalogName, locale: string): string { + return locale === 'en' ? row.nameEn : row.nameFa; +} diff --git a/client/src/services/catalog/types.ts b/client/src/services/catalog/types.ts new file mode 100644 index 0000000..f0c0ec2 --- /dev/null +++ b/client/src/services/catalog/types.ts @@ -0,0 +1,158 @@ +import type { PageParams, Paginated } from '@/lib/api/types'; + +/** + * Catalog domain — the admin-seeded skeleton (categories → option groups → option values) and + * the nurse pricing layer (variants — the atomic bookable unit). Shapes mirror the b5 contract + * (`dev/contracts/domains/catalog.md`) exactly. The wire is **camelCase** (`nameFa`/`serviceCategoryId`/ + * `priceUnit`), not the snake_case the routing convention implies — the swagger snapshot is the source + * of truth. + * + * Load-bearing semantics (see the contract "Key semantics"): + * - The bookable unit is the **variant**, never the nurse or the category. + * - `price` is **IRR Rials, integer, a string of digits** (e.g. `"2800000"`) — never a float, never Toman. + * The engagement total is `price` + `priceUnit` + `sessionCount`, never `price` alone. + * - A `serviceCategoryId = null` option group is **cross-category** — it applies to every category. + * - Categories/option groups/values/variants are **reference data + soft-deactivated**, never hard-deleted. + */ + +/** The five price units a variant's `price` may be quoted in (closed enum; the only hardcoded catalog set). */ +export type PriceUnit = 'per_hour' | 'per_session' | 'per_half_day' | 'per_day' | 'per_24h'; + +/** Ordered for the price-unit select; labels are i18n keys off the code (never derived from the code). */ +export const PRICE_UNITS: readonly PriceUnit[] = [ + 'per_hour', + 'per_session', + 'per_half_day', + 'per_day', + 'per_24h', +] as const; + +/** Fields every localisable catalog row carries — `nameFa` is primary, the client picks by locale. */ +export interface CatalogName { + nameFa: string; + nameEn: string; +} + +/** `ServiceCategoryDto` — a top-level service category (Elderly Care, Post-Surgery Recovery, …). */ +export interface ServiceCategory extends CatalogName { + id: number; + descriptionFa: string | null; + descriptionEn: string | null; + /** Optional icon hint; the tile maps known keys to a registered icon and falls back gracefully. */ + iconKey: string | null; + sortOrder: number; + isActive: boolean; +} + +/** `OptionValueDto` — a concrete answer within an option group (e.g. شبانه‌روزی / Live-in). */ +export interface ServiceOptionValue extends CatalogName { + id: number; + sortOrder: number; + isActive: boolean; +} + +/** + * `OptionGroupDto` — a configurable dimension for a category (e.g. نوع شیفت / Shift type). + * `serviceCategoryId = null` ⇒ **cross-category** (applies to every category). `isRequired` groups + * must be answered (one value) before a variant can be created. + */ +export interface ServiceOptionGroup extends CatalogName { + id: number; + serviceCategoryId: number | null; + isRequired: boolean; + sortOrder: number; + isActive: boolean; + values: ServiceOptionValue[]; +} + +/** `VariantOptionDto` — one answered dimension on a variant (denormalised group + value labels). */ +export interface VariantOption { + optionGroupId: number; + groupNameFa: string; + groupNameEn: string; + optionValueId: number; + valueNameFa: string; + valueNameEn: string; +} + +/** `VariantDto` — a nurse's priced offering: category + chosen option values + own price + unit. */ +export interface NurseServiceVariant { + id: number; + serviceCategoryId: number; + categoryNameFa: string; + categoryNameEn: string; + /** IRR Rials as a string of digits (money-and-types.md). Rendered only via the money util. */ + price: string; + priceUnit: PriceUnit; + sessionCount: number | null; + displayName: string; + isActive: boolean; + options: VariantOption[]; +} + +/** One answered dimension in a create request — one value per group (mirrors the server UNIQUE). */ +export interface VariantOptionSelection { + optionGroupId: number; + optionValueId: number; +} + +/** + * `nurse_variants/create` body. `price` is the IRR digit-string (converted from Toman at the field + * boundary). Omit `displayName` to let the server auto-generate it from the category + value labels. + */ +export interface CreateVariantInput { + serviceCategoryId: number; + options: VariantOptionSelection[]; + price: string; + priceUnit: PriceUnit; + sessionCount?: number | null; + displayName?: string | null; +} + +/** + * `nurse_variants/update/{id}` body — edits price/unit/session/display only. The option-set is + * **immutable** on update (changing dimensions = create-new + deactivate-old). A blank `displayName` + * leaves the current one unchanged. + */ +export interface UpdateVariantInput { + price: string; + priceUnit: PriceUnit; + sessionCount?: number | null; + displayName?: string | null; +} + +/** + * The catalog domain's API seam — the real HTTP client and the in-memory mock both implement this + * interface; selection is by config (`USE_CATALOG_MOCK`), never scattered `if (mock)` checks. + */ +export interface CatalogApi { + /** Public reference data — active categories, ordered by `sortOrder`, paginated + cached. */ + listCategories(params?: PageParams): Promise>; + /** A category's applicable option groups (its own + every cross-category group), with values. Cached. */ + getCategoryOptionGroups(categoryId: number): Promise; + /** The signed-in nurse's own offerings — active and inactive, active-first, paginated. */ + listMyVariants(params?: PageParams): Promise>; + getVariant(id: number): Promise; + createVariant(input: CreateVariantInput): Promise; + updateVariant(id: number, input: UpdateVariantInput): Promise; + /** Soft deactivate/reactivate — never a hard delete. */ + setVariantActive(id: number, isActive: boolean): Promise; +} + +/** + * The applicable option groups for a category = its own groups **plus** every cross-category + * (`serviceCategoryId === null`) group. The server already returns exactly this set from + * `getCategoryOptionGroups`; this predicate documents the rule and is used by the mock. + */ +export function isGroupApplicable(group: ServiceOptionGroup, categoryId: number): boolean { + return group.serviceCategoryId === null || group.serviceCategoryId === categoryId; +} + +/** + * A stable, order-independent signature of a variant's answered option-set, used for the + * duplicate-listing guard (same nurse + same category + identical option-set → 409). The server's + * uniqueness is the source of truth; this lets the mock reproduce the 409 and the builder pre-warn. + */ +export function optionSetSignature(categoryId: number, valueIds: number[]): string { + return `${categoryId}:${[...valueIds].sort((a, b) => a - b).join(',')}`; +} diff --git a/client/src/utils/money.test.ts b/client/src/utils/money.test.ts index c4e394e..b0aba97 100644 --- a/client/src/utils/money.test.ts +++ b/client/src/utils/money.test.ts @@ -1,4 +1,4 @@ -import { parseIrr, rialToToman, formatIrr, formatIrrToToman } from './money'; +import { parseIrr, rialToToman, tomanToRial, multiplyIrr, formatIrr, formatIrrToToman } from './money'; describe('money utils', () => { describe('parseIrr', () => { @@ -29,6 +29,33 @@ describe('money utils', () => { }); }); + describe('tomanToRial', () => { + it('multiplies Toman by 10 into an IRR digit-string', () => { + expect(tomanToRial('280000')).toBe('2800000'); + expect(tomanToRial(280000)).toBe('2800000'); + }); + + it('stays integer-safe for large Toman amounts', () => { + expect(tomanToRial('900719925474099')).toBe('9007199254740990'); + }); + + it('rejects non-integer input', () => { + expect(() => tomanToRial('12.5')).toThrow(); + }); + }); + + describe('multiplyIrr', () => { + it('multiplies an IRR amount by an integer count', () => { + // 280,000 Toman/hr = 2,800,000 IRR × 6 hours = 16,800,000 IRR. + expect(multiplyIrr('2800000', 6)).toBe('16800000'); + }); + + it('rejects a negative or non-integer count', () => { + expect(() => multiplyIrr('2800000', -1)).toThrow(); + expect(() => multiplyIrr('2800000', 1.5)).toThrow(); + }); + }); + describe('formatting', () => { it('groups Rials in en locale', () => { expect(formatIrr('23300000', 'en')).toBe('23,300,000'); diff --git a/client/src/utils/money.ts b/client/src/utils/money.ts index ee7d87a..8c93fca 100644 --- a/client/src/utils/money.ts +++ b/client/src/utils/money.ts @@ -27,6 +27,26 @@ export function rialToToman(value: string | number | bigint): bigint { return parseIrr(value) / RIALS_PER_TOMAN; } +/** + * Converts a whole-Toman amount (what the nurse types in the price field) to the IRR Rial + * digit-string the wire expects (×10). This is the sanctioned Toman→Rial conversion at the + * *field boundary* (money-and-types.md): the UI collects Toman, the contract carries Rials. + * Integer-safe (BigInt); never a float on the price path. + */ +export function tomanToRial(value: string | number | bigint): string { + return String(parseIrr(value) * RIALS_PER_TOMAN); +} + +/** + * Multiplies an IRR Rial amount by an integer count, returning the IRR digit-string. Used for + * the builder's unit-aware estimated total (`price` × `session_count`) — a total is only ever + * `price` interpreted by its `price_unit` and multiplied by the count, never `price` alone. + */ +export function multiplyIrr(value: string | number | bigint, count: number): string { + if (!Number.isInteger(count) || count < 0) throw new Error(`count must be a non-negative integer, got ${count}`); + return String(parseIrr(value) * BigInt(count)); +} + /** Formats a grouped Rial amount (no unit). `fa` uses Persian digits. */ export function formatIrr(value: string | number | bigint, locale: string = 'fa'): string { return new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US').format(parseIrr(value)); diff --git a/dev/shared-working-context/frontend/STATUS.md b/dev/shared-working-context/frontend/STATUS.md index d152ad5..52c0af9 100644 --- a/dev/shared-working-context/frontend/STATUS.md +++ b/dev/shared-working-context/frontend/STATUS.md @@ -12,6 +12,36 @@ for awareness. - **Requests filed:** frontend/requests/for-backend.md (yes/no) --> +## frontend-phase-4-b5 — Catalog browse (Home A5) & nurse service builder (B7) — 2026-07-05 +- **Shipped:** `services/catalog` domain (types/keys/constants/apis[client+mock+seam]/hooks/index) — the b5 + catalog skeleton + nurse pricing layer. Hooks: `useServiceCategories`, `useCategoryOptionGroups` (both + **session-cached reference data**, Infinite `staleTime`), `useMyVariants` (paginated, self-scoped), + `useCreateVariant`/`useUpdateVariant`/`useSetVariantActive` (mutations invalidate `catalogKeys.myVariantsLists()`; + update `setQueryData`s the row). Shared composites (each tested): **`CategoryTile`** (data-driven Home tile + + builder `selected` state), **`PriceDisplay`** (money-util Toman + i18n unit label + unit-aware estimated total), + **`VariantCard`** (offering card, active/deactivated distinction, no delete). Money util gained `tomanToRial` + (field-boundary Toman→IRR) + `multiplyIrr` (integer-safe estimate). Screens: **customer Home (A5)** — greeting + + avatar, search bar (navigates toward f6 `/search`, results deferred), **data-driven category grid** + (loading/empty/error), patient nudge (reuses cached f2 `usePatients`, no new fetch); **nurse Services & prices + (B7)** at `/nurse/services` (new sidebar tab) — offerings list (active/inactive, edit, soft deactivate w/ confirm, + reactivate, empty/skeleton) + **3-step variant builder** (category → required/optional options → price+unit+duration; + required-group gate; Toman→IRR digit-string submit; live unit-aware total; editable auto `displayName`; inline + `409` duplicate warning; locked-category edit form). Added `catalog`/`services`/`search` i18n namespaces + `home` + additions + `nav.services` (both locales); 7 icons (`services`,`category`,`elderly`,`post_surgery`,`infant`, + `chronic`,`companionship`); routes `SEARCH`,`NURSE_SERVICES`; deferred `/search` placeholder stub (→ f6). +- **Consumes:** dev/contracts/domains/catalog.md (backend-phase-5). Routes `api/v1/catalog/{categories,option_groups}`, + `api/v1/nurse_variants/{create,update/{id},set_active/{id},list,get/{id}}`. Wire camelCase; `price_unit` enum; + IRR-string money; `409` duplicate listing / `400` missing required dimension. +- **Mocked client-side:** `services/catalog` via `catalogMockApi` behind `USE_CATALOG_MOCK` (default `true`) — seeds + the 5 real b5 categories + representative option groups (incl. a cross-category one) + the `409`/`400` rules; + variant store seeded **empty** so the offerings empty-state demos. Real `catalogClientApi` wired for a one-line + flip. See mocks-registry + the report (note: the mock seeds option groups the fresh backend does not — an admin + authors them). +- **Gate:** npm run check green · npm run test:ci green (147 tests, +18 across 4 suites: catalog components + money) + · npm run build green with NEXT_PUBLIC_API_URL set (routes /nurse/services, /search generated; home prerenders). +- **Requests filed:** frontend/requests/for-backend.md — yes (REQ-010 confirm the list pagination query-param name + `pageSize` vs the doc's `page_size`). + ## frontend-phase-3-b4 — Addresses, map picker & nurse coverage areas — 2026-07-03 - **Shipped:** three domain services — `services/geography` (cached province→city→district reference lookups; **Infinity `staleTime`** + shared `geographyKeys`; `useProvinces`/`useCities`/`useDistricts`; seam+mock+client), diff --git a/dev/shared-working-context/frontend/requests/for-backend.md b/dev/shared-working-context/frontend/requests/for-backend.md index fe921c5..cbff73f 100644 --- a/dev/shared-working-context/frontend/requests/for-backend.md +++ b/dev/shared-working-context/frontend/requests/for-backend.md @@ -120,3 +120,15 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a this is purely to prefill the client cascade. - **Proposed shape:** `CustomerAddressDto { …, provinceId: long }` (join from `cities.province_id`). - **Status:** open + +## REQ-010 — Confirm/align the list pagination query-param name (catalog + all lists) — filed by frontend-phase-4-b5 — 2026-07-05 +- **Need:** Confirm the exact query-param name the paginated list endpoints bind for page size. The + `catalog.md` route examples write `?page=&page_size=` (snake_case), but the **working** b4 `serviceAreas` + client binds `pageSize` (camelCase, case-insensitive to the server's `PageSize` property) — the f3 report + flagged this as the `page_size`→`pageSize` gotcha. The f4 catalog client follows the proven `pageSize` for + `catalog/categories` and `nurse_variants/list`; the domain filter `category_id` stays snake_case per the doc. +- **Why:** So the real-endpoint swap (f6 flips `USE_CATALOG_MOCK=false`) doesn't silently paginate wrong. If + the server truly binds `pageSize`, please update the `page_size` occurrences in the contract docs to match; + if it binds `page_size`, tell us and we'll switch the client (one line per list call). +- **Proposed shape:** list query = `?page={1-based}&pageSize={≤100}`; response `data` = `{ items, total, page, pageSize }`. +- **Status:** open diff --git a/dev/shared-working-context/reports/frontend-phase-4-report.md b/dev/shared-working-context/reports/frontend-phase-4-report.md new file mode 100644 index 0000000..8ef0fb8 --- /dev/null +++ b/dev/shared-working-context/reports/frontend-phase-4-report.md @@ -0,0 +1,117 @@ +# Frontend Phase 4 — Catalog browse (Home A5) & nurse service builder (B7) — Report (2026-07-05) + +Lights up the two faces of the configurable catalog: the **customer Home (A5)** front door and the +**nurse "add a service" builder + offerings list (B7 services half)**, over a new cached `services/catalog` +domain. Consumes the b5 `catalog` contract. Unlocks search & discovery (f6). + +## What was built + +### `services/catalog` domain (`client/src/services/catalog/`) +- **`types.ts`** — DTOs mirrored from [`catalog.md`](../../contracts/domains/catalog.md) (**camelCase** wire): + `ServiceCategory`, `ServiceOptionGroup` (`serviceCategoryId: number|null` = cross-category, `isRequired`), + `ServiceOptionValue`, `NurseServiceVariant` (`price` = **IRR digit-string**, `priceUnit`, `sessionCount`, + `options[]`), `CreateVariantInput`/`UpdateVariantInput`, the `CatalogApi` seam, and helpers + `isGroupApplicable` / `optionSetSignature` (the duplicate-listing signature). `PriceUnit` +the `PRICE_UNITS` + array are the **only** closed enum — categories/groups/values are data-driven. +- **`keys.ts`** — `catalogKeys`: `categories()`, `categoryOptionGroups(id)`, `myVariants(params)` / + `myVariantsLists()` (invalidation prefix), `variant(id)`. +- **`constants.ts`** — `USE_CATALOG_MOCK` (default `true`); `CATALOG_REFERENCE_STALE_TIME = Infinity` + + `CATALOG_REFERENCE_GC_TIME` (reference data cached session-long, like geography); `MY_VARIANTS_STALE_TIME`; + page sizes. +- **`apis/`** — `clientApi.ts` (real, action-style routes, camelCase bodies, `pageSize` pagination), + `mockApi.ts` + `seed.ts` (in-memory, behind the seam), `index.ts` (config-selected seam). +- **`hooks/`** (one per file) — `useServiceCategories`, `useCategoryOptionGroups` (cached reference data); + `useMyVariants` (paginated, auth-gated, self-scoped); `useCreateVariant`, `useUpdateVariant` + (`setQueryData` the row + invalidate), `useSetVariantActive` (deactivate/reactivate). `index.ts` barrels hooks. +- **`names.ts`** — `pickCatalogName(row, locale)` locale-label helper (fa primary). + +### Shared components (`client/src/components/`, each with a co-located `*.test.tsx`) +- **`CategoryTile`** — data-driven, tappable category tile (icon disc + localised label; `selected` state for + the builder; robust `iconKey`→icon fallback to a generic `category` icon). +- **`PriceDisplay`** — renders `{Toman} {unit}` via the f0 money util + an i18n unit label off `price_unit`, and + the **unit-aware estimated total** (`price × sessionCount`, integer-safe) only when a duration is present. +- **`VariantCard`** — a nurse offering: `display_name`, `PriceDisplay`, active/deactivated distinction (dimmed + + neutral chip + "can't be booked" hint), Edit + Deactivate/Reactivate actions — **no delete affordance**. + +### Money util (`client/src/utils/money.ts`) +- `tomanToRial(value)` — the sanctioned **Toman→IRR digit-string** conversion at the field boundary. +- `multiplyIrr(value, count)` — integer-safe `price × count` for the estimated total (never from price alone). +- Both unit-tested (`money.test.ts`). + +### Screens +- **Customer Home (A5)** — `app/[locale]/(private-routes)/(customer)/page.tsx` (rewritten): greeting + avatar + (`useMe` firstName / initial), **search bar** (navigates toward `/search?q=…`; execution deferred to f6), + **data-driven category grid** (`useServiceCategories`; loading skeletons / empty / error+retry; tiles carry + `service_category_id` → `/search?category_id=`), the complete-patient-record nudge (reuses the **cached f2 + `usePatients`** — no new fetch), and the preserved first-login onboarding gate. +- **`/search`** — deferred `PlaceholderScreen` stub so the Home CTAs don't dead-end (echoes the q/category). +- **Nurse Services & prices (B7)** — `app/[locale]/(private-routes)/nurse/services/` (new sidebar tab). `page.tsx` + switches between `MyServicesList` (offerings — active/inactive, edit, soft-deactivate w/ confirm, reactivate, + empty/skeleton) and `VariantBuilder` (create/edit). + +### Builder (`VariantBuilder.tsx`) +- **Create** = 3-step stepper (reuses the f0 `StepperHeader`): category (CategoryTile grid) → options + (single-select `ToggleButtonGroup` per group; required badge; **blocks advancing until every required group is + answered**, cross-category groups included) → price+unit+duration. Price entered in **Toman** → submitted as an + **IRR digit-string** (`tomanToRial`, no float on the path); **live unit-aware estimated total** via + `PriceDisplay`; editable auto-generated `display_name`. The duplicate-listing **`409`** shows a friendly inline + warning. **Edit** locks the category + option-set and edits only price/unit/duration/display. + +### Wiring +- i18n: `catalog` / `services` / `search` namespaces + `home` additions + `nav.services` (both locales, in sync, + 313 keys). Icons: `services`, `category`, `elderly`, `post_surgery`, `infant`, `chronic`, `companionship`. + Routes: `SEARCH`, `NURSE_SERVICES`. Nurse sidebar gains **Services**. + +## What is now testable (and exactly how) + +Run `npm run dev` (mock is on by default — no backend needed). Follow the phase §7 steps: +1. **Home** — sign in as a customer → greeting + avatar, search bar, and the **category grid** (5 seeded + categories ordered by `sortOrder`); the patient nudge shows/hides off the cached patient state (React Query + Devtools: the `patients` query is **reused**, not refetched). +2. **Locale + RTL** — toggle `fa`↔`en`: labels translate, `dir` flips, tiles/grid mirror; Persian unit labels + (ساعتی/روزانه/شبانه‌روزی) read correctly. +3. **Build a variant** — sign in as a nurse → `/nurse/services` → `+ افزودن خدمت` → pick **مراقبت از سالمند**; + in options, try **Next** without answering **نوع شیفت** (required) → blocked, the required badge turns red; + answer it → Next; enter a **Toman** price + unit (ساعتی) + a duration → the **estimated total** updates from + `price × duration`; edit the auto `display_name`; submit → the card appears (`… تومان ساعتی`). +4. **Duplicate** — create a second variant with the **same category + same option-set** → the friendly + `409` duplicate warning shows inline; no crash / no generic toast. +5. **Edit + deactivate** — edit a variant's price/name → the list reflects it without a full refetch (Devtools: + `setQueryData` + single invalidation). Deactivate → confirm dialog → the row dims to the deactivated state + with the "can't be booked" hint; **no delete** option. Reactivate from the inactive row. +6. **Caching** — Devtools: `catalogKeys.categories()` / `categoryOptionGroups` are served from cache across Home + and every builder step (no per-step refetch); a variant mutation invalidates only `myVariants`. +7. **Gate** — `npm run check` + `npm run test:ci` pass. + +## What is mocked / waiting on a real service +- **`CatalogApi`** — `client/src/services/catalog/apis/mockApi.ts` (+ `seed.ts`), behind `USE_CATALOG_MOCK` + (default `true`). Faithfully reproduces the b5 create validation (`400` missing-required / bad-price) and the + `(nurse, category, option-set)` duplicate **`409`**; categories mirror the real seed; the variant store starts + **empty** (so the empty state demos). See [`mocks-registry.md`](./mocks-registry.md) (`CatalogApi` row). + **Swap = one line** (`USE_CATALOG_MOCK = false`); `catalogClientApi` is already wired to the action-style routes. + Caveat recorded there: the mock seeds representative **option groups the fresh backend does not** (an admin + authors them per category) — after the swap, categories have no groups until seeded server-side. + +## Contracts +- **Consumed:** [`dev/contracts/domains/catalog.md`](../../contracts/domains/catalog.md) (backend-phase-b5) — + types/services derive from it; no shape guessed. +- **Requested:** `REQ-010` in [`for-backend.md`](../frontend/requests/for-backend.md) — confirm/align the list + pagination query-param name (`pageSize`, per the proven b4 binding, vs the doc's `page_size`). +- **Produced:** none (frontend produces no contract). + +## Docs updated +- [`client/CLAUDE.md`](../../../client/CLAUDE.md) — *Project Structure* (customer `search` stub, nurse `services` + route, `services/catalog` domain, `CategoryTile`/`PriceDisplay`/`VariantCard`); the reference-data caching note + (catalog = second long-lived cached domain); the i18n namespaces list (`catalog`/`services`/`search`, `home`). +- This report + `STATUS.md` + `mocks-registry.md` + `for-backend.md` (REQ-010). +- No product-doc rule change was needed; the estimated-total presentation rule (total only from + price × session_count, never price alone) is captured here and enforced in `PriceDisplay`/the builder. + +## Follow-ups for later phases +- **f6 (search & discovery)** — the Home **search bar** hands a `q` / `service_category_id` to `/search` (today a + placeholder); f6 builds the results, filters, and nurse cards, and can **reuse** `CategoryTile`, `PriceDisplay`, + the cached `useServiceCategories`/`useCategoryOptionGroups`, and the `catalog`/`search` namespaces. The **variant + builder is what populates the index f6 reads** (a nurse must have ≥1 active variant + coverage to appear). +- **Backend** — deliver `REQ-010` (pagination param confirmation), then f6 can flip `USE_CATALOG_MOCK=false`. +- **Deferred (unchanged):** admin catalog manager (→ f15), nurse availability slots, public nurse-profile service + rows (→ f6 C3), holiday/surge pricing.