From 4b4243c45133d95e894c9e8268a83ebaab2b733b Mon Sep 17 00:00:00 2001 From: hamid Date: Thu, 2 Jul 2026 22:04:38 +0330 Subject: [PATCH] =?UTF-8?q?frontend=20phase=202:=20onboarding=20&=20profil?= =?UTF-8?q?es=20=E2=80=94=20customer/patient,=20nurse=20profile=20&=20bank?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turns a logged-in user into a usable account, consuming the b3 identity-profiles contract behind the services/{domain} seam. Services (mock default true; real HTTP clients wired for a one-line flip): - services/patients: rewritten to b3 PatientDto + client-augmented relation/conditions; full CRUD, optimistic soft-archive, cache-splice on create, age<->birthDate helper. - services/profiles: customer + nurse profile get/upsert + avatar (404->null mapping). - services/nurse: payout bank accounts + IBAN(Sheba) util + pending-only polling. Screens: A3->A4 onboarding wizard, E1 patients list/CRUD, A5 home (first-login gate + nudge), customer profile (no national-ID), nurse profile bootstrap (unverified placeholder), nurse bank settings (pending/verified/mismatch + make-primary). Shared composites (each tested): GenderToggle, ConditionChips, RelationSelect, PatientForm, PatientCard, BankStatusPanel; reuses f0 StepperHeader/StatusChip/PhoneField. Adds onboarding/home/profile/nurseProfile/bank i18n namespaces (both locales, in sync), the --bal-primary-soft token, and nurse sidebar Profile + Bank entries. Contract gaps filed: REQ-005 (patient relation/conditions), REQ-006 (avatar route), REQ-007 (customer name/language). Gate: check + 112 tests + build all green. Co-Authored-By: Claude Opus 4.8 (1M context) --- client/CLAUDE.md | 26 +- client/messages/en.json | 131 ++++++++- client/messages/fa.json | 133 ++++++++- .../(customer)/onboarding/page.tsx | 97 +++++++ .../(private-routes)/(customer)/page.tsx | 104 ++++++- .../(customer)/patients/page.tsx | 253 ++++++++++++------ .../(customer)/profile/page.tsx | 132 ++++++++- .../(private-routes)/nurse/bank/page.tsx | 158 +++++++++++ .../(private-routes)/nurse/profile/page.tsx | 165 ++++++++++++ .../BankStatusPanel/BankStatusPanel.test.tsx | 52 ++++ .../BankStatusPanel/BankStatusPanel.tsx | 116 ++++++++ .../src/components/BankStatusPanel/index.tsx | 4 + .../ConditionChips/ConditionChips.test.tsx | 41 +++ .../ConditionChips/ConditionChips.tsx | 53 ++++ .../src/components/ConditionChips/index.tsx | 4 + .../GenderToggle/GenderToggle.test.tsx | 42 +++ .../components/GenderToggle/GenderToggle.tsx | 61 +++++ client/src/components/GenderToggle/index.tsx | 4 + .../PatientCard/PatientCard.test.tsx | 60 +++++ .../components/PatientCard/PatientCard.tsx | 88 ++++++ client/src/components/PatientCard/index.tsx | 4 + .../PatientForm/PatientForm.test.tsx | 50 ++++ .../components/PatientForm/PatientForm.tsx | 182 +++++++++++++ client/src/components/PatientForm/index.tsx | 4 + .../RelationSelect/RelationSelect.test.tsx | 40 +++ .../RelationSelect/RelationSelect.tsx | 68 +++++ .../src/components/RelationSelect/index.tsx | 4 + .../src/components/common/AppIcon/config.ts | 10 + client/src/components/index.tsx | 27 +- client/src/constants/routes.ts | 4 + client/src/layout/NurseLayout.tsx | 2 + client/src/services/nurse/apis/clientApi.ts | 32 +++ client/src/services/nurse/apis/index.ts | 10 + client/src/services/nurse/apis/mockApi.ts | 81 ++++++ client/src/services/nurse/constants.ts | 20 ++ .../nurse/hooks/useAddNurseBankAccount.ts | 21 ++ .../nurse/hooks/useNurseBankAccounts.ts | 25 ++ .../nurse/hooks/useSetPrimaryBankAccount.ts | 18 ++ client/src/services/nurse/iban.ts | 48 ++++ client/src/services/nurse/index.ts | 3 + client/src/services/nurse/keys.ts | 5 + client/src/services/nurse/types.ts | 42 +++ client/src/services/patients/age.ts | 22 ++ .../src/services/patients/apis/clientApi.ts | 69 ++++- client/src/services/patients/apis/mockApi.ts | 83 ++++-- client/src/services/patients/constants.ts | 22 +- .../services/patients/hooks/useAddPatient.ts | 20 -- .../patients/hooks/useArchivePatient.ts | 37 +++ .../patients/hooks/useCreatePatient.ts | 26 ++ .../patients/hooks/useUpdatePatient.ts | 19 ++ client/src/services/patients/index.ts | 4 +- client/src/services/patients/types.ts | 66 +++-- .../src/services/profiles/apis/clientApi.ts | 86 ++++++ client/src/services/profiles/apis/index.ts | 10 + client/src/services/profiles/apis/mockApi.ts | 73 +++++ client/src/services/profiles/constants.ts | 11 + .../profiles/hooks/useCustomerProfile.ts | 16 ++ .../profiles/hooks/useNurseProfile.ts | 16 ++ .../profiles/hooks/useUploadAvatar.ts | 12 + .../hooks/useUpsertCustomerProfile.ts | 22 ++ .../profiles/hooks/useUpsertNurseProfile.ts | 22 ++ client/src/services/profiles/index.ts | 5 + client/src/services/profiles/keys.ts | 9 + client/src/services/profiles/types.ts | 76 ++++++ client/src/theme/tokens.css | 4 + dev/shared-working-context/frontend/STATUS.md | 27 ++ .../frontend/requests/for-backend.md | 33 +++ .../reports/frontend-phase-2-report.md | 82 ++++++ .../reports/mocks-registry.md | 4 +- product/business/01-actors-and-onboarding.md | 1 + 70 files changed, 3111 insertions(+), 190 deletions(-) create mode 100644 client/src/app/[locale]/(private-routes)/(customer)/onboarding/page.tsx create mode 100644 client/src/app/[locale]/(private-routes)/nurse/bank/page.tsx create mode 100644 client/src/app/[locale]/(private-routes)/nurse/profile/page.tsx create mode 100644 client/src/components/BankStatusPanel/BankStatusPanel.test.tsx create mode 100644 client/src/components/BankStatusPanel/BankStatusPanel.tsx create mode 100644 client/src/components/BankStatusPanel/index.tsx create mode 100644 client/src/components/ConditionChips/ConditionChips.test.tsx create mode 100644 client/src/components/ConditionChips/ConditionChips.tsx create mode 100644 client/src/components/ConditionChips/index.tsx create mode 100644 client/src/components/GenderToggle/GenderToggle.test.tsx create mode 100644 client/src/components/GenderToggle/GenderToggle.tsx create mode 100644 client/src/components/GenderToggle/index.tsx create mode 100644 client/src/components/PatientCard/PatientCard.test.tsx create mode 100644 client/src/components/PatientCard/PatientCard.tsx create mode 100644 client/src/components/PatientCard/index.tsx create mode 100644 client/src/components/PatientForm/PatientForm.test.tsx create mode 100644 client/src/components/PatientForm/PatientForm.tsx create mode 100644 client/src/components/PatientForm/index.tsx create mode 100644 client/src/components/RelationSelect/RelationSelect.test.tsx create mode 100644 client/src/components/RelationSelect/RelationSelect.tsx create mode 100644 client/src/components/RelationSelect/index.tsx create mode 100644 client/src/services/nurse/apis/clientApi.ts create mode 100644 client/src/services/nurse/apis/index.ts create mode 100644 client/src/services/nurse/apis/mockApi.ts create mode 100644 client/src/services/nurse/constants.ts create mode 100644 client/src/services/nurse/hooks/useAddNurseBankAccount.ts create mode 100644 client/src/services/nurse/hooks/useNurseBankAccounts.ts create mode 100644 client/src/services/nurse/hooks/useSetPrimaryBankAccount.ts create mode 100644 client/src/services/nurse/iban.ts create mode 100644 client/src/services/nurse/index.ts create mode 100644 client/src/services/nurse/keys.ts create mode 100644 client/src/services/nurse/types.ts create mode 100644 client/src/services/patients/age.ts delete mode 100644 client/src/services/patients/hooks/useAddPatient.ts create mode 100644 client/src/services/patients/hooks/useArchivePatient.ts create mode 100644 client/src/services/patients/hooks/useCreatePatient.ts create mode 100644 client/src/services/patients/hooks/useUpdatePatient.ts create mode 100644 client/src/services/profiles/apis/clientApi.ts create mode 100644 client/src/services/profiles/apis/index.ts create mode 100644 client/src/services/profiles/apis/mockApi.ts create mode 100644 client/src/services/profiles/constants.ts create mode 100644 client/src/services/profiles/hooks/useCustomerProfile.ts create mode 100644 client/src/services/profiles/hooks/useNurseProfile.ts create mode 100644 client/src/services/profiles/hooks/useUploadAvatar.ts create mode 100644 client/src/services/profiles/hooks/useUpsertCustomerProfile.ts create mode 100644 client/src/services/profiles/hooks/useUpsertNurseProfile.ts create mode 100644 client/src/services/profiles/index.ts create mode 100644 client/src/services/profiles/keys.ts create mode 100644 client/src/services/profiles/types.ts create mode 100644 dev/shared-working-context/reports/frontend-phase-2-report.md diff --git a/client/CLAUDE.md b/client/CLAUDE.md index 64b7eff..e3276d4 100644 --- a/client/CLAUDE.md +++ b/client/CLAUDE.md @@ -118,14 +118,17 @@ 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 # / (home) + │ │ │ ├── page.tsx # / (A5 home — 'use client'; first-login onboarding gate + record/profile nudges) + │ │ │ ├── onboarding/page.tsx # /onboarding — A3→A4 wizard (relation → first patient) │ │ │ ├── bookings/page.tsx # /bookings - │ │ │ ├── patients/page.tsx # /patients — reference services/{domain} + Query screen + │ │ │ ├── patients/page.tsx # /patients — E1 list/CRUD (add/edit dialog reusing PatientForm, soft-archive) │ │ │ ├── wallet/page.tsx # /wallet - │ │ │ └── profile/page.tsx # /profile + │ │ │ └── profile/page.tsx # /profile — customer profile + emergency contact (no national-ID) │ │ ├── nurse/ # Nurse app (/nurse/…) — sidebar shell │ │ │ ├── layout.tsx # 'use client' — wraps NurseLayout │ │ │ ├── page.tsx # /nurse (dashboard) + │ │ │ ├── profile/page.tsx # /nurse/profile — B7 profile bootstrap (avatar+bio+years; unverified placeholder) + │ │ │ ├── bank/page.tsx # /nurse/bank — payout IBAN + ownership states (pending/verified/mismatch) │ │ │ ├── verification/page.tsx # /nurse/verification │ │ │ └── visits/page.tsx # /nurse/visits (EVV) │ │ └── admin/ # Admin/backoffice (/admin/…) — desktop sidebar shell @@ -142,6 +145,12 @@ client/ │ ├── PhoneNumberField/ # Iranian mobile field (digit-normalizing, LTR-in-RTL, maskIranMobile) │ ├── StepperHeader/ # Progress header for onboarding/verification flows │ ├── StatusChip/ # Semantic status chip (verified/pending/rejected/…) off --bal-* tokens + │ ├── GenderToggle/ # Required male/female toggle (never defaulted) — drives same-gender matching + │ ├── ConditionChips/ # Multi-select patient-condition chips (stable codes, translated labels) + │ ├── RelationSelect/ # Single-select relation radio cards (parent/spouse/child/self) + │ ├── 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 │ └── auth/ # Auth-flow composites: LoginFlow, PhoneStep, OtpStep, RoleRouter, SelectRole, AuthCard, BrandMark, AuthSplash, useCountdown ├── i18n/ │ ├── routing.ts # defineRouting — locales: ['en', 'fa'], defaultLocale: 'fa' @@ -183,7 +192,9 @@ client/ │ └── index.ts # Re-exports constants ONLY (never server/client) ├── services/ # Domain services — no top-level barrel; import directly from the file │ ├── auth/ # Phone-OTP auth: requestOtp/verifyOtp/refresh/logout/me/selectRole + role router (routing.ts) + useSessionRoleSync - │ ├── patients/ # Reference domain for the mock-behind-a-seam pattern (§ services pattern) + │ ├── patients/ # Care-recipient CRUD (b3 PatientDto + client-augmented relation/conditions), soft-archive; age.ts helper + │ ├── profiles/ # Customer + nurse profile get/upsert + avatar (behind the ProfilesApi seam) + │ ├── nurse/ # Nurse payout bank accounts + IBAN(Sheba) util (iban.ts) + ownership-inquiry states │ └── {domain}/ │ ├── types.ts # Request/response types + the domain's Api interface (the seam) │ ├── keys.ts # React Query key factory (hierarchical) @@ -265,7 +276,12 @@ async function MyServerComponent() { - `'nav'` — the actor shells (`CustomerLayout`/`NurseLayout`/`AdminLayout`) build their nav from here - `'common'` — `DarkModeButton.tsx` (dark/light labels), shared words (loading, retry, currency_toman, …) - `'shell'` — actor-shell titles + the not-yet-built placeholder body -- `'patients'` — the reference services/{domain} demo screen +- `'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) +- `'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) - `'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 diff --git a/client/messages/en.json b/client/messages/en.json index ae91233..4d5b6b2 100644 --- a/client/messages/en.json +++ b/client/messages/en.json @@ -5,6 +5,7 @@ "patients": "Patients", "wallet": "Wallet", "profile": "Profile", + "bank": "Bank account", "dashboard": "Dashboard", "verification": "Verification", "visits": "Visits", @@ -25,6 +26,11 @@ "retry": "Retry", "add": "Add", "cancel": "Cancel", + "save": "Save", + "saving": "Saving…", + "back": "Back", + "close": "Close", + "optional": "Optional", "currency_toman": "Toman", "brand": "Balinyaar", "brand_tagline": "Home care you can trust" @@ -35,16 +41,129 @@ "admin_console": "Admin console", "placeholder_body": "This area will be built in a later phase." }, - "patients": { - "title": "Patients", - "subtitle": "A reference screen wired to the services/{domain} + React Query pattern (mocked data).", - "add": "Add patient", - "empty": "No patients yet. Add your first patient.", + "home": { + "greeting": "Welcome to Balinyaar", + "subtitle": "Manage care for the people you love.", + "nudge_patient_title": "Complete the patient record", + "nudge_patient_body": "Add conditions, medications and routine so nurses arrive prepared.", + "nudge_patient_cta": "Open patients", + "nudge_profile_title": "Complete your profile", + "nudge_profile_body": "Add an emergency contact to speed up bookings.", + "nudge_profile_cta": "Go to profile" + }, + "onboarding": { + "step_relation": "Who for", + "step_patient": "Patient", + "relation_title": "Who are you arranging care for?", + "relation_subtitle": "This helps us tailor the right care. You can add more people later.", + "relation_parent": "Parent", + "relation_spouse": "Spouse", + "relation_child": "Child", + "relation_self": "Myself", + "patient_title": "Register the patient", + "patient_subtitle": "The person receiving care — not necessarily the account holder.", "name_label": "Full name", + "name_required": "Enter the patient's name", + "age_label": "Age", + "age_invalid": "Enter a valid age", "gender_label": "Gender", "gender_male": "Male", "gender_female": "Female", - "added": "Patient added" + "gender_required": "Patient gender is required", + "conditions_label": "Conditions", + "conditions_hint": "Optional — select any that apply.", + "condition_elderly": "Elderly", + "condition_post_surgery": "Post-surgery", + "condition_diabetes": "Diabetes", + "condition_mobility": "Limited mobility", + "condition_dementia": "Dementia", + "continue": "Continue", + "save_continue": "Save and continue", + "saved": "Patient saved" + }, + "patients": { + "title": "Patients", + "subtitle": "The people you arrange care for.", + "add": "Add patient", + "add_title": "Add patient", + "edit_title": "Edit patient", + "edit": "Edit", + "archive": "Archive", + "empty_title": "No patients yet", + "empty_body": "Add your first patient to get started.", + "archive_title": "Archive patient?", + "archive_body": "This patient is removed from your list, but past booking records are kept.", + "archive_confirm": "Archive", + "archived": "Patient archived", + "age_years": "{age} yrs", + "conditions_none": "No recorded conditions", + "unavailable": "This patient isn't available to you." + }, + "profile": { + "title": "Profile", + "subtitle": "Your details and emergency contact.", + "first_name": "First name", + "last_name": "Last name", + "language": "Preferred language", + "language_fa": "Persian", + "language_en": "English", + "emergency_section": "Emergency contact", + "emergency_hint": "Who we call in an emergency.", + "emergency_name": "Emergency contact name", + "emergency_phone": "Emergency contact phone", + "emergency_phone_invalid": "Enter a valid mobile number", + "save": "Save", + "saved": "Profile saved", + "completion_done": "Your profile is complete.", + "completion_todo": "Complete your profile to speed up bookings." + }, + "nurseProfile": { + "title": "Nurse profile", + "subtitle": "What families see when they find you.", + "photo": "Profile photo", + "photo_hint": "A clear photo of your face.", + "upload": "Upload photo", + "uploading": "Uploading…", + "bio": "About me", + "bio_hint": "A short intro — experience, focus, approach.", + "years": "Years of experience", + "years_invalid": "Enter a number between 0 and 80", + "save": "Save profile", + "saved": "Profile saved", + "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." + }, + "bank": { + "title": "Bank account", + "subtitle": "Where your earnings are paid out.", + "iban_label": "IBAN (Sheba)", + "iban_hint": "Starts with IR followed by 24 digits.", + "iban_invalid": "Enter a valid IBAN (IR + 24 digits)", + "holder_label": "Account holder name", + "holder_hint": "Must be exactly your own name.", + "holder_required": "Enter the account holder name", + "submit": "Submit account", + "submitting": "Submitting…", + "added": "Account submitted — ownership inquiry started", + "add_error": "This account couldn't be added. Check the IBAN and try again.", + "status_pending_chip": "Checking", + "status_pending_title": "Verifying account ownership", + "status_pending_body": "The Sheba ownership inquiry is running; this can take a moment.", + "status_verified_chip": "Verified", + "status_verified_title": "Account verified", + "status_verified_body": "This account is ready to receive your payouts.", + "status_mismatch_chip": "Ownership mismatch", + "status_mismatch_title": "The account must be in your own name", + "status_mismatch_body": "This account's holder doesn't match your identity. Please enter the IBAN of an account in your own name.", + "reenter": "Enter another account", + "make_primary": "Make primary", + "primary_set": "Primary account updated", + "iban_masked_label": "IBAN", + "primary": "Primary", + "empty_title": "No bank account yet", + "empty_body": "Add a bank account in your own name to receive payouts." }, "auth": { "customer_title": "Sign in to Balinyaar", diff --git a/client/messages/fa.json b/client/messages/fa.json index cf9b540..6af55fa 100644 --- a/client/messages/fa.json +++ b/client/messages/fa.json @@ -5,6 +5,7 @@ "patients": "بیماران", "wallet": "کیف‌پول", "profile": "پروفایل", + "bank": "حساب بانکی", "dashboard": "داشبورد", "verification": "احراز هویت", "visits": "ویزیت‌ها", @@ -25,6 +26,11 @@ "retry": "تلاش مجدد", "add": "افزودن", "cancel": "انصراف", + "save": "ذخیره", + "saving": "در حال ذخیره…", + "back": "بازگشت", + "close": "بستن", + "optional": "اختیاری", "currency_toman": "تومان", "brand": "بلینیار", "brand_tagline": "مراقبت مطمئن در خانه" @@ -35,16 +41,129 @@ "admin_console": "کنسول مدیریت", "placeholder_body": "این بخش در فازهای بعدی تکمیل می‌شود." }, - "patients": { - "title": "بیماران", - "subtitle": "یک صفحهٔ مرجع که به الگوی services/{domain} و React Query متصل است (داده‌های آزمایشی).", - "add": "افزودن بیمار", - "empty": "هنوز بیماری ثبت نشده است. اولین بیمار را اضافه کنید.", - "name_label": "نام کامل", + "home": { + "greeting": "به بلینیار خوش آمدید", + "subtitle": "مراقبت از عزیزانتان را مدیریت کنید.", + "nudge_patient_title": "تکمیل پروندهٔ بیمار", + "nudge_patient_body": "وضعیت‌ها، داروها و روتین را اضافه کنید تا پرستار آماده حاضر شود.", + "nudge_patient_cta": "مشاهدهٔ بیماران", + "nudge_profile_title": "تکمیل پروفایل", + "nudge_profile_body": "برای رزرو سریع‌تر، تماس اضطراری را اضافه کنید.", + "nudge_profile_cta": "رفتن به پروفایل" + }, + "onboarding": { + "step_relation": "برای چه کسی", + "step_patient": "بیمار", + "relation_title": "برای چه کسی مراقبت می‌خواهید؟", + "relation_subtitle": "این کمک می‌کند مراقبت مناسب را پیشنهاد دهیم. بعداً می‌توانید افراد دیگری هم اضافه کنید.", + "relation_parent": "پدر/مادر", + "relation_spouse": "همسر", + "relation_child": "فرزند", + "relation_self": "خودم", + "patient_title": "ثبت بیمار", + "patient_subtitle": "فردی که مراقبت می‌شود — لزوماً صاحب حساب نیست.", + "name_label": "نام و نام خانوادگی", + "name_required": "نام بیمار را وارد کنید", + "age_label": "سن", + "age_invalid": "سن معتبر وارد کنید", "gender_label": "جنسیت", "gender_male": "مرد", "gender_female": "زن", - "added": "بیمار اضافه شد" + "gender_required": "انتخاب جنسیت بیمار الزامی است", + "conditions_label": "وضعیت‌ها", + "conditions_hint": "اختیاری — موارد مرتبط را انتخاب کنید.", + "condition_elderly": "سالمند", + "condition_post_surgery": "پس از جراحی", + "condition_diabetes": "دیابت", + "condition_mobility": "کم‌تحرک", + "condition_dementia": "آلزایمر/دمانس", + "continue": "ادامه", + "save_continue": "ذخیره و ادامه", + "saved": "بیمار ثبت شد" + }, + "patients": { + "title": "بیماران", + "subtitle": "کسانی که برایشان مراقبت می‌گیرید.", + "add": "افزودن بیمار", + "add_title": "افزودن بیمار", + "edit_title": "ویرایش بیمار", + "edit": "ویرایش", + "archive": "آرشیو", + "empty_title": "هنوز بیماری ثبت نشده", + "empty_body": "برای شروع، اولین بیمار را اضافه کنید.", + "archive_title": "آرشیو بیمار؟", + "archive_body": "این بیمار از فهرست شما حذف می‌شود اما سوابق رزروهای گذشته حفظ می‌ماند.", + "archive_confirm": "آرشیو", + "archived": "بیمار آرشیو شد", + "age_years": "{age} سال", + "conditions_none": "وضعیت خاصی ثبت نشده", + "unavailable": "این بیمار در دسترس شما نیست." + }, + "profile": { + "title": "پروفایل", + "subtitle": "اطلاعات شما و تماس اضطراری.", + "first_name": "نام", + "last_name": "نام خانوادگی", + "language": "زبان ترجیحی", + "language_fa": "فارسی", + "language_en": "انگلیسی", + "emergency_section": "تماس اضطراری", + "emergency_hint": "شماره‌ای که در مواقع ضروری با آن تماس می‌گیریم.", + "emergency_name": "نام تماس اضطراری", + "emergency_phone": "شماره تماس اضطراری", + "emergency_phone_invalid": "شماره موبایل معتبر وارد کنید", + "save": "ذخیره", + "saved": "پروفایل ذخیره شد", + "completion_done": "پروفایل شما کامل است.", + "completion_todo": "برای رزرو سریع‌تر، پروفایل خود را کامل کنید." + }, + "nurseProfile": { + "title": "پروفایل پرستار", + "subtitle": "چیزی که خانواده‌ها هنگام یافتن شما می‌بینند.", + "photo": "عکس پروفایل", + "photo_hint": "یک عکس واضح از چهره‌تان.", + "upload": "بارگذاری عکس", + "uploading": "در حال بارگذاری…", + "bio": "درباره من", + "bio_hint": "یک معرفی کوتاه — تجربه، تخصص، رویکرد.", + "years": "سابقه کار (سال)", + "years_invalid": "عددی بین ۰ تا ۸۰ وارد کنید", + "save": "ذخیره پروفایل", + "saved": "پروفایل ذخیره شد", + "unverified_title": "پروفایل شما هنوز فعال نیست", + "unverified_body": "برای نمایش به خانواده‌ها و دریافت رزرو، احراز هویت را تکمیل کنید.", + "unverified_cta": "تکمیل احراز هویت", + "deferred_services": "خدمات و قیمت‌ها و روزهای کاری را در مرحله بعد اضافه می‌کنید." + }, + "bank": { + "title": "حساب بانکی", + "subtitle": "مقصد واریز درآمد شما.", + "iban_label": "شماره شبا", + "iban_hint": "با IR شروع می‌شود و ۲۴ رقم دارد.", + "iban_invalid": "شماره شبا معتبر وارد کنید (IR و ۲۴ رقم)", + "holder_label": "نام صاحب حساب", + "holder_hint": "باید دقیقاً به نام خودتان باشد.", + "holder_required": "نام صاحب حساب را وارد کنید", + "submit": "ثبت حساب", + "submitting": "در حال ثبت…", + "added": "حساب ثبت شد — استعلام مالکیت آغاز شد", + "add_error": "ثبت این حساب ممکن نشد. شبا را بررسی کرده و دوباره تلاش کنید.", + "status_pending_chip": "در حال استعلام", + "status_pending_title": "در حال استعلام مالکیت حساب", + "status_pending_body": "استعلام شبا در حال انجام است؛ ممکن است چند لحظه طول بکشد.", + "status_verified_chip": "تاییدشد", + "status_verified_title": "حساب تایید شد", + "status_verified_body": "این حساب برای واریز درآمد شما آماده است.", + "status_mismatch_chip": "مغایرت مالکیت", + "status_mismatch_title": "حساب باید به نام خودتان باشد", + "status_mismatch_body": "نام صاحب این حساب با هویت شما مطابقت ندارد. لطفاً شبای حسابی به نام خودتان وارد کنید.", + "reenter": "ثبت حساب دیگر", + "make_primary": "انتخاب به‌عنوان حساب اصلی", + "primary_set": "حساب اصلی به‌روزرسانی شد", + "iban_masked_label": "شبا", + "primary": "حساب اصلی", + "empty_title": "هنوز حسابی ثبت نشده", + "empty_body": "برای دریافت درآمد، یک حساب بانکی به نام خودتان اضافه کنید." }, "auth": { "customer_title": "ورود به بلینیار", diff --git a/client/src/app/[locale]/(private-routes)/(customer)/onboarding/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/onboarding/page.tsx new file mode 100644 index 0000000..652ef9d --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/(customer)/onboarding/page.tsx @@ -0,0 +1,97 @@ +'use client'; +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { useLocale, useTranslations } from 'next-intl'; +import { useSnackbar } from 'notistack'; +import { Box, Stack, Typography } from '@mui/material'; +import { AppButton, PatientForm, RelationSelect, StepperHeader } from '@/components'; +import { ROUTES } from '@/constants'; +import { useCreatePatient } from '@/services/patients'; +import { RELATION_CODES } from '@/services/patients/constants'; +import type { CreatePatientInput, Relation } from '@/services/patients/types'; + +const ONBOARDING_MAX_WIDTH = 520; + +/** + * A3 → A4 onboarding wizard: pick who care is for, then register the first patient. The + * chosen relation pre-shapes the patient (it is hidden on the A4 form since it's already + * chosen here). On save it creates the patient and lands on Home (A5). + */ +export default function OnboardingPage() { + const t = useTranslations('onboarding'); + const tc = useTranslations('common'); + const router = useRouter(); + const locale = useLocale(); + const { enqueueSnackbar } = useSnackbar(); + const createPatient = useCreatePatient(); + + const [step, setStep] = useState(0); + const [relation, setRelation] = useState(null); + + const relationOptions = RELATION_CODES.map((code) => ({ code, label: t(`relation_${code}`), icon: 'account' })); + + const handleCreate = (input: CreatePatientInput) => { + createPatient.mutate( + { ...input, relation }, + { + onSuccess: () => { + enqueueSnackbar(t('saved'), { variant: 'success' }); + router.replace(`/${locale}${ROUTES.HOME}`); + }, + }, + ); + }; + + return ( + + + + {step === 0 ? ( + + + + {t('relation_title')} + + + {t('relation_subtitle')} + + + setRelation(code as Relation)} + /> + setStep(1)} + sx={{ m: 0 }} + > + {t('continue')} + + + ) : ( + + + + {t('patient_title')} + + + {t('patient_subtitle')} + + + setStep(0)} + cancelLabel={tc('back')} + /> + + )} + + ); +} diff --git a/client/src/app/[locale]/(private-routes)/(customer)/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/page.tsx index a5fe37a..a3813fe 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/page.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/page.tsx @@ -1,8 +1,100 @@ -import { getTranslations } from 'next-intl/server'; -import { PlaceholderScreen } from '@/components'; +'use client'; +import { FunctionComponent, useEffect } 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 { ROUTES } from '@/constants'; +import { useMe } from '@/services/auth'; +import { usePatients } from '@/services/patients'; -export default async function CustomerHomePage() { - const t = await getTranslations('nav'); - const tShell = await getTranslations('shell'); - return ; +interface NudgeCardProps { + icon: string; + title: string; + body: string; + ctaLabel: string; + to: string; +} + +const NudgeCard: FunctionComponent = ({ icon, title, body, ctaLabel, to }) => ( + + + + + + {title} + + + {body} + + + + {ctaLabel} + + + +); + +/** + * 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. + */ +export default function CustomerHomePage() { + const t = useTranslations('home'); + const router = useRouter(); + const locale = useLocale(); + + 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(() => { + if (isEmpty) router.replace(`/${locale}${ROUTES.ONBOARDING}`); + }, [isEmpty, router, locale]); + + if (data == null || isEmpty) { + return ; + } + + const href = (path: string) => `/${locale}${path}`; + const profileComplete = me?.hasCustomerProfile ?? false; + + return ( + + + + {t('greeting')} + + + {t('subtitle')} + + + + + {!profileComplete ? ( + + ) : null} + + ); } diff --git a/client/src/app/[locale]/(private-routes)/(customer)/patients/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/patients/page.tsx index 2fb876f..e18ce0d 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/patients/page.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/patients/page.tsx @@ -1,101 +1,200 @@ 'use client'; -import { ChangeEvent, useState } from 'react'; -import { useLocale, useTranslations } from 'next-intl'; -import { Box, Chip, List, ListItem, ListItemText, MenuItem, Stack, TextField, Typography } from '@mui/material'; +import { useState } from 'react'; +import { useTranslations } from 'next-intl'; import { useSnackbar } from 'notistack'; -import { AppButton, AppLoading } from '@/components'; -import { usePatients, useAddPatient } from '@/services/patients'; -import type { Gender } from '@/services/patients/types'; -import { formatShamsiDate } from '@/utils'; +import { + Box, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Paper, + Skeleton, + Stack, + Typography, +} from '@mui/material'; +import { AppButton, AppIcon, PatientCard, PatientForm } from '@/components'; +import { usePatients, useCreatePatient, useUpdatePatient, useArchivePatient } from '@/services/patients'; +import { birthDateToAge } from '@/services/patients/age'; +import type { CreatePatientInput, Patient } from '@/services/patients/types'; /** - * Reference screen for the services/{domain} + React Query pattern (§3.3). It reads the - * mocked patients list via usePatients (cached with a staleTime) and adds one via - * useAddPatient, whose onSuccess invalidates the list so the new row appears without a - * manual refetch — visible in the React Query Devtools. + * E1 — the Patients tab: a cached, invalidate-on-mutation list of the customer's patients + * with add/edit (the A4 form reused in a dialog) and soft archive (confirm). Loading skeleton + * and an empty state with the add CTA are both handled. */ export default function PatientsPage() { const t = useTranslations('patients'); - const locale = useLocale(); + const to = useTranslations('onboarding'); + const tc = useTranslations('common'); const { enqueueSnackbar } = useSnackbar(); const { data, isLoading } = usePatients(); - const addPatient = useAddPatient(); + const createPatient = useCreatePatient(); + const updatePatient = useUpdatePatient(); + const archivePatient = useArchivePatient(); - const [name, setName] = useState(''); - const [gender, setGender] = useState('female'); + const [formOpen, setFormOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [archiveTarget, setArchiveTarget] = useState(null); - const genderLabel = (value: Gender) => (value === 'male' ? t('gender_male') : t('gender_female')); - - const handleAdd = () => { - const fullName = name.trim(); - if (!fullName) return; - addPatient.mutate( - { fullName, gender }, - { - onSuccess: () => { - setName(''); - enqueueSnackbar(t('added'), { variant: 'success' }); - }, - } - ); + const openAdd = () => { + setEditing(null); + setFormOpen(true); }; + const openEdit = (patient: Patient) => { + setEditing(patient); + setFormOpen(true); + }; + const closeForm = () => setFormOpen(false); + + const handleSubmit = (input: CreatePatientInput) => { + const onSuccess = () => { + closeForm(); + enqueueSnackbar(to('saved'), { variant: 'success' }); + }; + if (editing) { + updatePatient.mutate( + { id: editing.id, input }, + { onSuccess, onError: () => enqueueSnackbar(t('unavailable'), { variant: 'error' }) }, + ); + } else { + createPatient.mutate(input, { onSuccess }); + } + }; + + const confirmArchive = () => { + if (!archiveTarget) return; + const id = archiveTarget.id; + setArchiveTarget(null); + archivePatient.mutate(id, { + onSuccess: () => enqueueSnackbar(t('archived'), { variant: 'success' }), + // A cross-tenant/stale id returns 404 (not toasted by the fetch layer) — the archive was + // optimistic, so tell the user why the card reappeared. + onError: () => enqueueSnackbar(t('unavailable'), { variant: 'error' }), + }); + }; + + const patients = data?.items ?? []; + const isEmpty = !isLoading && patients.length === 0; return ( - - - {t('title')} - - - {t('subtitle')} - - - - - ) => setName(event.target.value)} - fullWidth - /> - ) => setGender(event.target.value as Gender)} - sx={{ minWidth: 140 }} - > - {t('gender_female')} - {t('gender_male')} - - - {t('add')} - + + + + {t('title')} + + + {t('subtitle')} + + + {!isEmpty ? ( + + {t('add')} + + ) : null} {isLoading ? ( - - ) : !data || data.items.length === 0 ? ( - {t('empty')} - ) : ( - - {data.items.map((patient) => ( - } - > - - + + {[0, 1].map((key) => ( + ))} - + + ) : isEmpty ? ( + + + + {t('empty_title')} + + + {t('empty_body')} + + + {t('add')} + + + ) : ( + + {patients.map((patient) => { + const age = birthDateToAge(patient.birthDate); + return ( + to(`condition_${code}`))} + noConditionsLabel={t('conditions_none')} + onEdit={() => openEdit(patient)} + onArchive={() => setArchiveTarget(patient)} + editLabel={t('edit')} + archiveLabel={t('archive')} + /> + ); + })} + )} + + + {editing ? t('edit_title') : t('add_title')} + + + + + + + + setArchiveTarget(null)}> + {t('archive_title')} + + + {t('archive_body')} + + + + setArchiveTarget(null)}> + {tc('cancel')} + + + {t('archive_confirm')} + + + ); } diff --git a/client/src/app/[locale]/(private-routes)/(customer)/profile/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/profile/page.tsx index 7558a49..21cc32f 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/profile/page.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/profile/page.tsx @@ -1,8 +1,128 @@ -import { getTranslations } from 'next-intl/server'; -import { PlaceholderScreen } from '@/components'; +'use client'; +import { FunctionComponent, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { useSnackbar } from 'notistack'; +import { Box, Divider, MenuItem, Stack, TextField, Typography } from '@mui/material'; +import { AppButton, AppLoading, PhoneNumberField } from '@/components'; +import { isIranianMobile } from '@/components/PhoneNumberField'; +import { digitsOnly } from '@/utils'; +import { useCustomerProfile, useUpsertCustomerProfile } from '@/services/profiles'; +import type { CustomerProfile } from '@/services/profiles/types'; -export default async function ProfilePage() { - const t = await getTranslations('nav'); - const tShell = await getTranslations('shell'); - return ; +/** Customer profile — name, preferred language, and the emergency contact. No national-ID KYC. */ +export default function CustomerProfilePage() { + const { data: profile, isLoading } = useCustomerProfile(); + if (isLoading) return ; + return ; } + +const CustomerProfileForm: FunctionComponent<{ initial: CustomerProfile | null }> = ({ initial }) => { + const t = useTranslations('profile'); + const tc = useTranslations('common'); + const { enqueueSnackbar } = useSnackbar(); + const upsert = useUpsertCustomerProfile(); + + const [firstName, setFirstName] = useState(initial?.firstName ?? ''); + const [lastName, setLastName] = useState(initial?.lastName ?? ''); + const [language, setLanguage] = useState(initial?.preferredLanguage ?? 'fa'); + const [emergencyName, setEmergencyName] = useState(initial?.defaultEmergencyContactName ?? ''); + const [emergencyPhone, setEmergencyPhone] = useState(digitsOnly(initial?.defaultEmergencyContactPhone ?? '')); + const [nameError, setNameError] = useState(false); + const [phoneError, setPhoneError] = useState(false); + + const isComplete = Boolean(initial?.defaultEmergencyContactName && initial?.defaultEmergencyContactPhone); + + const handleSave = () => { + const nameInvalid = emergencyName.trim().length === 0; + const phoneInvalid = !isIranianMobile(emergencyPhone); + setNameError(nameInvalid); + setPhoneError(phoneInvalid); + if (nameInvalid || phoneInvalid) return; + + upsert.mutate( + { + defaultEmergencyContactName: emergencyName.trim(), + defaultEmergencyContactPhone: emergencyPhone, + firstName: firstName.trim() || null, + lastName: lastName.trim() || null, + preferredLanguage: language, + }, + { onSuccess: () => enqueueSnackbar(t('saved'), { variant: 'success' }) }, + ); + }; + + return ( + + + + {t('title')} + + + {t('subtitle')} + + + {isComplete ? t('completion_done') : t('completion_todo')} + + + + + setFirstName(e.target.value)} fullWidth /> + setLastName(e.target.value)} fullWidth /> + + + setLanguage(e.target.value)} + sx={{ maxWidth: 220 }} + > + {t('language_fa')} + {t('language_en')} + + + + + + + {t('emergency_section')} + + + {t('emergency_hint')} + + + + { + setEmergencyName(e.target.value); + if (nameError) setNameError(false); + }} + error={nameError} + fullWidth + /> + { + setEmergencyPhone(value); + if (phoneError) setPhoneError(false); + }} + error={phoneError} + helperText={phoneError ? t('emergency_phone_invalid') : undefined} + fullWidth + /> + + + {upsert.isPending ? tc('saving') : t('save')} + + + ); +}; diff --git a/client/src/app/[locale]/(private-routes)/nurse/bank/page.tsx b/client/src/app/[locale]/(private-routes)/nurse/bank/page.tsx new file mode 100644 index 0000000..d366fdd --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/nurse/bank/page.tsx @@ -0,0 +1,158 @@ +'use client'; +import { useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { useSnackbar } from 'notistack'; +import { Box, Paper, Stack, TextField, Typography } from '@mui/material'; +import { AppButton, AppIcon, AppLoading, BankStatusPanel } from '@/components'; +import { useNurseBankAccounts, useAddNurseBankAccount, useSetPrimaryBankAccount } from '@/services/nurse'; +import { isValidSheba } from '@/services/nurse/iban'; +import { deriveBankStatus } from '@/services/nurse/types'; + +/** + * Nurse payout bank settings — submit an IBAN (شبا) + account-holder name, then watch the + * ownership inquiry resolve through its three states (pending → verified / mismatch). The list + * polls only while pending; a verified account shows the masked IBAN; mismatch offers re-enter. + */ +export default function NurseBankPage() { + const t = useTranslations('bank'); + const { enqueueSnackbar } = useSnackbar(); + + const { data, isLoading } = useNurseBankAccounts(); + const addAccount = useAddNurseBankAccount(); + const setPrimary = useSetPrimaryBankAccount(); + + const [iban, setIban] = useState(''); + const [holder, setHolder] = useState(''); + const [ibanError, setIbanError] = useState(false); + const [holderError, setHolderError] = useState(false); + const [showForm, setShowForm] = useState(false); + + const accounts = data ?? []; + const showFormNow = !isLoading && (accounts.length === 0 || showForm); + + const submit = () => { + const ibanInvalid = !isValidSheba(iban); + const holderInvalid = holder.trim().length === 0; + setIbanError(ibanInvalid); + setHolderError(holderInvalid); + if (ibanInvalid || holderInvalid) return; + + addAccount.mutate( + { iban, accountHolderName: holder.trim() }, + { + onSuccess: () => { + setIban(''); + setHolder(''); + setShowForm(false); + enqueueSnackbar(t('added'), { variant: 'success' }); + }, + onError: () => enqueueSnackbar(t('add_error'), { variant: 'error' }), + }, + ); + }; + + return ( + + + + {t('title')} + + + {t('subtitle')} + + + + {isLoading ? : null} + + {accounts.map((account) => { + const status = deriveBankStatus(account); + return ( + + setShowForm(true) : undefined} + reenterLabel={t('reenter')} + /> + {/* Promote a verified non-primary account so payouts (gated on matchedNationalId) target it. */} + {status === 'verified' && !account.isPrimary ? ( + + setPrimary.mutate(account.id, { + onSuccess: () => enqueueSnackbar(t('primary_set'), { variant: 'success' }), + }) + } + sx={{ m: 0, alignSelf: 'flex-start' }} + > + {t('make_primary')} + + ) : null} + + ); + })} + + {!isLoading && accounts.length === 0 ? ( + + + + {t('empty_title')} + + + {t('empty_body')} + + + ) : null} + + {showFormNow ? ( + + { + setIban(e.target.value.toUpperCase()); + if (ibanError) setIbanError(false); + }} + error={ibanError} + helperText={ibanError ? t('iban_invalid') : t('iban_hint')} + slotProps={{ htmlInput: { dir: 'ltr', style: { textAlign: 'start', letterSpacing: 1 } } }} + fullWidth + /> + { + setHolder(e.target.value); + if (holderError) setHolderError(false); + }} + error={holderError} + helperText={holderError ? t('holder_required') : t('holder_hint')} + fullWidth + /> + + {addAccount.isPending ? t('submitting') : t('submit')} + + + ) : null} + + ); +} diff --git a/client/src/app/[locale]/(private-routes)/nurse/profile/page.tsx b/client/src/app/[locale]/(private-routes)/nurse/profile/page.tsx new file mode 100644 index 0000000..f104bb2 --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/nurse/profile/page.tsx @@ -0,0 +1,165 @@ +'use client'; +import { ChangeEvent, FunctionComponent, useRef, useState } from 'react'; +import { useLocale, useTranslations } from 'next-intl'; +import { useSnackbar } from 'notistack'; +import { Avatar, Box, Paper, Stack, TextField, Typography } from '@mui/material'; +import { AppButton, AppIcon, AppLoading } from '@/components'; +import { ROUTES } from '@/constants'; +import { useNurseProfile, useUpsertNurseProfile, useUploadAvatar } from '@/services/profiles'; +import type { NurseProfile } from '@/services/profiles/types'; + +const MAX_YEARS = 80; + +/** Nurse profile bootstrap (B7 header): avatar + bio + years. Services/availability are deferred (f4). */ +export default function NurseProfilePage() { + const { data: profile, isLoading } = useNurseProfile(); + if (isLoading) return ; + return ; +} + +const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({ initial }) => { + const t = useTranslations('nurseProfile'); + const tc = useTranslations('common'); + const locale = useLocale(); + const { enqueueSnackbar } = useSnackbar(); + const upsert = useUpsertNurseProfile(); + const uploadAvatar = useUploadAvatar(); + const fileInputRef = useRef(null); + + const [avatarUrl, setAvatarUrl] = useState(initial?.avatarUrl ?? null); + const [bio, setBio] = useState(initial?.bio ?? ''); + const [years, setYears] = useState(initial ? String(initial.yearsOfExperience) : ''); + const [yearsError, setYearsError] = useState(false); + + const pickFile = () => fileInputRef.current?.click(); + + const onFileSelected = (event: ChangeEvent) => { + const file = event.target.files?.[0]; + event.target.value = ''; + if (!file) return; + uploadAvatar.mutate(file, { onSuccess: (result) => setAvatarUrl(result.url) }); + }; + + const handleSave = () => { + const trimmed = years.trim(); + const yearsNum = trimmed === '' ? 0 : Number(trimmed); + const yearsInvalid = !Number.isInteger(yearsNum) || yearsNum < 0 || yearsNum > MAX_YEARS; + setYearsError(yearsInvalid); + if (yearsInvalid) return; + + upsert.mutate( + { + bio: bio.trim(), + yearsOfExperience: yearsNum, + educationLevel: initial?.educationLevel ?? '', + educationField: initial?.educationField ?? '', + specializationsJson: initial?.specializationsJson ?? '[]', + avatarUrl, + }, + { onSuccess: () => enqueueSnackbar(t('saved'), { variant: 'success' }) }, + ); + }; + + return ( + + + + {t('title')} + + + {t('subtitle')} + + + + {/* Not bookable until verification (f5) — a neutral placeholder, not the real banner. */} + + + + + + + {t('unverified_title')} + + + {t('unverified_body')} + + + + {t('unverified_cta')} + + + + + + + + {avatarUrl ? null : } + + + + {t('photo')} + + + {t('photo_hint')} + + + {uploadAvatar.isPending ? t('uploading') : t('upload')} + + + + + + setBio(e.target.value)} + helperText={t('bio_hint')} + multiline + minRows={3} + fullWidth + /> + + { + setYears(e.target.value.replace(/\D/g, '').slice(0, 2)); + if (yearsError) setYearsError(false); + }} + error={yearsError} + helperText={yearsError ? t('years_invalid') : undefined} + slotProps={{ htmlInput: { dir: 'ltr', inputMode: 'numeric', style: { textAlign: 'start' } } }} + sx={{ maxWidth: 200 }} + /> + + + {t('deferred_services')} + + + + {upsert.isPending ? tc('saving') : t('save')} + + + ); +}; diff --git a/client/src/components/BankStatusPanel/BankStatusPanel.test.tsx b/client/src/components/BankStatusPanel/BankStatusPanel.test.tsx new file mode 100644 index 0000000..ceeead2 --- /dev/null +++ b/client/src/components/BankStatusPanel/BankStatusPanel.test.tsx @@ -0,0 +1,52 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ThemeProvider } from '../../theme'; +import BankStatusPanel from './BankStatusPanel'; + +describe(' component', () => { + it('renders the pending state with its chip and title', () => { + const { container } = render( + + + , + ); + expect(container.querySelector('[data-status="pending"]')).toBeInTheDocument(); + expect(screen.getByText('Verifying ownership')).toBeInTheDocument(); + expect(screen.getByText('Checking')).toBeInTheDocument(); + }); + + it('shows the masked IBAN on the verified state', () => { + render( + + + , + ); + expect(screen.getByText('••••3456')).toBeInTheDocument(); + }); + + it('offers the re-enter action only on mismatch', async () => { + const user = userEvent.setup(); + const onReenter = jest.fn(); + render( + + + , + ); + await user.click(screen.getByText('Enter another account')); + expect(onReenter).toHaveBeenCalledTimes(1); + }); +}); diff --git a/client/src/components/BankStatusPanel/BankStatusPanel.tsx b/client/src/components/BankStatusPanel/BankStatusPanel.tsx new file mode 100644 index 0000000..aa84749 --- /dev/null +++ b/client/src/components/BankStatusPanel/BankStatusPanel.tsx @@ -0,0 +1,116 @@ +'use client'; +import { FunctionComponent } from 'react'; +import Paper from '@mui/material/Paper'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { AppButton } from '@/components/common'; +import StatusChip from '@/components/StatusChip'; +import type { StatusKind } from '@/components/StatusChip'; +import type { BankAccountStatus } from '@/services/nurse/types'; + +const STATUS_KIND: Record = { + pending: 'pending', + verified: 'verified', + mismatch: 'rejected', +}; + +const ACCENT_TOKEN: Record = { + pending: 'var(--bal-warning)', + verified: 'var(--bal-success)', + mismatch: 'var(--bal-error)', +}; + +export interface BankStatusPanelProps { + status: BankAccountStatus; + /** Translated status chip / title / body for the active status. */ + chipLabel: string; + title: string; + body: string; + /** Masked IBAN (last-4), shown when present. */ + ibanMasked?: string; + ibanLabel?: string; + bankName?: string; + isPrimary?: boolean; + primaryLabel?: string; + /** Rendered only for the mismatch state (a friendly re-enter path). */ + onReenter?: () => void; + reenterLabel?: string; +} + +/** + * Renders one bank account in one of the three ownership-inquiry states — **pending**, + * **verified**, **mismatch** — each visually distinct off the semantic tokens. The IBAN is + * shown masked (last-4). Mismatch copy is passed in non-accusatory; the re-enter CTA is the + * only action offered there. All strings are translated by the caller. + * @component BankStatusPanel + */ +const BankStatusPanel: FunctionComponent = ({ + status, + chipLabel, + title, + body, + ibanMasked, + ibanLabel, + bankName, + isPrimary = false, + primaryLabel, + onReenter, + reenterLabel, +}) => ( + + + + + {isPrimary && primaryLabel ? ( + + ) : null} + + + + + {title} + + + {body} + + + + {ibanMasked ? ( + + {ibanLabel ? ( + + {ibanLabel} + + ) : null} + + {ibanMasked} + + {bankName ? ( + + {bankName} + + ) : null} + + ) : null} + + {status === 'mismatch' && onReenter && reenterLabel ? ( + + {reenterLabel} + + ) : null} + + +); + +export default BankStatusPanel; diff --git a/client/src/components/BankStatusPanel/index.tsx b/client/src/components/BankStatusPanel/index.tsx new file mode 100644 index 0000000..1abaf0f --- /dev/null +++ b/client/src/components/BankStatusPanel/index.tsx @@ -0,0 +1,4 @@ +import BankStatusPanel from './BankStatusPanel'; + +export type { BankStatusPanelProps } from './BankStatusPanel'; +export { BankStatusPanel as default, BankStatusPanel }; diff --git a/client/src/components/ConditionChips/ConditionChips.test.tsx b/client/src/components/ConditionChips/ConditionChips.test.tsx new file mode 100644 index 0000000..c4204c1 --- /dev/null +++ b/client/src/components/ConditionChips/ConditionChips.test.tsx @@ -0,0 +1,41 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ThemeProvider } from '../../theme'; +import ConditionChips from './ConditionChips'; + +const OPTIONS = [ + { code: 'elderly', label: 'Elderly' }, + { code: 'diabetes', label: 'Diabetes' }, +]; + +function renderChips(value: string[]) { + const onChange = jest.fn(); + render( + + + , + ); + return { onChange }; +} + +describe(' component', () => { + it('renders every option', () => { + renderChips([]); + expect(screen.getByText('Elderly')).toBeInTheDocument(); + expect(screen.getByText('Diabetes')).toBeInTheDocument(); + }); + + it('adds an unselected code on click', async () => { + const user = userEvent.setup(); + const { onChange } = renderChips([]); + await user.click(screen.getByText('Elderly')); + expect(onChange).toHaveBeenCalledWith(['elderly']); + }); + + it('removes an already-selected code on click', async () => { + const user = userEvent.setup(); + const { onChange } = renderChips(['elderly']); + await user.click(screen.getByText('Elderly')); + expect(onChange).toHaveBeenCalledWith([]); + }); +}); diff --git a/client/src/components/ConditionChips/ConditionChips.tsx b/client/src/components/ConditionChips/ConditionChips.tsx new file mode 100644 index 0000000..ec8cdc0 --- /dev/null +++ b/client/src/components/ConditionChips/ConditionChips.tsx @@ -0,0 +1,53 @@ +'use client'; +import { FunctionComponent } from 'react'; +import Box from '@mui/material/Box'; +import Chip from '@mui/material/Chip'; + +export interface ConditionOption { + /** Stable code stored on the patient (e.g. `elderly`). */ + code: string; + /** Translated display label. */ + label: string; +} + +export interface ConditionChipsProps { + options: ConditionOption[]; + /** Selected codes. */ + value: string[]; + onChange: (value: string[]) => void; + disabled?: boolean; +} + +/** + * Multi-select condition chips (A4). Toggles a stable code in/out of the selected set; + * selection is optional. Labels are translated by the caller. + * @component ConditionChips + */ +const ConditionChips: FunctionComponent = ({ options, value, onChange, disabled = false }) => { + const toggle = (code: string) => { + onChange(value.includes(code) ? value.filter((item) => item !== code) : [...value, code]); + }; + + return ( + + {options.map((option) => { + const selected = value.includes(option.code); + return ( + toggle(option.code)} + /> + ); + })} + + ); +}; + +export default ConditionChips; diff --git a/client/src/components/ConditionChips/index.tsx b/client/src/components/ConditionChips/index.tsx new file mode 100644 index 0000000..38f6c80 --- /dev/null +++ b/client/src/components/ConditionChips/index.tsx @@ -0,0 +1,4 @@ +import ConditionChips from './ConditionChips'; + +export type { ConditionChipsProps, ConditionOption } from './ConditionChips'; +export { ConditionChips as default, ConditionChips }; diff --git a/client/src/components/GenderToggle/GenderToggle.test.tsx b/client/src/components/GenderToggle/GenderToggle.test.tsx new file mode 100644 index 0000000..5b15877 --- /dev/null +++ b/client/src/components/GenderToggle/GenderToggle.test.tsx @@ -0,0 +1,42 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ThemeProvider } from '../../theme'; +import GenderToggle from './GenderToggle'; + +function renderToggle(value: 'male' | 'female' | null) { + const onChange = jest.fn(); + const utils = render( + + + , + ); + return { ...utils, onChange }; +} + +describe(' component', () => { + it('renders both options', () => { + renderToggle(null); + expect(screen.getByText('Male')).toBeInTheDocument(); + expect(screen.getByText('Female')).toBeInTheDocument(); + }); + + it('marks the selected value as pressed', () => { + const { container } = renderToggle('female'); + expect(container.querySelector('[data-gender="female"]')).toHaveAttribute('aria-pressed', 'true'); + expect(container.querySelector('[data-gender="male"]')).toHaveAttribute('aria-pressed', 'false'); + }); + + it('calls onChange with the picked gender', async () => { + const user = userEvent.setup(); + const { onChange } = renderToggle(null); + await user.click(screen.getByText('Male')); + expect(onChange).toHaveBeenCalledWith('male'); + }); + + it('does not fire onChange when the active value is clicked again (no deselect)', async () => { + const user = userEvent.setup(); + const { onChange } = renderToggle('male'); + await user.click(screen.getByText('Male')); + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/components/GenderToggle/GenderToggle.tsx b/client/src/components/GenderToggle/GenderToggle.tsx new file mode 100644 index 0000000..5c20c1a --- /dev/null +++ b/client/src/components/GenderToggle/GenderToggle.tsx @@ -0,0 +1,61 @@ +'use client'; +import { FunctionComponent } from 'react'; +import ToggleButton from '@mui/material/ToggleButton'; +import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; +import type { Gender } from '@/services/patients/types'; + +export interface GenderToggleProps { + /** Current selection; `null` means nothing chosen yet (gender is never defaulted). */ + value: Gender | null; + /** Fires only with a concrete gender — deselecting is ignored so the field stays required. */ + onChange: (value: Gender) => void; + maleLabel: string; + femaleLabel: string; + /** Marks the group invalid (e.g. submitted without a choice). */ + error?: boolean; + disabled?: boolean; + ariaLabel?: string; +} + +/** + * Required male/female toggle. Gender is **load-bearing** for same-gender caregiver matching + * (search/booking), so it is never defaulted and cannot be deselected back to empty via the UI. + * Labels are translated by the caller (labels are i18n keys off the code). + * @component GenderToggle + */ +const GenderToggle: FunctionComponent = ({ + value, + onChange, + maleLabel, + femaleLabel, + error = false, + disabled = false, + ariaLabel, +}) => ( + { + if (next) onChange(next); + }} + sx={{ + '& .MuiToggleButton-root': { + flex: 1, + py: 1.25, + fontWeight: 600, + borderColor: error ? 'var(--bal-error)' : undefined, + }, + }} + > + + {maleLabel} + + + {femaleLabel} + + +); + +export default GenderToggle; diff --git a/client/src/components/GenderToggle/index.tsx b/client/src/components/GenderToggle/index.tsx new file mode 100644 index 0000000..aaca3b8 --- /dev/null +++ b/client/src/components/GenderToggle/index.tsx @@ -0,0 +1,4 @@ +import GenderToggle from './GenderToggle'; + +export type { GenderToggleProps } from './GenderToggle'; +export { GenderToggle as default, GenderToggle }; diff --git a/client/src/components/PatientCard/PatientCard.test.tsx b/client/src/components/PatientCard/PatientCard.test.tsx new file mode 100644 index 0000000..3e1ddd6 --- /dev/null +++ b/client/src/components/PatientCard/PatientCard.test.tsx @@ -0,0 +1,60 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ThemeProvider } from '../../theme'; +import PatientCard from './PatientCard'; +import type { Patient } from '@/services/patients/types'; + +const PATIENT: Patient = { + id: 1, + displayName: 'Zahra Mohammadi', + firstName: 'Zahra', + lastName: 'Mohammadi', + birthDate: '1956-01-01', + gender: 'female', + bloodType: null, + initialMedicalNotes: null, + isActive: true, + relation: 'parent', + conditions: ['elderly'], +}; + +function renderCard() { + const onEdit = jest.fn(); + const onArchive = jest.fn(); + render( + + + , + ); + return { onEdit, onArchive }; +} + +describe(' component', () => { + it('renders name, relation, meta and conditions', () => { + renderCard(); + expect(screen.getByText('Zahra Mohammadi')).toBeInTheDocument(); + expect(screen.getByText('Parent')).toBeInTheDocument(); + expect(screen.getByText('70 yrs · Female')).toBeInTheDocument(); + expect(screen.getByText('Elderly')).toBeInTheDocument(); + }); + + it('calls onEdit and onArchive from the action buttons', async () => { + const user = userEvent.setup(); + const { onEdit, onArchive } = renderCard(); + await user.click(screen.getByLabelText('Edit')); + await user.click(screen.getByLabelText('Archive')); + expect(onEdit).toHaveBeenCalledTimes(1); + expect(onArchive).toHaveBeenCalledTimes(1); + }); +}); diff --git a/client/src/components/PatientCard/PatientCard.tsx b/client/src/components/PatientCard/PatientCard.tsx new file mode 100644 index 0000000..3e3e3bc --- /dev/null +++ b/client/src/components/PatientCard/PatientCard.tsx @@ -0,0 +1,88 @@ +'use client'; +import { FunctionComponent } from 'react'; +import Box from '@mui/material/Box'; +import Chip from '@mui/material/Chip'; +import Paper from '@mui/material/Paper'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { AppIconButton } from '@/components/common'; +import type { Patient } from '@/services/patients/types'; + +export interface PatientCardProps { + patient: Patient; + /** Translated relation label; omitted when the patient has no relation set. */ + relationLabel?: string; + /** Translated gender label. */ + genderLabel: string; + /** Translated age label (e.g. "70 yrs"); omitted when the birth date is unknown. */ + ageLabel?: string; + /** Translated condition labels; empty renders the "no conditions" line. */ + conditionLabels: string[]; + noConditionsLabel: string; + onEdit: () => void; + onArchive: () => void; + editLabel: string; + archiveLabel: string; +} + +/** + * Patient summary card for the E1 list — relation + name, age/gender, and condition chips, + * with edit and archive actions. All display text is translated by the caller. + * @component PatientCard + */ +const PatientCard: FunctionComponent = ({ + patient, + relationLabel, + genderLabel, + ageLabel, + conditionLabels, + noConditionsLabel, + onEdit, + onArchive, + editLabel, + archiveLabel, +}) => { + const meta = [ageLabel, genderLabel].filter(Boolean).join(' · '); + + return ( + + + + + + {patient.displayName} + + {relationLabel ? ( + + ) : null} + + + {meta ? ( + + {meta} + + ) : null} + + {conditionLabels.length > 0 ? ( + + {conditionLabels.map((label) => ( + + ))} + + ) : ( + + {noConditionsLabel} + + )} + + + + + + + + + ); +}; + +export default PatientCard; diff --git a/client/src/components/PatientCard/index.tsx b/client/src/components/PatientCard/index.tsx new file mode 100644 index 0000000..5f64129 --- /dev/null +++ b/client/src/components/PatientCard/index.tsx @@ -0,0 +1,4 @@ +import PatientCard from './PatientCard'; + +export type { PatientCardProps } from './PatientCard'; +export { PatientCard as default, PatientCard }; diff --git a/client/src/components/PatientForm/PatientForm.test.tsx b/client/src/components/PatientForm/PatientForm.test.tsx new file mode 100644 index 0000000..24e8f80 --- /dev/null +++ b/client/src/components/PatientForm/PatientForm.test.tsx @@ -0,0 +1,50 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ThemeProvider } from '../../theme'; + +jest.mock('next-intl', () => ({ useTranslations: () => (key: string) => key })); + +import PatientForm from './PatientForm'; + +function renderForm() { + const onSubmit = jest.fn(); + render( + + + , + ); + return { onSubmit }; +} + +describe(' component', () => { + it('blocks submit and flags gender when it is missing', async () => { + const user = userEvent.setup(); + const { onSubmit } = renderForm(); + await user.type(screen.getByLabelText('name_label'), 'Ali Rezaei'); + await user.type(screen.getByLabelText('age_label'), '40'); + await user.click(screen.getByText('Save')); + expect(onSubmit).not.toHaveBeenCalled(); + expect(screen.getByText('gender_required')).toBeInTheDocument(); + }); + + it('submits the mapped patient input once name, age and gender are set', async () => { + const user = userEvent.setup(); + const { onSubmit } = renderForm(); + await user.type(screen.getByLabelText('name_label'), 'Ali Rezaei'); + await user.type(screen.getByLabelText('age_label'), '40'); + await user.click(screen.getByText('gender_male')); + await user.click(screen.getByText('Save')); + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + displayName: 'Ali Rezaei', + firstName: 'Ali', + lastName: 'Rezaei', + gender: 'male', + relation: null, + conditions: [], + birthDate: expect.stringMatching(/^\d{4}-01-01$/), + }), + ); + }); +}); diff --git a/client/src/components/PatientForm/PatientForm.tsx b/client/src/components/PatientForm/PatientForm.tsx new file mode 100644 index 0000000..515d964 --- /dev/null +++ b/client/src/components/PatientForm/PatientForm.tsx @@ -0,0 +1,182 @@ +'use client'; +import { FunctionComponent, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import FormLabel from '@mui/material/FormLabel'; +import Stack from '@mui/material/Stack'; +import TextField from '@mui/material/TextField'; +import Typography from '@mui/material/Typography'; +import { AppButton } from '@/components/common'; +import GenderToggle from '@/components/GenderToggle'; +import ConditionChips from '@/components/ConditionChips'; +import RelationSelect from '@/components/RelationSelect'; +import { digitsOnly } from '@/utils'; +import { CONDITION_CODES, RELATION_CODES } from '@/services/patients/constants'; +import { ageToBirthDate, birthDateToAge } from '@/services/patients/age'; +import type { ConditionCode, CreatePatientInput, Gender, Patient, Relation } from '@/services/patients/types'; + +const MAX_AGE = 120; + +export interface PatientFormProps { + /** Prefill for edit, or a relation carried from the onboarding relation step. */ + initial?: Partial>; + /** Show the relation picker (E1 add/edit). Onboarding hides it — A3 already chose the relation. */ + showRelation?: boolean; + submitLabel: string; + submitting?: boolean; + onSubmit: (input: CreatePatientInput) => void; + onCancel?: () => void; + cancelLabel?: string; +} + +// A single full-name field (per the wireframe) maps to the contract's first/last/display. +function splitName(fullName: string): Pick { + const displayName = fullName.trim(); + const parts = displayName.split(/\s+/); + const firstName = parts[0] ?? ''; + const lastName = parts.slice(1).join(' ') || firstName; + return { displayName, firstName, lastName }; +} + +/** + * The A4 patient form — full name, age, **required** gender, optional condition chips, and + * (for E1) the relation. Reused for create and edit. Gender is required and never defaulted; + * age maps to `birthDate`. Strings come from the `onboarding` namespace. + * @component PatientForm + */ +const PatientForm: FunctionComponent = ({ + initial, + showRelation = false, + submitLabel, + submitting = false, + onSubmit, + onCancel, + cancelLabel, +}) => { + const t = useTranslations('onboarding'); + + const [fullName, setFullName] = useState(initial?.displayName ?? ''); + const [age, setAge] = useState(() => { + const initialAge = birthDateToAge(initial?.birthDate); + return initialAge == null ? '' : String(initialAge); + }); + const [gender, setGender] = useState(initial?.gender ?? null); + const [conditions, setConditions] = useState(initial?.conditions ?? []); + const [relation, setRelation] = useState(initial?.relation ?? null); + + const [nameError, setNameError] = useState(false); + const [ageError, setAgeError] = useState(false); + const [genderError, setGenderError] = useState(false); + + const conditionOptions = CONDITION_CODES.map((code) => ({ code, label: t(`condition_${code}`) })); + const relationOptions = RELATION_CODES.map((code) => ({ code, label: t(`relation_${code}`) })); + + const handleSubmit = () => { + const name = fullName.trim(); + const ageNum = Number(digitsOnly(age)); + const nameInvalid = name.length === 0; + const ageInvalid = age.trim().length === 0 || !Number.isInteger(ageNum) || ageNum < 0 || ageNum > MAX_AGE; + const genderInvalid = gender == null; + + setNameError(nameInvalid); + setAgeError(ageInvalid); + setGenderError(genderInvalid); + if (nameInvalid || ageInvalid || genderInvalid) return; + + onSubmit({ + ...splitName(name), + birthDate: ageToBirthDate(ageNum), + gender: gender as Gender, + bloodType: null, + initialMedicalNotes: null, + relation, + conditions: conditions as ConditionCode[], + }); + }; + + return ( + + { + setFullName(event.target.value); + if (nameError) setNameError(false); + }} + error={nameError} + helperText={nameError ? t('name_required') : undefined} + fullWidth + /> + + { + setAge(digitsOnly(event.target.value).slice(0, 3)); + if (ageError) setAgeError(false); + }} + error={ageError} + helperText={ageError ? t('age_invalid') : undefined} + slotProps={{ htmlInput: { dir: 'ltr', inputMode: 'numeric', style: { textAlign: 'start' } } }} + sx={{ maxWidth: 160 }} + /> + + + {t('gender_label')} + { + setGender(next); + if (genderError) setGenderError(false); + }} + maleLabel={t('gender_male')} + femaleLabel={t('gender_female')} + error={genderError} + ariaLabel={t('gender_label')} + /> + {genderError ? ( + + {t('gender_required')} + + ) : null} + + + + {t('conditions_label')} + + {t('conditions_hint')} + + + + + {showRelation ? ( + + {t('relation_title')} + setRelation(code as Relation)} + /> + + ) : null} + + + {onCancel ? ( + + {cancelLabel} + + ) : null} + + {submitLabel} + + + + ); +}; + +export default PatientForm; diff --git a/client/src/components/PatientForm/index.tsx b/client/src/components/PatientForm/index.tsx new file mode 100644 index 0000000..1d910e4 --- /dev/null +++ b/client/src/components/PatientForm/index.tsx @@ -0,0 +1,4 @@ +import PatientForm from './PatientForm'; + +export type { PatientFormProps } from './PatientForm'; +export { PatientForm as default, PatientForm }; diff --git a/client/src/components/RelationSelect/RelationSelect.test.tsx b/client/src/components/RelationSelect/RelationSelect.test.tsx new file mode 100644 index 0000000..ebc63f8 --- /dev/null +++ b/client/src/components/RelationSelect/RelationSelect.test.tsx @@ -0,0 +1,40 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ThemeProvider } from '../../theme'; +import RelationSelect from './RelationSelect'; + +const OPTIONS = [ + { code: 'parent', label: 'Parent' }, + { code: 'self', label: 'Myself' }, +]; + +function renderSelect(value: string | null) { + const onChange = jest.fn(); + const utils = render( + + + , + ); + return { ...utils, onChange }; +} + +describe(' component', () => { + it('renders every relation option', () => { + renderSelect(null); + expect(screen.getByText('Parent')).toBeInTheDocument(); + expect(screen.getByText('Myself')).toBeInTheDocument(); + }); + + it('marks the selected option as checked', () => { + const { container } = renderSelect('self'); + expect(container.querySelector('[data-code="self"]')).toHaveAttribute('aria-checked', 'true'); + expect(container.querySelector('[data-code="parent"]')).toHaveAttribute('aria-checked', 'false'); + }); + + it('calls onChange with the picked code', async () => { + const user = userEvent.setup(); + const { onChange } = renderSelect(null); + await user.click(screen.getByText('Parent')); + expect(onChange).toHaveBeenCalledWith('parent'); + }); +}); diff --git a/client/src/components/RelationSelect/RelationSelect.tsx b/client/src/components/RelationSelect/RelationSelect.tsx new file mode 100644 index 0000000..a7ce0fb --- /dev/null +++ b/client/src/components/RelationSelect/RelationSelect.tsx @@ -0,0 +1,68 @@ +'use client'; +import { FunctionComponent } from 'react'; +import Paper from '@mui/material/Paper'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import AppIcon from '@/components/common/AppIcon'; + +export interface RelationOption { + /** Stable code (`parent`/`spouse`/`child`/`self`). */ + code: string; + /** Translated label. */ + label: string; + /** Optional AppIcon name for the card. */ + icon?: string; +} + +export interface RelationSelectProps { + options: RelationOption[]; + value: string | null; + onChange: (value: string) => void; +} + +/** + * Single-select relation picker rendered as radio cards (A3 "who is care for?"). The relation + * is a stable enum code carried into the patient; labels are translated by the caller. + * @component RelationSelect + */ +const RelationSelect: FunctionComponent = ({ options, value, onChange }) => ( + + {options.map((option) => { + const selected = value === option.code; + return ( + onChange(option.code)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onChange(option.code); + } + }} + sx={{ + p: 2, + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + gap: 2, + border: '2px solid', + borderColor: selected ? 'primary.main' : 'divider', + borderRadius: 2, + }} + > + {option.icon ? : null} + + {option.label} + + + ); + })} + +); + +export default RelationSelect; diff --git a/client/src/components/RelationSelect/index.tsx b/client/src/components/RelationSelect/index.tsx new file mode 100644 index 0000000..f9861ab --- /dev/null +++ b/client/src/components/RelationSelect/index.tsx @@ -0,0 +1,4 @@ +import RelationSelect from './RelationSelect'; + +export type { RelationSelectProps, RelationOption } from './RelationSelect'; +export { RelationSelect as default, RelationSelect }; diff --git a/client/src/components/common/AppIcon/config.ts b/client/src/components/common/AppIcon/config.ts index ff25dc5..d756f29 100644 --- a/client/src/components/common/AppIcon/config.ts +++ b/client/src/components/common/AppIcon/config.ts @@ -32,6 +32,11 @@ import CancelIcon from '@mui/icons-material/Cancel'; import MedicalServicesIcon from '@mui/icons-material/MedicalServices'; import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings'; import AddIcon from '@mui/icons-material/Add'; +import EditIcon from '@mui/icons-material/EditOutlined'; +import ArchiveIcon from '@mui/icons-material/Inventory2Outlined'; +import BankIcon from '@mui/icons-material/AccountBalanceOutlined'; +import CameraIcon from '@mui/icons-material/PhotoCameraOutlined'; +import WarningIcon from '@mui/icons-material/WarningAmberOutlined'; /** * List of all available Icon names @@ -79,4 +84,9 @@ export const ICONS /* Note: Setting type disables property autocomplete :( was - visits: MedicalServicesIcon, admin: AdminPanelSettingsIcon, add: AddIcon, + edit: EditIcon, + archive: ArchiveIcon, + bank: BankIcon, + camera: CameraIcon, + warning: WarningIcon, }; diff --git a/client/src/components/index.tsx b/client/src/components/index.tsx index e33dfce..15a2870 100644 --- a/client/src/components/index.tsx +++ b/client/src/components/index.tsx @@ -6,10 +6,35 @@ import OtpInput from './OtpInput'; import PhoneNumberField from './PhoneNumberField'; import StepperHeader from './StepperHeader'; import StatusChip from './StatusChip'; +import GenderToggle from './GenderToggle'; +import ConditionChips from './ConditionChips'; +import RelationSelect from './RelationSelect'; +import PatientCard from './PatientCard'; +import PatientForm from './PatientForm'; +import BankStatusPanel from './BankStatusPanel'; -export { UserInfo, PlaceholderScreen, OtpInput, PhoneNumberField, StepperHeader, StatusChip }; +export { + UserInfo, + PlaceholderScreen, + OtpInput, + PhoneNumberField, + StepperHeader, + StatusChip, + GenderToggle, + ConditionChips, + RelationSelect, + PatientCard, + PatientForm, + BankStatusPanel, +}; export type { PlaceholderScreenProps } from './PlaceholderScreen'; export type { OtpInputProps } from './OtpInput'; export type { PhoneNumberFieldProps } from './PhoneNumberField'; export type { StepperHeaderProps } from './StepperHeader'; export type { StatusChipProps, StatusKind } from './StatusChip'; +export type { GenderToggleProps } from './GenderToggle'; +export type { ConditionChipsProps, ConditionOption } from './ConditionChips'; +export type { RelationSelectProps, RelationOption } from './RelationSelect'; +export type { PatientCardProps } from './PatientCard'; +export type { PatientFormProps } from './PatientForm'; +export type { BankStatusPanelProps } from './BankStatusPanel'; diff --git a/client/src/constants/routes.ts b/client/src/constants/routes.ts index 3527c05..191581e 100644 --- a/client/src/constants/routes.ts +++ b/client/src/constants/routes.ts @@ -5,6 +5,8 @@ export const ROUTES = { // Customer (family) app — mobile-first, bottom-tab nav HOME: '/', + // First-login "who is care for?" flow (A3→A4); re-enterable from the patient list. + ONBOARDING: '/onboarding', BOOKINGS: '/bookings', PATIENTS: '/patients', WALLET: '/wallet', @@ -12,6 +14,8 @@ export const ROUTES = { // Nurse app NURSE: '/nurse', + NURSE_PROFILE: '/nurse/profile', + NURSE_BANK: '/nurse/bank', NURSE_VERIFICATION: '/nurse/verification', NURSE_VISITS: '/nurse/visits', diff --git a/client/src/layout/NurseLayout.tsx b/client/src/layout/NurseLayout.tsx index b6d213d..a76ed91 100644 --- a/client/src/layout/NurseLayout.tsx +++ b/client/src/layout/NurseLayout.tsx @@ -18,6 +18,8 @@ const NurseLayout: FunctionComponent = ({ children }) => { const sidebarItems: Array = useMemo( () => [ { title: t('dashboard'), path: ROUTES.NURSE, icon: 'dashboard' }, + { title: t('profile'), path: ROUTES.NURSE_PROFILE, icon: 'profile' }, + { title: t('bank'), path: ROUTES.NURSE_BANK, icon: 'bank' }, { title: t('verification'), path: ROUTES.NURSE_VERIFICATION, icon: 'verification' }, { title: t('visits'), path: ROUTES.NURSE_VISITS, icon: 'visits' }, ], diff --git a/client/src/services/nurse/apis/clientApi.ts b/client/src/services/nurse/apis/clientApi.ts new file mode 100644 index 0000000..da310f5 --- /dev/null +++ b/client/src/services/nurse/apis/clientApi.ts @@ -0,0 +1,32 @@ +import { clientFetch } from '@/lib/api/client'; +import { unwrap, type ApiEnvelope } from '@/lib/api/types'; +import { normalizeSheba, shebaBankName } from '../iban'; +import type { AddBankAccountInput, NurseBankAccountDto, NurseBankAccountsApi } from '../types'; + +const BASE = '/api/v1/nurse_bank_accounts'; + +/** + * Real HTTP implementation of the NurseBankAccountsApi seam (b3 action-style routes). `add` + * runs the ownership inquiry server-side and returns the account with `matchedNationalId` + * already set, so the real path needs no polling. Selected once USE_NURSE_BANK_MOCK is false. + */ +export const nurseBankClientApi: NurseBankAccountsApi = { + list: async () => unwrap(await clientFetch>(`${BASE}/list`)), + + add: async (input: AddBankAccountInput) => { + const iban = normalizeSheba(input.iban); + return unwrap( + await clientFetch>(`${BASE}/add`, { + method: 'POST', + body: JSON.stringify({ bankName: shebaBankName(iban), accountHolderName: input.accountHolderName, iban }), + }), + ); + }, + + setPrimary: async (id: number) => { + await clientFetch>(`${BASE}/set_primary/${id}`, { method: 'POST' }); + }, + + verifyOwnership: async (id: number) => + unwrap(await clientFetch>(`${BASE}/verify_ownership/${id}`, { method: 'POST' })), +}; diff --git a/client/src/services/nurse/apis/index.ts b/client/src/services/nurse/apis/index.ts new file mode 100644 index 0000000..bc5a638 --- /dev/null +++ b/client/src/services/nurse/apis/index.ts @@ -0,0 +1,10 @@ +import { USE_NURSE_BANK_MOCK } from '../constants'; +import type { NurseBankAccountsApi } from '../types'; +import { nurseBankClientApi } from './clientApi'; +import { nurseBankMockApi } from './mockApi'; + +/** + * The selected NurseBankAccountsApi implementation — the single seam hooks import. Selection + * is by config (USE_NURSE_BANK_MOCK), never by scattered `if (mock)` checks. + */ +export const nurseBankApi: NurseBankAccountsApi = USE_NURSE_BANK_MOCK ? nurseBankMockApi : nurseBankClientApi; diff --git a/client/src/services/nurse/apis/mockApi.ts b/client/src/services/nurse/apis/mockApi.ts new file mode 100644 index 0000000..8d80321 --- /dev/null +++ b/client/src/services/nurse/apis/mockApi.ts @@ -0,0 +1,81 @@ +import { sleep } from '@/utils'; +import { ApiError } from '@/lib/api/errors'; +import { KNOWN_MISMATCH_IBAN } from '../constants'; +import { normalizeSheba, shebaBankName } from '../iban'; +import type { AddBankAccountInput, NurseBankAccountDto, NurseBankAccountsApi } from '../types'; + +const MOCK_LATENCY_MS = 400; + +// The number of list reads the inquiry stays pending before resolving. 2 lets the pending +// panel show on the invalidation-triggered read, then flip on the next poll — so the +// pending→verified/mismatch transition is visible without a manual reload. +const POLLS_BEFORE_RESOLVE = 2; + +interface StoredAccount { + dto: NurseBankAccountDto; + iban: string; // normalized full value — mock-only; the real value is encrypted server-side + pollsLeft: number; +} + +let store: StoredAccount[] = []; +let nextId = 1; + +function maskIban(normalized: string): string { + return `••••${normalized.slice(-4)}`; +} + +// Deterministic fake استعلام شبا: every IBAN matches except the configured mismatch IBAN. +function resolveIfDue(entry: StoredAccount): void { + if (entry.dto.matchedNationalId !== null || entry.pollsLeft <= 0) return; + entry.pollsLeft -= 1; + if (entry.pollsLeft > 0) return; + const matched = normalizeSheba(entry.iban) !== normalizeSheba(KNOWN_MISMATCH_IBAN); + entry.dto = { ...entry.dto, matchedNationalId: matched, isVerified: matched }; +} + +/** + * In-memory mock behind the NurseBankAccountsApi seam. Drives the pending→verified/mismatch + * transition and single-primary enforcement so all three UI states are demonstrable. Mirrors + * the real shapes (masked IBAN, `matchedNationalId` gate) for a one-line swap. + */ +export const nurseBankMockApi: NurseBankAccountsApi = { + list: async (): Promise => { + await sleep(MOCK_LATENCY_MS); + store.forEach(resolveIfDue); + return store.map((entry) => entry.dto); + }, + + add: async (input: AddBankAccountInput): Promise => { + await sleep(MOCK_LATENCY_MS); + const normalized = normalizeSheba(input.iban); + if (store.some((entry) => entry.iban === normalized)) { + throw new ApiError(400, 'Duplicate IBAN', 'iban_duplicate'); + } + const dto: NurseBankAccountDto = { + id: nextId++, + bankName: shebaBankName(normalized), + ibanMasked: maskIban(normalized), + isPrimary: store.length === 0, + isVerified: false, + matchedNationalId: null, + }; + store = [...store, { dto, iban: normalized, pollsLeft: POLLS_BEFORE_RESOLVE }]; + return dto; + }, + + setPrimary: async (id: number): Promise => { + await sleep(MOCK_LATENCY_MS); + if (!store.some((entry) => entry.dto.id === id)) throw new ApiError(404, 'Account not found'); + store = store.map((entry) => ({ ...entry, dto: { ...entry.dto, isPrimary: entry.dto.id === id } })); + }, + + verifyOwnership: async (id: number): Promise => { + await sleep(MOCK_LATENCY_MS); + const entry = store.find((item) => item.dto.id === id); + if (!entry) throw new ApiError(404, 'Account not found'); + const matched = normalizeSheba(entry.iban) !== normalizeSheba(KNOWN_MISMATCH_IBAN); + entry.dto = { ...entry.dto, matchedNationalId: matched, isVerified: matched }; + entry.pollsLeft = 0; + return entry.dto; + }, +}; diff --git a/client/src/services/nurse/constants.ts b/client/src/services/nurse/constants.ts new file mode 100644 index 0000000..c40bc1f --- /dev/null +++ b/client/src/services/nurse/constants.ts @@ -0,0 +1,20 @@ +/** + * When true, the nurse bank-account domain is served by the in-memory mock behind the + * NurseBankAccountsApi seam. The b3 endpoints are live, but the استعلام شبا ownership + * inquiry is itself backend-mocked (`IBankAccountOwnershipVerifier`), so this phase drives + * the pending→verified/mismatch UI transition behind the client mock. Flip to false to use + * the real endpoints — no hook/component changes (mocks-registry.md). + */ +export const USE_NURSE_BANK_MOCK = true; + +/** Bank accounts change rarely; keep them warm across screen visits. */ +export const BANK_STALE_TIME = 30_000; + +/** Poll interval (ms) used only while an account's ownership inquiry is pending. */ +export const BANK_POLL_INTERVAL_MS = 2_000; + +/** + * The IBAN that the (mock) ownership inquiry resolves to a mismatch, so the mismatch UI + * state is demonstrable end-to-end. Mirrors the backend default `Seams:BankOwnership:MismatchIban`. + */ +export const KNOWN_MISMATCH_IBAN = 'IR000000000000000000000000'; diff --git a/client/src/services/nurse/hooks/useAddNurseBankAccount.ts b/client/src/services/nurse/hooks/useAddNurseBankAccount.ts new file mode 100644 index 0000000..1f1a305 --- /dev/null +++ b/client/src/services/nurse/hooks/useAddNurseBankAccount.ts @@ -0,0 +1,21 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { nurseBankApi } from '../apis'; +import { bankKeys } from '../keys'; +import type { AddBankAccountInput } from '../types'; + +/** + * Submits an IBAN + account-holder name; the server kicks off the ownership inquiry and + * returns the account (pending in the mock, resolved on the real path). Invalidates the list + * so the pending state — and its later transition — surfaces on the next read/poll. Domain + * 400s (invalid/duplicate IBAN, no nurse profile) surface via `mutation.error`. + */ +export function useAddNurseBankAccount() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (input: AddBankAccountInput) => nurseBankApi.add(input), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: bankKeys.list() }); + }, + }); +} diff --git a/client/src/services/nurse/hooks/useNurseBankAccounts.ts b/client/src/services/nurse/hooks/useNurseBankAccounts.ts new file mode 100644 index 0000000..6b5c84f --- /dev/null +++ b/client/src/services/nurse/hooks/useNurseBankAccounts.ts @@ -0,0 +1,25 @@ +import { useQuery } from '@tanstack/react-query'; +import { useIsAuthenticated } from '@/hooks'; +import { nurseBankApi } from '../apis'; +import { bankKeys } from '../keys'; +import { BANK_POLL_INTERVAL_MS, BANK_STALE_TIME } from '../constants'; +import { deriveBankStatus, type NurseBankAccountDto } from '../types'; + +/** + * The nurse's bank accounts (usually one primary). Polls **only while an account's ownership + * inquiry is pending** so the pending→verified/mismatch transition appears without a manual + * reload, then stops once every account has resolved. + */ +export function useNurseBankAccounts() { + const isAuthenticated = useIsAuthenticated(); + return useQuery({ + queryKey: bankKeys.list(), + queryFn: () => nurseBankApi.list(), + enabled: isAuthenticated, + staleTime: BANK_STALE_TIME, + refetchInterval: (query) => { + const accounts = (query.state.data ?? []) as NurseBankAccountDto[]; + return accounts.some((account) => deriveBankStatus(account) === 'pending') ? BANK_POLL_INTERVAL_MS : false; + }, + }); +} diff --git a/client/src/services/nurse/hooks/useSetPrimaryBankAccount.ts b/client/src/services/nurse/hooks/useSetPrimaryBankAccount.ts new file mode 100644 index 0000000..849bedd --- /dev/null +++ b/client/src/services/nurse/hooks/useSetPrimaryBankAccount.ts @@ -0,0 +1,18 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { nurseBankApi } from '../apis'; +import { bankKeys } from '../keys'; + +/** + * Makes an account the payout primary; single-primary enforcement is server-side (the prior + * primary is cleared atomically). Invalidates the list so the cache reflects the switch. + */ +export function useSetPrimaryBankAccount() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (id: number) => nurseBankApi.setPrimary(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: bankKeys.list() }); + }, + }); +} diff --git a/client/src/services/nurse/iban.ts b/client/src/services/nurse/iban.ts new file mode 100644 index 0000000..71b99b9 --- /dev/null +++ b/client/src/services/nurse/iban.ts @@ -0,0 +1,48 @@ +import { digitsOnly } from '@/utils'; + +/** An Iranian IBAN (شبا) is `IR` + 24 digits. */ +export const SHEBA_DIGIT_COUNT = 24; + +/** + * Normalizes user input to canonical `IR`+24-digit form: uppercases, strips spaces, drops a + * leading `IR`, keeps ASCII digits (Persian/Arabic normalized), caps at 24. Partial input + * yields fewer digits (so `isValidSheba` still fails). + */ +export function normalizeSheba(input: string): string { + const raw = input.trim().toUpperCase().replace(/\s+/g, ''); + const withoutPrefix = raw.startsWith('IR') ? raw.slice(2) : raw; + return `IR${digitsOnly(withoutPrefix).slice(0, SHEBA_DIGIT_COUNT)}`; +} + +/** True when the input is a well-formed Sheba (`IR` + exactly 24 digits) after normalization. */ +export function isValidSheba(input: string): boolean { + return /^IR\d{24}$/.test(normalizeSheba(input)); +} + +// Bank identifier = the 3 digits after the 2 check digits (BBAN prefix). Reference data used +// to populate the add-body `bankName`; the returned DTO's bankName is authoritative for display. +const BANK_NAMES: Record = { + '011': 'بانک صنعت و معدن', + '012': 'بانک ملت', + '013': 'بانک رفاه کارگران', + '014': 'بانک مسکن', + '015': 'بانک سپه', + '016': 'بانک کشاورزی', + '017': 'بانک ملی ایران', + '018': 'بانک تجارت', + '019': 'بانک صادرات ایران', + '021': 'پست بانک ایران', + '053': 'بانک کارآفرین', + '054': 'بانک پارسیان', + '055': 'بانک اقتصاد نوین', + '057': 'بانک پاسارگاد', + '062': 'بانک آینده', +}; + +/** Best-effort bank name from the IBAN's 3-digit bank code; empty string when unknown. */ +export function shebaBankName(input: string): string { + const normalized = normalizeSheba(input); + if (!/^IR\d{24}$/.test(normalized)) return ''; + const bankCode = normalized.slice(4, 7); + return BANK_NAMES[bankCode] ?? ''; +} diff --git a/client/src/services/nurse/index.ts b/client/src/services/nurse/index.ts new file mode 100644 index 0000000..80514fa --- /dev/null +++ b/client/src/services/nurse/index.ts @@ -0,0 +1,3 @@ +export { useNurseBankAccounts } from './hooks/useNurseBankAccounts'; +export { useAddNurseBankAccount } from './hooks/useAddNurseBankAccount'; +export { useSetPrimaryBankAccount } from './hooks/useSetPrimaryBankAccount'; diff --git a/client/src/services/nurse/keys.ts b/client/src/services/nurse/keys.ts new file mode 100644 index 0000000..995d4ca --- /dev/null +++ b/client/src/services/nurse/keys.ts @@ -0,0 +1,5 @@ +/** React Query key factory for the nurse bank-account domain. */ +export const bankKeys = { + all: ['nurse-bank-accounts'] as const, + list: () => [...bankKeys.all, 'list'] as const, +}; diff --git a/client/src/services/nurse/types.ts b/client/src/services/nurse/types.ts new file mode 100644 index 0000000..7cd5988 --- /dev/null +++ b/client/src/services/nurse/types.ts @@ -0,0 +1,42 @@ +/** + * Nurse payout bank-account sub-domain (kept separate from the profile because + * verification/payouts read it independently). Shapes mirror the b3 contract + * (`dev/contracts/domains/identity-profiles.md` → `NurseBankAccountDto`). The full IBAN is + * never returned — the DTO carries `ibanMasked` (last-4 only). + */ + +/** `NurseBankAccountDto`. `matchedNationalId` is null until the ownership inquiry runs. */ +export interface NurseBankAccountDto { + id: number; + bankName: string; + /** Last-4 only, e.g. `••••3456`. */ + ibanMasked: string; + isPrimary: boolean; + isVerified: boolean; + matchedNationalId: boolean | null; +} + +/** The form input — bankName is derived from the IBAN in the API impl (see iban.ts). */ +export interface AddBankAccountInput { + iban: string; + accountHolderName: string; +} + +/** + * The three ownership-inquiry UI states. `matchedNationalId` is the gating field: + * null → pending, false → mismatch, true → verified (the b13 first-payout gate). + */ +export type BankAccountStatus = 'pending' | 'verified' | 'mismatch'; + +export function deriveBankStatus(account: Pick): BankAccountStatus { + if (account.matchedNationalId == null) return 'pending'; + return account.matchedNationalId ? 'verified' : 'mismatch'; +} + +/** The domain's API seam — a mock and the real client both implement this interface. */ +export interface NurseBankAccountsApi { + list(): Promise; + add(input: AddBankAccountInput): Promise; + setPrimary(id: number): Promise; + verifyOwnership(id: number): Promise; +} diff --git a/client/src/services/patients/age.ts b/client/src/services/patients/age.ts new file mode 100644 index 0000000..c076698 --- /dev/null +++ b/client/src/services/patients/age.ts @@ -0,0 +1,22 @@ +/** + * Age ↔ birth-date helpers. The A4 form collects a whole-year **age** (per the wireframe) + * while the contract stores a `birthDate` (`YYYY-MM-DD`) — we map between them here. Birth + * date is approximated as 1 January of the birth year; that round-trips back to the same age. + */ + +/** Approximate ISO birth date (`YYYY-MM-01-01`) for a whole-year age. */ +export function ageToBirthDate(age: number, now: Date = new Date()): string { + const year = now.getUTCFullYear() - Math.max(0, Math.floor(age)); + return `${year}-01-01`; +} + +/** Whole-year age from an ISO birth date (floored); null for an empty/invalid date. */ +export function birthDateToAge(birthDate: string | null | undefined, now: Date = new Date()): number | null { + if (!birthDate) return null; + const date = new Date(birthDate); + if (Number.isNaN(date.getTime())) return null; + let age = now.getUTCFullYear() - date.getUTCFullYear(); + const monthDelta = now.getUTCMonth() - date.getUTCMonth(); + if (monthDelta < 0 || (monthDelta === 0 && now.getUTCDate() < date.getUTCDate())) age -= 1; + return age < 0 ? null : age; +} diff --git a/client/src/services/patients/apis/clientApi.ts b/client/src/services/patients/apis/clientApi.ts index bbbcc10..d497bc6 100644 --- a/client/src/services/patients/apis/clientApi.ts +++ b/client/src/services/patients/apis/clientApi.ts @@ -1,29 +1,70 @@ import { clientFetch } from '@/lib/api/client'; import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types'; -import type { CreatePatientDto, Patient, PatientsApi } from '../types'; +import type { CreatePatientInput, Patient, PatientDto, PatientsApi } from '../types'; -const BASE = '/patients'; +const BASE = '/api/v1/patients'; + +// The wire `PatientDto` has no relation/conditions yet (REQ-005). Reads default them; writes +// echo the caller's choice onto the returned row so the just-edited card reflects it (not +// yet persisted server-side). +function toPatient(dto: PatientDto, augment?: Pick): Patient { + return { ...dto, relation: augment?.relation ?? null, conditions: augment?.conditions ?? [] }; +} + +// Only the wire fields cross the boundary — relation/conditions are client-augmented (REQ-005). +function toBody(input: CreatePatientInput) { + const { displayName, firstName, lastName, birthDate, gender } = input; + return { + displayName, + firstName, + lastName, + birthDate, + gender, + bloodType: input.bloodType ?? null, + initialMedicalNotes: input.initialMedicalNotes ?? null, + }; +} /** - * Real HTTP implementation of the PatientsApi seam. Wired to `clientFetch`, which - * returns the raw server envelope — so each call reads the payload via `unwrap`. - * Not selected until USE_PATIENTS_MOCK is false and the endpoints exist. + * Real HTTP implementation of the PatientsApi seam (b3 action-style routes). `clientFetch` + * returns the raw envelope, so each call reads its payload via `unwrap`. Selected once + * USE_PATIENTS_MOCK is false and the relation/conditions fields land. */ export const patientsClientApi: PatientsApi = { list: async (params) => { const query = new URLSearchParams(); if (params?.page) query.set('page', String(params.page)); - if (params?.pageSize) query.set('page_size', String(params.pageSize)); + if (params?.pageSize) query.set('pageSize', String(params.pageSize)); const qs = query.toString(); - const env = await clientFetch>>(`${BASE}${qs ? `?${qs}` : ''}`); - return unwrap(env); + const page = unwrap(await clientFetch>>(`${BASE}/list${qs ? `?${qs}` : ''}`)); + return { ...page, items: page.items.map((dto) => toPatient(dto)) }; }, - create: async (dto: CreatePatientDto) => { - const env = await clientFetch>(BASE, { - method: 'POST', - body: JSON.stringify(dto), - }); - return unwrap(env); + get: async (id) => toPatient(unwrap(await clientFetch>(`${BASE}/get/${id}`))), + + create: async (input) => + toPatient( + unwrap( + await clientFetch>(`${BASE}/create`, { + method: 'POST', + body: JSON.stringify(toBody(input)), + }), + ), + input, + ), + + update: async (id, input) => + toPatient( + unwrap( + await clientFetch>(`${BASE}/update/${id}`, { + method: 'POST', + body: JSON.stringify(toBody(input)), + }), + ), + input, + ), + + archive: async (id) => { + await clientFetch>(`${BASE}/archive/${id}`, { method: 'POST' }); }, }; diff --git a/client/src/services/patients/apis/mockApi.ts b/client/src/services/patients/apis/mockApi.ts index 20c59a6..3536b1a 100644 --- a/client/src/services/patients/apis/mockApi.ts +++ b/client/src/services/patients/apis/mockApi.ts @@ -1,45 +1,72 @@ import { sleep } from '@/utils'; -import type { Paginated } from '@/lib/api/types'; -import type { CreatePatientDto, Patient, PatientsApi } from '../types'; +import { ApiError } from '@/lib/api/errors'; +import type { PageParams, Paginated } from '@/lib/api/types'; +import type { CreatePatientInput, Patient, PatientsApi } from '../types'; -const MOCK_LATENCY_MS = 400; +const MOCK_LATENCY_MS = 350; -// In-memory store. Seed timestamps are static strings (not Date.now) so repeated -// renders are stable; `create` stamps a real ISO time on the client. -let store: Patient[] = [ - { id: 2, fullName: 'زهرا محمدی', gender: 'female', createdAtUtc: '2026-05-12T08:30:00Z' }, - { id: 1, fullName: 'علی رضایی', gender: 'male', createdAtUtc: '2026-04-03T11:15:00Z' }, -]; -let nextId = 3; +// In-memory store, seeded **empty** so a single session can demo both the onboarding flow +// (A3→A4 creates the first patient) and the E1 empty state. Archive is soft (isActive=false) +// and never removes the row — the list simply hides inactive patients. +let store: Patient[] = []; +let nextId = 1; + +function build(id: number, input: CreatePatientInput, isActive: boolean): Patient { + return { + id, + displayName: input.displayName, + firstName: input.firstName, + lastName: input.lastName, + birthDate: input.birthDate, + gender: input.gender, + bloodType: input.bloodType ?? null, + initialMedicalNotes: input.initialMedicalNotes ?? null, + isActive, + relation: input.relation, + conditions: input.conditions, + }; +} /** - * In-memory mock behind the PatientsApi seam — the template f1+ follow until the real - * `/patients` endpoints are merged. Mirrors the real shapes so swapping is a one-line - * change in constants.ts. + * In-memory mock behind the PatientsApi seam — the b3 endpoints are live but the wire shape + * lacks relation/conditions (REQ-005), so this drives the UI until those land. Mirrors the + * real shapes so swapping is a one-line change in constants.ts. */ export const patientsMockApi: PatientsApi = { - list: async (params): Promise> => { + list: async (params?: PageParams): Promise> => { await sleep(MOCK_LATENCY_MS); + const active = store.filter((patient) => patient.isActive); const page = params?.page ?? 1; - const pageSize = params?.pageSize ?? 20; + const pageSize = params?.pageSize ?? 50; const start = (page - 1) * pageSize; - return { - items: store.slice(start, start + pageSize), - total: store.length, - page, - pageSize, - }; + return { items: active.slice(start, start + pageSize), total: active.length, page, pageSize }; }, - create: async (dto: CreatePatientDto): Promise => { + get: async (id: number): Promise => { await sleep(MOCK_LATENCY_MS); - const patient: Patient = { - id: nextId++, - fullName: dto.fullName, - gender: dto.gender, - createdAtUtc: new Date().toISOString(), - }; + const found = store.find((patient) => patient.id === id && patient.isActive); + if (!found) throw new ApiError(404, 'Patient not found'); + return found; + }, + + create: async (input: CreatePatientInput): Promise => { + await sleep(MOCK_LATENCY_MS); + const patient = build(nextId++, input, true); store = [patient, ...store]; return patient; }, + + update: async (id: number, input: CreatePatientInput): Promise => { + await sleep(MOCK_LATENCY_MS); + const existing = store.find((patient) => patient.id === id); + if (!existing) throw new ApiError(404, 'Patient not found'); + const updated = build(id, input, existing.isActive); + store = store.map((patient) => (patient.id === id ? updated : patient)); + return updated; + }, + + archive: async (id: number): Promise => { + await sleep(MOCK_LATENCY_MS); + store = store.map((patient) => (patient.id === id ? { ...patient, isActive: false } : patient)); + }, }; diff --git a/client/src/services/patients/constants.ts b/client/src/services/patients/constants.ts index a1fa032..8a6d666 100644 --- a/client/src/services/patients/constants.ts +++ b/client/src/services/patients/constants.ts @@ -1,8 +1,26 @@ /** * When true, the domain is served by the in-memory mock (apis/mockApi.ts) behind the - * PatientsApi seam. Flip to false once the real `/patients` endpoints land — no hook or - * component changes are needed (see dev/shared-working-context/reports/mocks-registry.md). + * PatientsApi seam. The b3 `patients/*` endpoints are live, but the wire `PatientDto` + * has no `relation`/`conditions` yet (filed as REQ-005), so this phase demos behind the + * mock. Flip to false once those fields land — no hook/component changes are needed + * (see dev/shared-working-context/reports/mocks-registry.md). */ export const USE_PATIENTS_MOCK = true; export const PATIENTS_STALE_TIME = 60_000; + +/** api-conventions default page size; `pageSize` ≤ 100. */ +export const PATIENTS_PAGE_SIZE = 50; + +/** + * Relation of the care recipient to the signed-in customer (payer ≠ patient). A stable + * enum code, i18n-labelled — never a hardcoded Persian string in logic. Client-augmented: + * not on the wire `PatientDto` yet (REQ-005); carried on create and stored by the mock. + */ +export const RELATION_CODES = ['parent', 'spouse', 'child', 'self'] as const; + +/** + * Common patient conditions surfaced as multi-select chips (A4). Client-augmented (REQ-005); + * carried on create and stored by the mock. Codes are stable; labels are i18n keys. + */ +export const CONDITION_CODES = ['elderly', 'post_surgery', 'diabetes', 'mobility', 'dementia'] as const; diff --git a/client/src/services/patients/hooks/useAddPatient.ts b/client/src/services/patients/hooks/useAddPatient.ts deleted file mode 100644 index 87fc472..0000000 --- a/client/src/services/patients/hooks/useAddPatient.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { patientsApi } from '../apis'; -import { patientKeys } from '../keys'; -import type { CreatePatientDto } from '../types'; - -/** - * Creates a patient and invalidates every patients list so the cache reflects the new - * row without a manual refetch. (setQueryData would also work when the API returns the - * full new list item and pagination is trivial — invalidation is the safe default.) - */ -export function useAddPatient() { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: (dto: CreatePatientDto) => patientsApi.create(dto), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: patientKeys.lists() }); - }, - }); -} diff --git a/client/src/services/patients/hooks/useArchivePatient.ts b/client/src/services/patients/hooks/useArchivePatient.ts new file mode 100644 index 0000000..2170d19 --- /dev/null +++ b/client/src/services/patients/hooks/useArchivePatient.ts @@ -0,0 +1,37 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import type { Paginated } from '@/lib/api/types'; +import { patientsApi } from '../apis'; +import { patientKeys } from '../keys'; +import type { Patient } from '../types'; + +/** + * Soft-archives a patient (`isActive=false`, never a hard delete — historical bookings must + * survive). Optimistically removes the card from every cached list, then reconciles on + * settle; on error the previous cache is restored. + */ +export function useArchivePatient() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (id: number) => patientsApi.archive(id), + onMutate: async (id) => { + await queryClient.cancelQueries({ queryKey: patientKeys.lists() }); + const previous = queryClient.getQueriesData>({ queryKey: patientKeys.lists() }); + previous.forEach(([key, data]) => { + if (!data) return; + queryClient.setQueryData>(key, { + ...data, + items: data.items.filter((patient) => patient.id !== id), + total: Math.max(0, data.total - 1), + }); + }); + return { previous }; + }, + onError: (_error, _id, context) => { + context?.previous?.forEach(([key, data]) => queryClient.setQueryData(key, data)); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: patientKeys.lists() }); + }, + }); +} diff --git a/client/src/services/patients/hooks/useCreatePatient.ts b/client/src/services/patients/hooks/useCreatePatient.ts new file mode 100644 index 0000000..5d163e7 --- /dev/null +++ b/client/src/services/patients/hooks/useCreatePatient.ts @@ -0,0 +1,26 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import type { Paginated } from '@/lib/api/types'; +import { patientsApi } from '../apis'; +import { patientKeys } from '../keys'; +import type { CreatePatientInput, Patient } from '../types'; + +/** + * Creates a patient. Splices the new row into every cached list immediately (so the E1 list + * and the Home onboarding-gate reflect it without waiting for a refetch — no transient + * "0 patients" window that would bounce the user back to onboarding), then invalidates to + * reconcile. Domain 400s (missing/invalid gender, future birth date) surface via + * `mutation.error`; the fetch layer owns 401/403/5xx toasts. + */ +export function useCreatePatient() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (input: CreatePatientInput) => patientsApi.create(input), + onSuccess: (patient) => { + queryClient.setQueriesData>({ queryKey: patientKeys.lists() }, (old) => + old ? { ...old, items: [patient, ...old.items], total: old.total + 1 } : old, + ); + queryClient.invalidateQueries({ queryKey: patientKeys.lists() }); + }, + }); +} diff --git a/client/src/services/patients/hooks/useUpdatePatient.ts b/client/src/services/patients/hooks/useUpdatePatient.ts new file mode 100644 index 0000000..50c1772 --- /dev/null +++ b/client/src/services/patients/hooks/useUpdatePatient.ts @@ -0,0 +1,19 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { patientsApi } from '../apis'; +import { patientKeys } from '../keys'; +import type { UpdatePatientInput } from '../types'; + +/** + * Updates a patient (the A4 form reused for edit) and invalidates the lists so the card + * reflects the change. A cross-tenant id returns 404 server-side (tenancy is enforced there). + */ +export function useUpdatePatient() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, input }: { id: number; input: UpdatePatientInput }) => patientsApi.update(id, input), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: patientKeys.lists() }); + }, + }); +} diff --git a/client/src/services/patients/index.ts b/client/src/services/patients/index.ts index 0552372..46862b0 100644 --- a/client/src/services/patients/index.ts +++ b/client/src/services/patients/index.ts @@ -1,2 +1,4 @@ export { usePatients } from './hooks/usePatients'; -export { useAddPatient } from './hooks/useAddPatient'; +export { useCreatePatient } from './hooks/useCreatePatient'; +export { useUpdatePatient } from './hooks/useUpdatePatient'; +export { useArchivePatient } from './hooks/useArchivePatient'; diff --git a/client/src/services/patients/types.ts b/client/src/services/patients/types.ts index 878d986..68a7a6a 100644 --- a/client/src/services/patients/types.ts +++ b/client/src/services/patients/types.ts @@ -1,33 +1,67 @@ import type { PageParams, Paginated } from '@/lib/api/types'; +import { CONDITION_CODES, RELATION_CODES } from './constants'; /** - * Patients domain — the reference `services/{domain}` implementation every later - * frontend phase copies. Enums cross the wire as stable string codes (money-and-types.md); - * mirror them as string-literal unions and never hardcode a display label off the code. + * Patients domain — the care-recipient (patient) sub-domain, customer-scoped and + * tenancy-enforced server-side. Shapes mirror the b3 contract + * (`dev/contracts/domains/identity-profiles.md` → `PatientDto`) exactly; enums cross the + * wire as stable string codes (money-and-types.md) mirrored here as unions. * - * Deriving these types from the contract: read the domain's shapes from the published - * `dev/contracts/domains/.md` + `dev/contracts/openapi/swagger.v1.json`, mirror - * the wire exactly (field names + casing), and map enums to unions here. Until the real - * `/patients` endpoints exist, the shapes below are the agreed target the mock honours. + * `relation` and `conditions` are **client-augmented**: they are not on the wire + * `PatientDto` yet (filed as REQ-005 in requests/for-backend.md). The mock persists them; + * the real client carries them through create/update so the just-edited card reflects the + * choice, but they are not round-tripped by the server until the backend adds the columns. */ export type Gender = 'male' | 'female'; +export type Relation = (typeof RELATION_CODES)[number]; +export type ConditionCode = (typeof CONDITION_CODES)[number]; -export interface Patient { +/** The b3 wire shape returned by every `patients/*` endpoint. */ +export interface PatientDto { id: number; - fullName: string; + displayName: string; + firstName: string; + lastName: string; + /** ISO date `YYYY-MM-DD`. */ + birthDate: string; gender: Gender; - /** UTC ISO-8601; display via formatShamsiDate. */ - createdAtUtc: string; + bloodType: string | null; + /** Decrypted, owner-only free-text notes (E2 record viewer, deferred). */ + initialMedicalNotes: string | null; + isActive: boolean; } -export interface CreatePatientDto { - fullName: string; - gender: Gender; +/** App-level patient = wire shape + the client-augmented relation/conditions. */ +export interface Patient extends PatientDto { + relation: Relation | null; + conditions: ConditionCode[]; } -/** The domain's API seam. A mock and the real client both implement this interface. */ +/** + * Create/update input. `firstName`/`lastName`/`displayName` derive from the A4 single + * full-name field (split on the first space); `birthDate` derives from the age field. + * `bloodType`/`initialMedicalNotes` are deferred to the E2 record viewer. + */ +export interface CreatePatientInput { + displayName: string; + firstName: string; + lastName: string; + birthDate: string; + gender: Gender; + bloodType?: string | null; + initialMedicalNotes?: string | null; + relation: Relation | null; + conditions: ConditionCode[]; +} + +export type UpdatePatientInput = CreatePatientInput; + +/** The domain's API seam — a mock and the real client both implement this interface. */ export interface PatientsApi { list(params?: PageParams): Promise>; - create(dto: CreatePatientDto): Promise; + get(id: number): Promise; + create(input: CreatePatientInput): Promise; + update(id: number, input: UpdatePatientInput): Promise; + archive(id: number): Promise; } diff --git a/client/src/services/profiles/apis/clientApi.ts b/client/src/services/profiles/apis/clientApi.ts new file mode 100644 index 0000000..c200a80 --- /dev/null +++ b/client/src/services/profiles/apis/clientApi.ts @@ -0,0 +1,86 @@ +import { clientFetch } from '@/lib/api/client'; +import { ApiError } from '@/lib/api/errors'; +import { unwrap, type ApiEnvelope } from '@/lib/api/types'; +import type { + AvatarUploadResult, + CustomerProfile, + CustomerProfileDto, + NurseProfile, + NurseProfileDto, + ProfilesApi, + UpsertCustomerProfileInput, + UpsertNurseProfileInput, +} from '../types'; + +const BASE = '/api/v1'; + +// A caller with no profile yet gets a 404 from the GET — map that to `null` (an empty form), +// not an error. Any other status propagates. +async function orNull(promise: Promise): Promise { + try { + return await promise; + } catch (error) { + if (error instanceof ApiError && error.status === 404) return null; + throw error; + } +} + +// The wire DTOs carry no avatar/name yet (REQ-006/007); reads default the augmented fields. +function toNurseProfile(dto: NurseProfileDto): NurseProfile { + return { ...dto, avatarUrl: null }; +} +function toCustomerProfile(dto: CustomerProfileDto): CustomerProfile { + return { ...dto, firstName: null, lastName: null, preferredLanguage: null }; +} + +/** + * Real HTTP implementation of the ProfilesApi seam (b3 action-style routes). Selected once + * USE_PROFILES_MOCK is false and the avatar/name gaps land. `uploadAvatar` has no route yet + * (REQ-006) and the JSON-only fetch layer can't send multipart — it stays mock-only. + */ +export const profilesClientApi: ProfilesApi = { + getCustomerProfile: async () => + orNull( + clientFetch>(`${BASE}/customer_profiles/me`).then((env) => + toCustomerProfile(unwrap(env)), + ), + ), + + upsertCustomerProfile: async (input: UpsertCustomerProfileInput) => + toCustomerProfile( + unwrap( + await clientFetch>(`${BASE}/customer_profiles/upsert`, { + method: 'POST', + body: JSON.stringify({ + defaultEmergencyContactName: input.defaultEmergencyContactName, + defaultEmergencyContactPhone: input.defaultEmergencyContactPhone, + }), + }), + ), + ), + + getNurseProfile: async () => + orNull( + clientFetch>(`${BASE}/nurse_profiles/me`).then((env) => toNurseProfile(unwrap(env))), + ), + + upsertNurseProfile: async (input: UpsertNurseProfileInput) => + toNurseProfile( + unwrap( + await clientFetch>(`${BASE}/nurse_profiles/upsert`, { + method: 'POST', + body: JSON.stringify({ + bio: input.bio, + yearsOfExperience: input.yearsOfExperience, + educationLevel: input.educationLevel, + educationField: input.educationField, + specializationsJson: input.specializationsJson, + }), + }), + ), + ), + + uploadAvatar: async (): Promise => { + throw new ApiError(501, 'Avatar upload has no backend route yet (REQ-006); served by the mock.'); + }, +}; diff --git a/client/src/services/profiles/apis/index.ts b/client/src/services/profiles/apis/index.ts new file mode 100644 index 0000000..9381fab --- /dev/null +++ b/client/src/services/profiles/apis/index.ts @@ -0,0 +1,10 @@ +import { USE_PROFILES_MOCK } from '../constants'; +import type { ProfilesApi } from '../types'; +import { profilesClientApi } from './clientApi'; +import { profilesMockApi } from './mockApi'; + +/** + * The selected ProfilesApi implementation — the single seam hooks import. Selection is by + * config (USE_PROFILES_MOCK), never by scattered `if (mock)` checks. + */ +export const profilesApi: ProfilesApi = USE_PROFILES_MOCK ? profilesMockApi : profilesClientApi; diff --git a/client/src/services/profiles/apis/mockApi.ts b/client/src/services/profiles/apis/mockApi.ts new file mode 100644 index 0000000..d609827 --- /dev/null +++ b/client/src/services/profiles/apis/mockApi.ts @@ -0,0 +1,73 @@ +import { sleep } from '@/utils'; +import type { + AvatarUploadResult, + CustomerProfile, + NurseProfile, + ProfilesApi, + UpsertCustomerProfileInput, + UpsertNurseProfileInput, +} from '../types'; + +const MOCK_LATENCY_MS = 350; + +// Both profiles start absent (a fresh user has none — the real GET would 404). Bootstrapping +// via upsert creates them; `isVerified` and the aggregates stay server-owned defaults. +let customerProfile: CustomerProfile | null = null; +let nurseProfile: NurseProfile | null = null; + +/** + * In-memory mock behind the ProfilesApi seam. Mirrors the b3 shapes and keeps the guarded + * read-only fields (`isVerified=false`, zero aggregates) exactly as the server would, so a + * bootstrapped nurse is never presented as verified/bookable. + */ +export const profilesMockApi: ProfilesApi = { + getCustomerProfile: async () => { + await sleep(MOCK_LATENCY_MS); + return customerProfile; + }, + + upsertCustomerProfile: async (input: UpsertCustomerProfileInput) => { + await sleep(MOCK_LATENCY_MS); + customerProfile = { + id: customerProfile?.id ?? 1, + defaultEmergencyContactName: input.defaultEmergencyContactName, + defaultEmergencyContactPhone: input.defaultEmergencyContactPhone, + firstName: input.firstName ?? customerProfile?.firstName ?? null, + lastName: input.lastName ?? customerProfile?.lastName ?? null, + preferredLanguage: input.preferredLanguage ?? customerProfile?.preferredLanguage ?? null, + }; + return customerProfile; + }, + + getNurseProfile: async () => { + await sleep(MOCK_LATENCY_MS); + return nurseProfile; + }, + + upsertNurseProfile: async (input: UpsertNurseProfileInput) => { + await sleep(MOCK_LATENCY_MS); + nurseProfile = { + id: nurseProfile?.id ?? 1, + bio: input.bio, + yearsOfExperience: input.yearsOfExperience, + educationLevel: input.educationLevel, + educationField: input.educationField, + specializationsJson: input.specializationsJson, + // Server-owned, guarded — a bootstrapped profile is never verified or bookable. + isVerified: false, + isAcceptingBookings: nurseProfile?.isAcceptingBookings ?? false, + averageRating: 0, + totalReviews: 0, + totalCompletedBookings: 0, + avatarUrl: input.avatarUrl ?? nurseProfile?.avatarUrl ?? null, + }; + return nurseProfile; + }, + + uploadAvatar: async (file: File): Promise => { + await sleep(MOCK_LATENCY_MS); + // Object URL reflects the actual picked image for the demo; the real impl returns an + // object-storage URL (REQ-006). + return { url: URL.createObjectURL(file) }; + }, +}; diff --git a/client/src/services/profiles/constants.ts b/client/src/services/profiles/constants.ts new file mode 100644 index 0000000..93bec3a --- /dev/null +++ b/client/src/services/profiles/constants.ts @@ -0,0 +1,11 @@ +/** + * When true, the profiles domain is served by the in-memory mock behind the ProfilesApi + * seam. The b3 `customer_profiles/*` and `nurse_profiles/*` endpoints are live, but the + * avatar/object-storage route and the customer name/preferred-language fields are gaps + * (REQ-006 / REQ-007), so this phase demos behind the mock. Flip to false once those land — + * no hook/component changes (see dev/shared-working-context/reports/mocks-registry.md). + */ +export const USE_PROFILES_MOCK = true; + +/** Profiles are stable within a session; revisiting a screen shouldn't refetch. */ +export const PROFILE_STALE_TIME = 60_000; diff --git a/client/src/services/profiles/hooks/useCustomerProfile.ts b/client/src/services/profiles/hooks/useCustomerProfile.ts new file mode 100644 index 0000000..bf46b8a --- /dev/null +++ b/client/src/services/profiles/hooks/useCustomerProfile.ts @@ -0,0 +1,16 @@ +import { useQuery } from '@tanstack/react-query'; +import { useIsAuthenticated } from '@/hooks'; +import { profilesApi } from '../apis'; +import { profileKeys } from '../keys'; +import { PROFILE_STALE_TIME } from '../constants'; + +/** The signed-in customer's payer profile (emergency contact + name). `null` until created. */ +export function useCustomerProfile() { + const isAuthenticated = useIsAuthenticated(); + return useQuery({ + queryKey: profileKeys.customer(), + queryFn: () => profilesApi.getCustomerProfile(), + enabled: isAuthenticated, + staleTime: PROFILE_STALE_TIME, + }); +} diff --git a/client/src/services/profiles/hooks/useNurseProfile.ts b/client/src/services/profiles/hooks/useNurseProfile.ts new file mode 100644 index 0000000..7794e00 --- /dev/null +++ b/client/src/services/profiles/hooks/useNurseProfile.ts @@ -0,0 +1,16 @@ +import { useQuery } from '@tanstack/react-query'; +import { useIsAuthenticated } from '@/hooks'; +import { profilesApi } from '../apis'; +import { profileKeys } from '../keys'; +import { PROFILE_STALE_TIME } from '../constants'; + +/** The signed-in nurse's own seller profile. `null` until bootstrapped (B7). */ +export function useNurseProfile() { + const isAuthenticated = useIsAuthenticated(); + return useQuery({ + queryKey: profileKeys.nurse(), + queryFn: () => profilesApi.getNurseProfile(), + enabled: isAuthenticated, + staleTime: PROFILE_STALE_TIME, + }); +} diff --git a/client/src/services/profiles/hooks/useUploadAvatar.ts b/client/src/services/profiles/hooks/useUploadAvatar.ts new file mode 100644 index 0000000..533d6c9 --- /dev/null +++ b/client/src/services/profiles/hooks/useUploadAvatar.ts @@ -0,0 +1,12 @@ +import { useMutation } from '@tanstack/react-query'; +import { profilesApi } from '../apis'; + +/** + * Uploads an avatar image and returns its URL. The caller folds the returned URL into the + * next profile upsert. Backed by the mock until the object-storage route lands (REQ-006). + */ +export function useUploadAvatar() { + return useMutation({ + mutationFn: (file: File) => profilesApi.uploadAvatar(file), + }); +} diff --git a/client/src/services/profiles/hooks/useUpsertCustomerProfile.ts b/client/src/services/profiles/hooks/useUpsertCustomerProfile.ts new file mode 100644 index 0000000..e815aa0 --- /dev/null +++ b/client/src/services/profiles/hooks/useUpsertCustomerProfile.ts @@ -0,0 +1,22 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { authKeys } from '@/services/auth/keys'; +import { profilesApi } from '../apis'; +import { profileKeys } from '../keys'; +import type { UpsertCustomerProfileInput } from '../types'; + +/** + * Creates/updates the customer profile. Writes the fresh profile straight into cache and + * invalidates `/me` so a profile-completion change reflects in the Home nudge (b3 also + * auto-provisions a thin customer profile, flipping `hasCustomerProfile`). + */ +export function useUpsertCustomerProfile() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (input: UpsertCustomerProfileInput) => profilesApi.upsertCustomerProfile(input), + onSuccess: (profile) => { + queryClient.setQueryData(profileKeys.customer(), profile); + queryClient.invalidateQueries({ queryKey: authKeys.me() }); + }, + }); +} diff --git a/client/src/services/profiles/hooks/useUpsertNurseProfile.ts b/client/src/services/profiles/hooks/useUpsertNurseProfile.ts new file mode 100644 index 0000000..195c153 --- /dev/null +++ b/client/src/services/profiles/hooks/useUpsertNurseProfile.ts @@ -0,0 +1,22 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { authKeys } from '@/services/auth/keys'; +import { profilesApi } from '../apis'; +import { profileKeys } from '../keys'; +import type { UpsertNurseProfileInput } from '../types'; + +/** + * Bootstraps (first entry) or edits the nurse profile via the single b3 upsert. `isVerified` + * is never sent — it stays server-owned/false. Writes the fresh profile to cache and + * invalidates `/me` so `hasNurseProfile` reflects the bootstrap. + */ +export function useUpsertNurseProfile() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (input: UpsertNurseProfileInput) => profilesApi.upsertNurseProfile(input), + onSuccess: (profile) => { + queryClient.setQueryData(profileKeys.nurse(), profile); + queryClient.invalidateQueries({ queryKey: authKeys.me() }); + }, + }); +} diff --git a/client/src/services/profiles/index.ts b/client/src/services/profiles/index.ts new file mode 100644 index 0000000..e791eaa --- /dev/null +++ b/client/src/services/profiles/index.ts @@ -0,0 +1,5 @@ +export { useCustomerProfile } from './hooks/useCustomerProfile'; +export { useUpsertCustomerProfile } from './hooks/useUpsertCustomerProfile'; +export { useNurseProfile } from './hooks/useNurseProfile'; +export { useUpsertNurseProfile } from './hooks/useUpsertNurseProfile'; +export { useUploadAvatar } from './hooks/useUploadAvatar'; diff --git a/client/src/services/profiles/keys.ts b/client/src/services/profiles/keys.ts new file mode 100644 index 0000000..a6b5e87 --- /dev/null +++ b/client/src/services/profiles/keys.ts @@ -0,0 +1,9 @@ +/** + * React Query key factory for the profiles domain. The customer and nurse profiles are + * distinct owner-scoped resources, each its own key. + */ +export const profileKeys = { + all: ['profiles'] as const, + customer: () => [...profileKeys.all, 'customer'] as const, + nurse: () => [...profileKeys.all, 'nurse'] as const, +}; diff --git a/client/src/services/profiles/types.ts b/client/src/services/profiles/types.ts new file mode 100644 index 0000000..85d5177 --- /dev/null +++ b/client/src/services/profiles/types.ts @@ -0,0 +1,76 @@ +/** + * Profiles domain — the nurse *seller* profile and the customer *payer* profile. Shapes + * mirror the b3 contract (`dev/contracts/domains/identity-profiles.md` → `NurseProfileDto`, + * `CustomerProfileDto`) exactly. The bank account is a separate domain (`services/nurse`). + * + * Client-augmented fields (not on the wire yet — filed in requests/for-backend.md): + * - nurse `avatarUrl` — no avatar/object-storage route in b3 (REQ-006). + * - customer `firstName`/`lastName`/`preferredLanguage` — the wire `CustomerProfileDto` + * carries only the emergency contact; name lives on `/me` with no update endpoint (REQ-007). + * The mock persists these; the real client sends only the wire fields. + */ + +/** `NurseProfileDto` — `isVerified` + the three aggregates are server-owned and read-only. */ +export interface NurseProfileDto { + id: number; + bio: string; + yearsOfExperience: number; + educationLevel: string; + educationField: string; + /** Raw JSON array string of specialization codes (the builder is deferred to f4). */ + specializationsJson: string; + isVerified: boolean; + isAcceptingBookings: boolean; + averageRating: number; + totalReviews: number; + totalCompletedBookings: number; +} + +export interface NurseProfile extends NurseProfileDto { + avatarUrl: string | null; +} + +/** `nurse_profiles/upsert` body — never carries `isVerified` or the aggregates. */ +export interface UpsertNurseProfileInput { + bio: string; + yearsOfExperience: number; + educationLevel: string; + educationField: string; + specializationsJson: string; + avatarUrl?: string | null; +} + +/** `CustomerProfileDto` — emergency contact returned in full to the owning customer. */ +export interface CustomerProfileDto { + id: number; + defaultEmergencyContactName: string; + defaultEmergencyContactPhone: string; +} + +export interface CustomerProfile extends CustomerProfileDto { + firstName: string | null; + lastName: string | null; + preferredLanguage: string | null; +} + +/** `customer_profiles/upsert` body (emergency contact) + client-augmented name/language. */ +export interface UpsertCustomerProfileInput { + defaultEmergencyContactName: string; + defaultEmergencyContactPhone: string; + firstName?: string | null; + lastName?: string | null; + preferredLanguage?: string | null; +} + +export interface AvatarUploadResult { + url: string; +} + +/** The domain's API seam. `get*` resolve to `null` when the caller has no profile yet (404). */ +export interface ProfilesApi { + getCustomerProfile(): Promise; + upsertCustomerProfile(input: UpsertCustomerProfileInput): Promise; + getNurseProfile(): Promise; + upsertNurseProfile(input: UpsertNurseProfileInput): Promise; + uploadAvatar(file: File): Promise; +} diff --git a/client/src/theme/tokens.css b/client/src/theme/tokens.css index 4df4f79..d64706c 100644 --- a/client/src/theme/tokens.css +++ b/client/src/theme/tokens.css @@ -25,6 +25,8 @@ --bal-primary-light: #2f6b5e; --bal-primary-dark: #123029; --bal-primary-contrast: #f3efe9; + /* Soft primary tint — selected chips, subtle info panels */ + --bal-primary-soft: rgba(29, 74, 64, 0.10); /* Secondary — terracotta */ --bal-secondary: #d98c6a; @@ -61,6 +63,8 @@ --bal-primary-light: #8fd2c1; --bal-primary-dark: #3f8a78; --bal-primary-contrast: #06120f; + /* Soft primary tint — selected chips, subtle info panels */ + --bal-primary-soft: rgba(111, 192, 172, 0.16); /* Secondary — warm terracotta-light */ --bal-secondary: #e6a98a; diff --git a/dev/shared-working-context/frontend/STATUS.md b/dev/shared-working-context/frontend/STATUS.md index 9692f8f..9aeb9b8 100644 --- a/dev/shared-working-context/frontend/STATUS.md +++ b/dev/shared-working-context/frontend/STATUS.md @@ -12,6 +12,33 @@ for awareness. - **Requests filed:** frontend/requests/for-backend.md (yes/no) --> +## frontend-phase-2-b3 — Onboarding & profiles (customer, patient, nurse, bank) — 2026-07-02 +- **Shipped:** three domain services — `services/patients` (rewritten to the b3 `PatientDto` + client-augmented + `relation`/`conditions`; full CRUD seam + mock + real client; `usePatients`/`useCreatePatient`/ + `useUpdatePatient`/`useArchivePatient` with optimistic soft-archive), `services/profiles` (customer + nurse + profile get/upsert + avatar; `useCustomerProfile`/`useUpsertCustomerProfile`/`useNurseProfile`/ + `useUpsertNurseProfile`/`useUploadAvatar`), `services/nurse` (bank accounts; `useNurseBankAccounts` with + pending-only `refetchInterval`, `useAddNurseBankAccount`, `useSetPrimaryBankAccount`; `iban.ts` Sheba + validate/normalize/bank-name). Screens: **A3→A4 onboarding wizard** (`(customer)/onboarding`), **E1 patients + list/CRUD** (add/edit dialog reusing the A4 form, soft-archive confirm, empty + skeleton states), **A5 Home** + (first-login redirect into onboarding when 0 patients + "complete patient record" nudge), **customer profile** + (name + preferred language + emergency contact, no national-ID), **nurse profile bootstrap** (avatar + bio + + years, unverified "not bookable" placeholder → verification), **nurse bank settings** (IBAN form + the three + ownership states pending/verified/mismatch). Shared composites `GenderToggle`/`ConditionChips`/`RelationSelect`/ + `PatientForm`/`PatientCard`/`BankStatusPanel` (each tested); reused the f0 `StepperHeader`/`StatusChip`/ + `PhoneNumberField`. Added `onboarding`/`home`/`profile`/`nurseProfile`/`bank` i18n namespaces + `patients` + extensions (both locales); `--bal-primary-soft` token (both schemes); nurse sidebar gains Profile + Bank. +- **Consumes:** dev/contracts/domains/identity-profiles.md (backend-phase-3). Routes `api/v1/{customer_profiles, + nurse_profiles}/{me,upsert}`, `api/v1/patients/{list,get,create,update,archive}`, `api/v1/nurse_bank_accounts/ + {list,add,set_primary,verify_ownership}`. +- **Mocked client-side:** `services/patients` (`USE_PATIENTS_MOCK`), `services/profiles` (`USE_PROFILES_MOCK`), + `services/nurse` (`USE_NURSE_BANK_MOCK`) — all default `true`; real clients wired for a one-line flip. See + mocks-registry + the report for exactly what/why (relation/conditions, avatar, customer name/language gaps). +- **Gate:** npm run check green · npm run test:ci green (112 tests, +17) · npm run build green with + NEXT_PUBLIC_API_URL set (routes /onboarding, /nurse/profile, /nurse/bank generated). +- **Requests filed:** frontend/requests/for-backend.md — yes (REQ-005 patient relation/conditions, REQ-006 avatar + upload route, REQ-007 customer name/preferred-language). + ## frontend-phase-1-b2 — Auth: phone-OTP login & role routing — 2026-07-02 - **Shipped:** `services/auth` rewritten for phone-OTP (types/keys/apis[client+mock+seam]/hooks: `useRequestOtp`/`useVerifyOtp`/`useMe`/`useRefresh`/`useLogout`/`useSelectRole`/`useSessionRoleSync`) — diff --git a/dev/shared-working-context/frontend/requests/for-backend.md b/dev/shared-working-context/frontend/requests/for-backend.md index 79a13c3..80073af 100644 --- a/dev/shared-working-context/frontend/requests/for-backend.md +++ b/dev/shared-working-context/frontend/requests/for-backend.md @@ -52,6 +52,39 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a - **Proposed shape:** `{ isSuccess: false, statusCode: 400, message: "…", code: "otp_locked", data: { retryAfterSeconds: 60 } }` - **Status:** open +## REQ-005 — Patient `relation` + `conditions` fields — filed by frontend-phase-2-b3 — 2026-07-02 +- **Need:** Add two fields to `PatientDto` and the `patients/create` + `patients/update` bodies: + 1. `relation` (`parent`|`spouse`|`child`|`self`, nullable) — the care-recipient's relation to the payer. + 2. `conditions` (string[] of stable codes, e.g. `elderly`/`post_surgery`/`diabetes`/`mobility`/`dementia`). +- **Why:** The A3 onboarding step captures the relation, and the A4 form + E1 patient cards show condition + chips. Neither field exists on the wire `PatientDto` (only `initialMedicalNotes` free-text). The client + currently augments them behind the `services/patients` seam (the mock persists them; `USE_PATIENTS_MOCK=true`) + and drops them on the real path. Adding the columns lets the client flip the flag to the live endpoints. +- **Proposed shape:** `PatientDto { …, relation: string|null, conditions: string[] }`; same fields accepted on + create/update. Enum for `relation`; `conditions` a stable code list (could also be a normalized child table). +- **Status:** open + +## REQ-006 — Avatar / object-storage upload route (nurse & customer) — filed by frontend-phase-2-b3 — 2026-07-02 +- **Need:** A multipart image-upload endpoint backed by `IObjectStorage` that returns a stored URL, plus an + `avatarUrl` field on `NurseProfileDto` (and later `CustomerProfileDto`). e.g. `POST api/v1/nurse_profiles/avatar` + (multipart/form-data) → `{ url }`, and persist `avatar_url` on the profile. +- **Why:** The B7 nurse profile bootstrap and the customer profile both take a profile photo. The b3 contract + has no avatar field or upload route, and the client fetch layer is JSON-only (can't send multipart). The + client mocks this behind the `services/profiles` seam (`uploadAvatar` returns an object URL). The real + `profilesClientApi.uploadAvatar` throws `501` until this lands. +- **Status:** open + +## REQ-007 — Customer name + preferred-language update — filed by frontend-phase-2-b3 — 2026-07-02 +- **Need:** Either add `firstName`/`lastName`/`preferredLanguage` to the `customer_profiles/upsert` body + + `CustomerProfileDto`, or confirm the customer name is only ever set elsewhere (and how). `MeResult` exposes + `firstName`/`lastName` read-only with no update endpoint; `CustomerProfileDto` carries only the emergency + contact. +- **Why:** The customer profile screen edits first/last name + preferred language alongside the emergency + contact. Absent a wire field/endpoint, the client augments name/language behind the `services/profiles` seam + (mock-persisted; the real upsert sends only the emergency contact). Confirm the intended home for these so the + client stops augmenting. +- **Status:** open + ## REQ-004 — Confirm multi-role disambiguation (activeRole?) — filed by frontend-phase-1-b2 — 2026-07-02 - **Need:** Confirm whether `MeResult` will gain an `activeRole` (the user's currently-selected actor) for a user who holds **both** `customer` and `nurse`, or whether the client should keep owning that choice. diff --git a/dev/shared-working-context/reports/frontend-phase-2-report.md b/dev/shared-working-context/reports/frontend-phase-2-report.md new file mode 100644 index 0000000..be4c03b --- /dev/null +++ b/dev/shared-working-context/reports/frontend-phase-2-report.md @@ -0,0 +1,82 @@ +# Frontend phase 2 (f2-b3) — Onboarding & profiles — report + +**Track:** frontend · **Consumes:** [`dev/contracts/domains/identity-profiles.md`](../../contracts/domains/identity-profiles.md) (backend-phase-3) · **Date:** 2026-07-02 + +## What was built + +### Domain services (f0 `services/{domain}` pattern: types ← contract, keys factory, apis[client+mock+seam], one-hook-per-file, hooks-only barrel) +- **`services/patients`** — rewritten from the f0 reference stub to the b3 `PatientDto`. Full CRUD seam + (`list`/`get`/`create`/`update`/`archive`) + real `patientsClientApi` (action routes `patients/{list,get,create, + update,archive}`) + `patientsMockApi`. Hooks: `usePatients` (staleTime), `useCreatePatient`, `useUpdatePatient` + (both invalidate lists), `useArchivePatient` (**optimistic** remove + rollback + settle-invalidate). Helpers + `age.ts` (age↔birthDate) and the client-augmented `relation`/`conditions` (REQ-005, mock-persisted). +- **`services/profiles`** — customer + nurse profile. Seam `getCustomerProfile`/`upsertCustomerProfile`/ + `getNurseProfile`/`upsertNurseProfile`/`uploadAvatar`; real client maps 404→`null` (no profile yet). Hooks + `useCustomerProfile`/`useNurseProfile` (queries) and `useUpsertCustomerProfile`/`useUpsertNurseProfile` + (setQueryData + invalidate `/me` for profile-completion) and `useUploadAvatar`. +- **`services/nurse`** — payout bank accounts (kept separate). Seam `list`/`add`/`setPrimary`/`verifyOwnership`; + `iban.ts` (Sheba `IR`+24 validate/normalize + bank-name from the 3-digit code). Hooks `useNurseBankAccounts` + (poll via `refetchInterval` **only while any account is pending**), `useAddNurseBankAccount`, + `useSetPrimaryBankAccount`. `deriveBankStatus` maps `matchedNationalId` (null→pending, false→mismatch, + true→verified). + +### Shared composites (`src/components/…`, each with a co-located `*.test.tsx`) +`GenderToggle` (required male/female, never defaulted, can't deselect), `ConditionChips` (multi-select codes), +`RelationSelect` (radio cards), `PatientForm` (the A4 form — name/age/gender/conditions/relation, reused +create+edit), `PatientCard` (E1 card), `BankStatusPanel` (the three ownership states, masked IBAN, non-accusatory +mismatch). Reused the f0 `StepperHeader`/`StatusChip`/`PhoneNumberField` (not re-implemented). Added `--bal-primary-soft` +token (both schemes) and 5 AppIcon registry names (edit/archive/bank/camera/warning). + +### Screens +- **A3→A4 onboarding** (`(customer)/onboarding/page.tsx`) — 2-step stepper: relation → patient form; creates the + first patient and lands on Home. +- **A5 Home** (`(customer)/page.tsx`, now a client component) — first-login gate: a customer with 0 patients is + redirected into onboarding (waits for a settled list so a post-create refetch never bounces back); otherwise the + "complete patient record" nudge (+ a profile nudge until `hasCustomerProfile`). +- **E1 patients** (`(customer)/patients/page.tsx`) — cached list, skeleton + empty states, add/edit dialog (A4 form), + soft-archive with confirm. +- **Customer profile** (`(customer)/profile/page.tsx`) — name + preferred language + emergency contact (reused + phone field). **No national-ID field.** +- **Nurse profile** (`nurse/profile/page.tsx`) — avatar upload + bio + years; unverified/not-bookable placeholder + → verification; services/availability correctly deferred (a caption, not a stub). +- **Nurse bank** (`nurse/bank/page.tsx`) — IBAN + holder form; renders each account via `BankStatusPanel` in its + pending/verified/mismatch state; mismatch offers re-enter. +- **NurseLayout** sidebar gains Profile + Bank. + +## What is now testable and exactly how (`npm run dev`, mocks default on — no backend needed) +- **Onboarding:** log in as customer → land on **A3** (step 1) → pick a relation → **A4**; submit **without gender** + → blocked + "gender required"; fill it + submit → lands on **Home (A5)** with the nudge; the flow doesn't + re-trigger (a patient now exists). +- **Patients (E1):** Patients tab shows the new patient card (relation/name/age·gender/conditions). "+ Add patient" + → dialog (A4 form) → appears without a full reload (invalidate). Edit → persists. Archive (confirm) → card + disappears (soft, `isActive=false`), not hard-deleted. Fresh session → empty state with add CTA. React Query + Devtools shows the list cached + invalidated on mutation. +- **Customer profile:** edit name + emergency contact → save → Home profile nudge clears. No national-ID field. +- **Nurse profile + bank:** log in as nurse → bootstrap profile (avatar + bio) → saves, shows **unverified / + not-bookable**. Bank settings → enter an IBAN → **pending** "در حال استعلام" panel → (after ~1 poll) **verified** + green with **masked** IBAN (last-4). Enter `IR000000000000000000000000` → **mismatch** with re-enter CTA. +- **i18n / RTL:** toggle locale → strings flip fa↔en, `dir` flips, gender toggle / chips / stepper mirror. + +## What is mocked client-side + how to make it real +All three services default to `USE_*_MOCK = true` (real HTTP clients fully wired for a one-line flip). See the +[mock registry](./mocks-registry.md) rows for `PatientsApi`, `ProfilesApi`, `NurseBankAccountsApi`. The b3 +endpoints are live; the mocks stay on because of the three filed gaps: +- **REQ-005** — `PatientDto.relation` + `conditions` (client-augmented meanwhile). +- **REQ-006** — avatar/object-storage upload route + `avatarUrl` (real `uploadAvatar` throws `501`). +- **REQ-007** — customer `firstName`/`lastName`/`preferredLanguage` home (name is `/me`-only, read-only today). +Once each lands, flip the corresponding flag — no hook/component/call-site change. + +## Contracts consumed +`identity-profiles.md` (b3) as the type source: `NurseProfileDto`, `CustomerProfileDto`, `PatientDto`, +`NurseBankAccountDto`, the enums (`gender` load-bearing), IBAN masking (last-4), guarded `isVerified`, tenancy-404. +Gaps filed in `requests/for-backend.md` (REQ-005/006/007) — no shapes guessed; augmented fields are clearly marked. + +## Follow-ups for later phases +- **f3 (addresses & geo):** reuse this profile shell + the `services/{domain}` pattern; the A4/E1 sibling address + book slots in. +- **f4 (catalog & service builder):** the nurse **services-and-prices** builder and **available-days** picker slot + onto the B7 profile (both deferred here; a caption marks them). +- **f5 (verification):** replaces the neutral unverified placeholder on the nurse profile with the real + "not bookable until verified" banner; flips `isVerified` inside the backend transaction. +- **f7 (booking):** consumes the patient (needs a known `gender`) created here. +- When REQ-005/006/007 land, flip the three mock flags. diff --git a/dev/shared-working-context/reports/mocks-registry.md b/dev/shared-working-context/reports/mocks-registry.md index eafb35d..8381fce 100644 --- a/dev/shared-working-context/reports/mocks-registry.md +++ b/dev/shared-working-context/reports/mocks-registry.md @@ -44,5 +44,7 @@ the frontend can build before the backend phase merges, and swap to the real HTT | Seam (interface) | File | What it fakes | Config flag | Make it real → | Status | | --- | --- | --- | --- | --- | --- | -| `PatientsApi` | `client/src/services/patients/apis/mockApi.ts` | In-memory patient list/create | `USE_PATIENTS_MOCK` (`services/patients/constants.ts`) | Publish `/patients` endpoints, set flag `false` | 🟡 | +| `PatientsApi` | `client/src/services/patients/apis/mockApi.ts` | In-memory patient CRUD (list/get/create/update/soft-archive), **seeded empty** so onboarding + the empty state both demo; persists the client-augmented `relation`/`conditions` the wire `PatientDto` lacks (REQ-005) | `USE_PATIENTS_MOCK` (`services/patients/constants.ts`, default `true`) | Deliver REQ-005 (relation/conditions on `PatientDto` + create/update), then set flag `false` — `patientsClientApi` is already wired to the b3 `patients/*` routes | 🟡 | +| `ProfilesApi` | `client/src/services/profiles/apis/mockApi.ts` | Customer + nurse profile get/upsert and **avatar upload** (echoes an object-URL). Keeps guarded read-only fields (`isVerified=false`, zero aggregates). Augments customer name/language (REQ-007) + nurse `avatarUrl` (REQ-006) the wire DTOs lack | `USE_PROFILES_MOCK` (`services/profiles/constants.ts`, default `true`) | b3 `customer_profiles/*` + `nurse_profiles/*` are live; deliver REQ-006 (avatar route/field) + REQ-007 (customer name/language) then set flag `false` — `profilesClientApi` is wired (its `uploadAvatar` throws `501` until REQ-006) | 🟡 | +| `NurseBankAccountsApi` | `client/src/services/nurse/apis/mockApi.ts` | Bank-account list/add/set-primary/verify-ownership. Drives the استعلام شبا **pending→verified/mismatch** transition over 2 list reads (so the poll shows it), single-primary enforcement, masked-IBAN (last-4); the configured mismatch IBAN (`IR000000000000000000000000`, matches backend default) resolves to `matchedNationalId=false` | `USE_NURSE_BANK_MOCK` (`services/nurse/constants.ts`, default `true`) | b3 `nurse_bank_accounts/*` are live (the real `add` resolves the inquiry synchronously — no client poll needed); set flag `false` — `nurseBankClientApi` is wired | 🟡 | | `AuthApi` | `client/src/services/auth/apis/mockApi.ts` (`authMockApi`) | Phone-OTP login offline: `requestOtp`→`{otpSent,resendAvailableInSeconds:120}`; `verifyOtp` accepts dev code **`123456`** and locks after 3 wrong tries (`otp_locked`); `getMe`/`selectRole`/`refresh` from a `MOCK_SCENARIO` toggle (`customer`/`nurse_unverified`/`no_role`) to exercise all router branches | `USE_AUTH_MOCK` (`services/auth/constants.ts`, default **false** — b2 is live) + `MOCK_SCENARIO` in `mockApi.ts` | The real `authClientApi` is already wired to the live b2 routes; set `USE_AUTH_MOCK = false` (already the default) — no hook/screen change | 🟢 real by default, 🟡 mock available | diff --git a/product/business/01-actors-and-onboarding.md b/product/business/01-actors-and-onboarding.md index 40fb565..450d82d 100644 --- a/product/business/01-actors-and-onboarding.md +++ b/product/business/01-actors-and-onboarding.md @@ -12,6 +12,7 @@ - An **admin** is provisioned internally with RBAC roles. - Each successful login creates a refresh-token session that can be revoked (logout, stolen-token detection). - **As-built decisions (backend-phase-2):** each refresh **rotates** the session (old revoked, new pair issued); a refresh token presented against an already-revoked session is treated as **stolen-token reuse → all of the user's sessions are revoked and the call returns 401**. Logout rotates the security stamp, so every outstanding access token dies (other devices recover by refreshing). OTP request/verify never reveal whether a phone already has an account; one OTP per phone per resend window (`auth_otp_resend_seconds`), and after `auth_otp_max_attempts` wrong codes verification refuses until a fresh OTP. `customer`/`nurse` are the only self-selectable roles (a user may hold both; grants audited via `granted_by`/`granted_at`); any admin sub-role self-assign attempt returns **403** — admin provisioning is internal-only. +- **As-built decisions (frontend-phase-2 — onboarding UI):** the "who is care for?" onboarding step captures the patient's **relation to the payer** as a stable enum — `parent` | `spouse` | `child` | `self` (the `self` case still creates a distinct patient row; the customer is never collapsed into the patient). It also collects optional **condition** chips (`elderly`/`post_surgery`/`diabetes`/`mobility`/`dementia`). Neither `relation` nor `conditions` exists on the `patients` table yet — they are carried client-side and requested for the backend as **REQ-005** (frontend `requests/for-backend.md`); confirm/adjust the enum values when persisting. ## (b) Iran-specific considerations - Phone-OTP is the dominant Iranian login norm and is also the anchor for **Shahkar** SIM↔national-ID binding (Section 2).