frontend phase 2: onboarding & profiles — customer/patient, nurse profile & bank

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) <noreply@anthropic.com>
This commit is contained in:
hamid
2026-07-02 22:04:38 +03:30
parent 82561c4cc6
commit 4b4243c451
70 changed files with 3111 additions and 190 deletions
+21 -5
View File
@@ -118,14 +118,17 @@ client/
│ │ ├── select-role/page.tsx # /select-role — first-use role picker (no public role yet); role router lands here │ │ ├── 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 │ │ ├── (customer)/ # Customer (family) app — mobile-first, bottom-tab nav; no URL segment
│ │ │ ├── layout.tsx # 'use client' — wraps CustomerLayout │ │ │ ├── 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 │ │ │ ├── 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 │ │ │ ├── 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 │ │ ├── nurse/ # Nurse app (/nurse/…) — sidebar shell
│ │ │ ├── layout.tsx # 'use client' — wraps NurseLayout │ │ │ ├── layout.tsx # 'use client' — wraps NurseLayout
│ │ │ ├── page.tsx # /nurse (dashboard) │ │ │ ├── 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 │ │ │ ├── verification/page.tsx # /nurse/verification
│ │ │ └── visits/page.tsx # /nurse/visits (EVV) │ │ │ └── visits/page.tsx # /nurse/visits (EVV)
│ │ └── admin/ # Admin/backoffice (/admin/…) — desktop sidebar shell │ │ └── admin/ # Admin/backoffice (/admin/…) — desktop sidebar shell
@@ -142,6 +145,12 @@ client/
│ ├── PhoneNumberField/ # Iranian mobile field (digit-normalizing, LTR-in-RTL, maskIranMobile) │ ├── PhoneNumberField/ # Iranian mobile field (digit-normalizing, LTR-in-RTL, maskIranMobile)
│ ├── StepperHeader/ # Progress header for onboarding/verification flows │ ├── StepperHeader/ # Progress header for onboarding/verification flows
│ ├── StatusChip/ # Semantic status chip (verified/pending/rejected/…) off --bal-* tokens │ ├── 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 │ └── auth/ # Auth-flow composites: LoginFlow, PhoneStep, OtpStep, RoleRouter, SelectRole, AuthCard, BrandMark, AuthSplash, useCountdown
├── i18n/ ├── i18n/
│ ├── routing.ts # defineRouting — locales: ['en', 'fa'], defaultLocale: 'fa' │ ├── routing.ts # defineRouting — locales: ['en', 'fa'], defaultLocale: 'fa'
@@ -183,7 +192,9 @@ client/
│ └── index.ts # Re-exports constants ONLY (never server/client) │ └── index.ts # Re-exports constants ONLY (never server/client)
├── services/ # Domain services — no top-level barrel; import directly from the file ├── 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 │ ├── 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}/ │ └── {domain}/
│ ├── types.ts # Request/response types + the domain's Api interface (the seam) │ ├── types.ts # Request/response types + the domain's Api interface (the seam)
│ ├── keys.ts # React Query key factory (hierarchical) │ ├── 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 - `'nav'` — the actor shells (`CustomerLayout`/`NurseLayout`/`AdminLayout`) build their nav from here
- `'common'``DarkModeButton.tsx` (dark/light labels), shared words (loading, retry, currency_toman, …) - `'common'``DarkModeButton.tsx` (dark/light labels), shared words (loading, retry, currency_toman, …)
- `'shell'` — actor-shell titles + the not-yet-built placeholder body - `'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) - `'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 **Namespace conventions for the phases to come** (seed each when its feature lands, in both locale
+125 -6
View File
@@ -5,6 +5,7 @@
"patients": "Patients", "patients": "Patients",
"wallet": "Wallet", "wallet": "Wallet",
"profile": "Profile", "profile": "Profile",
"bank": "Bank account",
"dashboard": "Dashboard", "dashboard": "Dashboard",
"verification": "Verification", "verification": "Verification",
"visits": "Visits", "visits": "Visits",
@@ -25,6 +26,11 @@
"retry": "Retry", "retry": "Retry",
"add": "Add", "add": "Add",
"cancel": "Cancel", "cancel": "Cancel",
"save": "Save",
"saving": "Saving…",
"back": "Back",
"close": "Close",
"optional": "Optional",
"currency_toman": "Toman", "currency_toman": "Toman",
"brand": "Balinyaar", "brand": "Balinyaar",
"brand_tagline": "Home care you can trust" "brand_tagline": "Home care you can trust"
@@ -35,16 +41,129 @@
"admin_console": "Admin console", "admin_console": "Admin console",
"placeholder_body": "This area will be built in a later phase." "placeholder_body": "This area will be built in a later phase."
}, },
"patients": { "home": {
"title": "Patients", "greeting": "Welcome to Balinyaar",
"subtitle": "A reference screen wired to the services/{domain} + React Query pattern (mocked data).", "subtitle": "Manage care for the people you love.",
"add": "Add patient", "nudge_patient_title": "Complete the patient record",
"empty": "No patients yet. Add your first patient.", "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_label": "Full name",
"name_required": "Enter the patient's name",
"age_label": "Age",
"age_invalid": "Enter a valid age",
"gender_label": "Gender", "gender_label": "Gender",
"gender_male": "Male", "gender_male": "Male",
"gender_female": "Female", "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": { "auth": {
"customer_title": "Sign in to Balinyaar", "customer_title": "Sign in to Balinyaar",
+126 -7
View File
@@ -5,6 +5,7 @@
"patients": "بیماران", "patients": "بیماران",
"wallet": "کیف‌پول", "wallet": "کیف‌پول",
"profile": "پروفایل", "profile": "پروفایل",
"bank": "حساب بانکی",
"dashboard": "داشبورد", "dashboard": "داشبورد",
"verification": "احراز هویت", "verification": "احراز هویت",
"visits": "ویزیت‌ها", "visits": "ویزیت‌ها",
@@ -25,6 +26,11 @@
"retry": "تلاش مجدد", "retry": "تلاش مجدد",
"add": "افزودن", "add": "افزودن",
"cancel": "انصراف", "cancel": "انصراف",
"save": "ذخیره",
"saving": "در حال ذخیره…",
"back": "بازگشت",
"close": "بستن",
"optional": "اختیاری",
"currency_toman": "تومان", "currency_toman": "تومان",
"brand": "بلینیار", "brand": "بلینیار",
"brand_tagline": "مراقبت مطمئن در خانه" "brand_tagline": "مراقبت مطمئن در خانه"
@@ -35,16 +41,129 @@
"admin_console": "کنسول مدیریت", "admin_console": "کنسول مدیریت",
"placeholder_body": "این بخش در فازهای بعدی تکمیل می‌شود." "placeholder_body": "این بخش در فازهای بعدی تکمیل می‌شود."
}, },
"patients": { "home": {
"title": یماران", "greeting": ه بلینیار خوش آمدید",
"subtitle": "یک صفحهٔ مرجع که به الگوی services/{domain} و React Query متصل است (داده‌های آزمایشی).", "subtitle": "مراقبت از عزیزانتان را مدیریت کنید.",
"add": "افزودن بیمار", "nudge_patient_title": "تکمیل پروندهٔ بیمار",
"empty": "هنوز بیماری ثبت نشده است. اولین بیمار را اضافه کنید.", "nudge_patient_body": "وضعیت‌ها، داروها و روتین را اضافه کنید تا پرستار آماده حاضر شود.",
"name_label": "نام کامل", "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_label": "جنسیت",
"gender_male": "مرد", "gender_male": "مرد",
"gender_female": "زن", "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": { "auth": {
"customer_title": "ورود به بلینیار", "customer_title": "ورود به بلینیار",
@@ -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<Relation | null>(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 (
<Box sx={{ maxWidth: ONBOARDING_MAX_WIDTH, mx: 'auto', display: 'flex', flexDirection: 'column', gap: 3 }}>
<StepperHeader steps={[t('step_relation'), t('step_patient')]} activeStep={step} />
{step === 0 ? (
<Stack sx={{ gap: 2 }}>
<Stack sx={{ gap: 0.5 }}>
<Typography variant="h6" component="h1">
{t('relation_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('relation_subtitle')}
</Typography>
</Stack>
<RelationSelect
options={relationOptions}
value={relation}
onChange={(code) => setRelation(code as Relation)}
/>
<AppButton
color="primary"
variant="contained"
fullWidth
disabled={!relation}
onClick={() => setStep(1)}
sx={{ m: 0 }}
>
{t('continue')}
</AppButton>
</Stack>
) : (
<Stack sx={{ gap: 2 }}>
<Stack sx={{ gap: 0.5 }}>
<Typography variant="h6" component="h1">
{t('patient_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('patient_subtitle')}
</Typography>
</Stack>
<PatientForm
initial={{ relation: relation ?? undefined }}
submitLabel={t('save_continue')}
submitting={createPatient.isPending}
onSubmit={handleCreate}
onCancel={() => setStep(0)}
cancelLabel={tc('back')}
/>
</Stack>
)}
</Box>
);
}
@@ -1,8 +1,100 @@
import { getTranslations } from 'next-intl/server'; 'use client';
import { PlaceholderScreen } from '@/components'; 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() { interface NudgeCardProps {
const t = await getTranslations('nav'); icon: string;
const tShell = await getTranslations('shell'); title: string;
return <PlaceholderScreen icon="home" title={t('home')} description={tShell('placeholder_body')} />; body: string;
ctaLabel: string;
to: string;
}
const NudgeCard: FunctionComponent<NudgeCardProps> = ({ icon, title, body, ctaLabel, to }) => (
<Paper
elevation={0}
sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2, display: 'flex', gap: 2 }}
>
<AppIcon icon={icon} size={28} color="var(--bal-primary)" />
<Stack sx={{ gap: 1, flexGrow: 1 }}>
<Stack sx={{ gap: 0.25 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{title}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{body}
</Typography>
</Stack>
<AppButton color="primary" variant="outlined" to={to} sx={{ m: 0, alignSelf: 'flex-start' }}>
{ctaLabel}
</AppButton>
</Stack>
</Paper>
);
/**
* 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 <AppLoading />;
}
const href = (path: string) => `/${locale}${path}`;
const profileComplete = me?.hasCustomerProfile ?? false;
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Box>
<Typography variant="h5" component="h1">
{t('greeting')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('subtitle')}
</Typography>
</Box>
<NudgeCard
icon="patients"
title={t('nudge_patient_title')}
body={t('nudge_patient_body')}
ctaLabel={t('nudge_patient_cta')}
to={href(ROUTES.PATIENTS)}
/>
{!profileComplete ? (
<NudgeCard
icon="profile"
title={t('nudge_profile_title')}
body={t('nudge_profile_body')}
ctaLabel={t('nudge_profile_cta')}
to={href(ROUTES.PROFILE)}
/>
) : null}
</Box>
);
} }
@@ -1,48 +1,86 @@
'use client'; 'use client';
import { ChangeEvent, useState } from 'react'; import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { Box, Chip, List, ListItem, ListItemText, MenuItem, Stack, TextField, Typography } from '@mui/material';
import { useSnackbar } from 'notistack'; import { useSnackbar } from 'notistack';
import { AppButton, AppLoading } from '@/components'; import {
import { usePatients, useAddPatient } from '@/services/patients'; Box,
import type { Gender } from '@/services/patients/types'; Dialog,
import { formatShamsiDate } from '@/utils'; 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 * E1 — the Patients tab: a cached, invalidate-on-mutation list of the customer's patients
* mocked patients list via usePatients (cached with a staleTime) and adds one via * with add/edit (the A4 form reused in a dialog) and soft archive (confirm). Loading skeleton
* useAddPatient, whose onSuccess invalidates the list so the new row appears without a * and an empty state with the add CTA are both handled.
* manual refetch — visible in the React Query Devtools.
*/ */
export default function PatientsPage() { export default function PatientsPage() {
const t = useTranslations('patients'); const t = useTranslations('patients');
const locale = useLocale(); const to = useTranslations('onboarding');
const tc = useTranslations('common');
const { enqueueSnackbar } = useSnackbar(); const { enqueueSnackbar } = useSnackbar();
const { data, isLoading } = usePatients(); const { data, isLoading } = usePatients();
const addPatient = useAddPatient(); const createPatient = useCreatePatient();
const updatePatient = useUpdatePatient();
const archivePatient = useArchivePatient();
const [name, setName] = useState(''); const [formOpen, setFormOpen] = useState(false);
const [gender, setGender] = useState<Gender>('female'); const [editing, setEditing] = useState<Patient | null>(null);
const [archiveTarget, setArchiveTarget] = useState<Patient | null>(null);
const genderLabel = (value: Gender) => (value === 'male' ? t('gender_male') : t('gender_female')); const openAdd = () => {
setEditing(null);
const handleAdd = () => { setFormOpen(true);
const fullName = name.trim();
if (!fullName) return;
addPatient.mutate(
{ fullName, gender },
{
onSuccess: () => {
setName('');
enqueueSnackbar(t('added'), { variant: 'success' });
},
}
);
}; };
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 ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Stack direction="row" sx={{ alignItems: 'flex-start', justifyContent: 'space-between', gap: 2 }}>
<Box> <Box>
<Typography variant="h5" component="h1"> <Typography variant="h5" component="h1">
{t('title')} {t('title')}
@@ -51,51 +89,112 @@ export default function PatientsPage() {
{t('subtitle')} {t('subtitle')}
</Typography> </Typography>
</Box> </Box>
{!isEmpty ? (
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1} sx={{ alignItems: { sm: 'flex-start' } }}> <AppButton color="primary" variant="contained" startIcon="add" onClick={openAdd} sx={{ m: 0, flexShrink: 0 }}>
<TextField
label={t('name_label')}
value={name}
onChange={(event: ChangeEvent<HTMLInputElement>) => setName(event.target.value)}
fullWidth
/>
<TextField
select
label={t('gender_label')}
value={gender}
onChange={(event: ChangeEvent<HTMLInputElement>) => setGender(event.target.value as Gender)}
sx={{ minWidth: 140 }}
>
<MenuItem value="female">{t('gender_female')}</MenuItem>
<MenuItem value="male">{t('gender_male')}</MenuItem>
</TextField>
<AppButton
color="primary"
startIcon="add"
onClick={handleAdd}
disabled={!name.trim() || addPatient.isPending}
>
{t('add')} {t('add')}
</AppButton> </AppButton>
) : null}
</Stack> </Stack>
{isLoading ? ( {isLoading ? (
<AppLoading /> <Stack sx={{ gap: 1.5 }}>
) : !data || data.items.length === 0 ? ( {[0, 1].map((key) => (
<Typography sx={{ color: 'text.secondary' }}>{t('empty')}</Typography> <Skeleton key={key} variant="rounded" height={96} />
) : (
<List>
{data.items.map((patient) => (
<ListItem
key={patient.id}
divider
secondaryAction={<Chip size="small" label={genderLabel(patient.gender)} />}
>
<ListItemText primary={patient.fullName} secondary={formatShamsiDate(patient.createdAtUtc, locale)} />
</ListItem>
))} ))}
</List> </Stack>
) : isEmpty ? (
<Paper
elevation={0}
sx={{
p: 4,
textAlign: 'center',
border: '1px dashed',
borderColor: 'divider',
borderRadius: 2,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 1.5,
}}
>
<AppIcon icon="patients" size={40} color="var(--bal-primary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('empty_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('empty_body')}
</Typography>
<AppButton color="primary" variant="contained" startIcon="add" onClick={openAdd} sx={{ mt: 1 }}>
{t('add')}
</AppButton>
</Paper>
) : (
<Stack sx={{ gap: 1.5 }}>
{patients.map((patient) => {
const age = birthDateToAge(patient.birthDate);
return (
<PatientCard
key={patient.id}
patient={patient}
relationLabel={patient.relation ? to(`relation_${patient.relation}`) : undefined}
genderLabel={to(`gender_${patient.gender}`)}
ageLabel={age == null ? undefined : t('age_years', { age })}
conditionLabels={patient.conditions.map((code) => to(`condition_${code}`))}
noConditionsLabel={t('conditions_none')}
onEdit={() => openEdit(patient)}
onArchive={() => setArchiveTarget(patient)}
editLabel={t('edit')}
archiveLabel={t('archive')}
/>
);
})}
</Stack>
)} )}
<Dialog open={formOpen} onClose={closeForm} fullWidth maxWidth="sm">
<DialogTitle>{editing ? t('edit_title') : t('add_title')}</DialogTitle>
<DialogContent>
<Box sx={{ pt: 1 }}>
<PatientForm
key={editing?.id ?? 'new'}
initial={
editing
? {
displayName: editing.displayName,
birthDate: editing.birthDate,
gender: editing.gender,
conditions: editing.conditions,
relation: editing.relation,
}
: undefined
}
showRelation
submitLabel={tc('save')}
submitting={createPatient.isPending || updatePatient.isPending}
onSubmit={handleSubmit}
onCancel={closeForm}
cancelLabel={tc('cancel')}
/>
</Box>
</DialogContent>
</Dialog>
<Dialog open={Boolean(archiveTarget)} onClose={() => setArchiveTarget(null)}>
<DialogTitle>{t('archive_title')}</DialogTitle>
<DialogContent>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('archive_body')}
</Typography>
</DialogContent>
<DialogActions>
<AppButton variant="text" onClick={() => setArchiveTarget(null)}>
{tc('cancel')}
</AppButton>
<AppButton color="error" variant="contained" onClick={confirmArchive}>
{t('archive_confirm')}
</AppButton>
</DialogActions>
</Dialog>
</Box> </Box>
); );
} }
@@ -1,8 +1,128 @@
import { getTranslations } from 'next-intl/server'; 'use client';
import { PlaceholderScreen } from '@/components'; 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() { /** Customer profile — name, preferred language, and the emergency contact. No national-ID KYC. */
const t = await getTranslations('nav'); export default function CustomerProfilePage() {
const tShell = await getTranslations('shell'); const { data: profile, isLoading } = useCustomerProfile();
return <PlaceholderScreen icon="profile" title={t('profile')} description={tShell('placeholder_body')} />; if (isLoading) return <AppLoading />;
return <CustomerProfileForm initial={profile ?? null} />;
} }
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 (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 520 }}>
<Box>
<Typography variant="h5" component="h1">
{t('title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('subtitle')}
</Typography>
<Typography variant="body2" sx={{ mt: 0.5, color: isComplete ? 'var(--bal-success)' : 'text.secondary' }}>
{isComplete ? t('completion_done') : t('completion_todo')}
</Typography>
</Box>
<Stack direction={{ xs: 'column', sm: 'row' }} sx={{ gap: 2 }}>
<TextField label={t('first_name')} value={firstName} onChange={(e) => setFirstName(e.target.value)} fullWidth />
<TextField label={t('last_name')} value={lastName} onChange={(e) => setLastName(e.target.value)} fullWidth />
</Stack>
<TextField
select
label={t('language')}
value={language}
onChange={(e) => setLanguage(e.target.value)}
sx={{ maxWidth: 220 }}
>
<MenuItem value="fa">{t('language_fa')}</MenuItem>
<MenuItem value="en">{t('language_en')}</MenuItem>
</TextField>
<Divider />
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('emergency_section')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('emergency_hint')}
</Typography>
</Box>
<TextField
label={t('emergency_name')}
value={emergencyName}
onChange={(e) => {
setEmergencyName(e.target.value);
if (nameError) setNameError(false);
}}
error={nameError}
fullWidth
/>
<PhoneNumberField
label={t('emergency_phone')}
value={emergencyPhone}
onChange={(value) => {
setEmergencyPhone(value);
if (phoneError) setPhoneError(false);
}}
error={phoneError}
helperText={phoneError ? t('emergency_phone_invalid') : undefined}
fullWidth
/>
<AppButton
color="primary"
variant="contained"
onClick={handleSave}
disabled={upsert.isPending}
sx={{ m: 0, alignSelf: 'flex-start' }}
>
{upsert.isPending ? tc('saving') : t('save')}
</AppButton>
</Box>
);
};
@@ -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 (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 560 }}>
<Box>
<Typography variant="h5" component="h1">
{t('title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('subtitle')}
</Typography>
</Box>
{isLoading ? <AppLoading /> : null}
{accounts.map((account) => {
const status = deriveBankStatus(account);
return (
<Stack key={account.id} sx={{ gap: 1 }}>
<BankStatusPanel
status={status}
chipLabel={t(`status_${status}_chip`)}
title={t(`status_${status}_title`)}
body={t(`status_${status}_body`)}
ibanMasked={status === 'verified' ? account.ibanMasked : undefined}
ibanLabel={t('iban_masked_label')}
bankName={account.bankName || undefined}
isPrimary={account.isPrimary}
primaryLabel={t('primary')}
onReenter={status === 'mismatch' ? () => setShowForm(true) : undefined}
reenterLabel={t('reenter')}
/>
{/* Promote a verified non-primary account so payouts (gated on matchedNationalId) target it. */}
{status === 'verified' && !account.isPrimary ? (
<AppButton
variant="text"
color="primary"
disabled={setPrimary.isPending}
onClick={() =>
setPrimary.mutate(account.id, {
onSuccess: () => enqueueSnackbar(t('primary_set'), { variant: 'success' }),
})
}
sx={{ m: 0, alignSelf: 'flex-start' }}
>
{t('make_primary')}
</AppButton>
) : null}
</Stack>
);
})}
{!isLoading && accounts.length === 0 ? (
<Paper
elevation={0}
sx={{ p: 3, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}
>
<AppIcon icon="bank" size={36} color="var(--bal-primary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('empty_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('empty_body')}
</Typography>
</Paper>
) : null}
{showFormNow ? (
<Stack sx={{ gap: 2 }}>
<TextField
label={t('iban_label')}
value={iban}
onChange={(e) => {
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
/>
<TextField
label={t('holder_label')}
value={holder}
onChange={(e) => {
setHolder(e.target.value);
if (holderError) setHolderError(false);
}}
error={holderError}
helperText={holderError ? t('holder_required') : t('holder_hint')}
fullWidth
/>
<AppButton
color="primary"
variant="contained"
startIcon="bank"
onClick={submit}
disabled={addAccount.isPending}
sx={{ m: 0, alignSelf: 'flex-start' }}
>
{addAccount.isPending ? t('submitting') : t('submit')}
</AppButton>
</Stack>
) : null}
</Box>
);
}
@@ -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 <AppLoading />;
return <NurseProfileForm initial={profile ?? null} />;
}
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<HTMLInputElement>(null);
const [avatarUrl, setAvatarUrl] = useState<string | null>(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<HTMLInputElement>) => {
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 (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 560 }}>
<Box>
<Typography variant="h5" component="h1">
{t('title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('subtitle')}
</Typography>
</Box>
{/* Not bookable until verification (f5) — a neutral placeholder, not the real banner. */}
<Paper
elevation={0}
sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider', borderInlineStartWidth: 4, borderInlineStartColor: 'var(--bal-warning)' }}
>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'flex-start' }}>
<AppIcon icon="warning" size={24} color="var(--bal-warning)" />
<Stack sx={{ gap: 1, flexGrow: 1 }}>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('unverified_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('unverified_body')}
</Typography>
</Box>
<AppButton
color="primary"
variant="outlined"
to={`/${locale}${ROUTES.NURSE_VERIFICATION}`}
sx={{ m: 0, alignSelf: 'flex-start' }}
>
{t('unverified_cta')}
</AppButton>
</Stack>
</Stack>
</Paper>
<Stack direction="row" sx={{ gap: 2, alignItems: 'center' }}>
<Avatar src={avatarUrl ?? undefined} sx={{ width: 72, height: 72, bgcolor: 'var(--bal-primary-soft)' }}>
{avatarUrl ? null : <AppIcon icon="account" size={36} color="var(--bal-primary)" />}
</Avatar>
<Stack sx={{ gap: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('photo')}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('photo_hint')}
</Typography>
<AppButton
variant="outlined"
color="primary"
startIcon="camera"
onClick={pickFile}
disabled={uploadAvatar.isPending}
sx={{ m: 0, mt: 0.5, alignSelf: 'flex-start' }}
>
{uploadAvatar.isPending ? t('uploading') : t('upload')}
</AppButton>
<input ref={fileInputRef} type="file" accept="image/*" hidden onChange={onFileSelected} />
</Stack>
</Stack>
<TextField
label={t('bio')}
value={bio}
onChange={(e) => setBio(e.target.value)}
helperText={t('bio_hint')}
multiline
minRows={3}
fullWidth
/>
<TextField
label={t('years')}
value={years}
onChange={(e) => {
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 }}
/>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('deferred_services')}
</Typography>
<AppButton
color="primary"
variant="contained"
onClick={handleSave}
disabled={upsert.isPending}
sx={{ m: 0, alignSelf: 'flex-start' }}
>
{upsert.isPending ? tc('saving') : t('save')}
</AppButton>
</Box>
);
};
@@ -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('<BankStatusPanel/> component', () => {
it('renders the pending state with its chip and title', () => {
const { container } = render(
<ThemeProvider>
<BankStatusPanel status="pending" chipLabel="Checking" title="Verifying ownership" body="Please wait" />
</ThemeProvider>,
);
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(
<ThemeProvider>
<BankStatusPanel
status="verified"
chipLabel="Verified"
title="Account verified"
body="Ready for payouts"
ibanMasked="••••3456"
ibanLabel="IBAN"
/>
</ThemeProvider>,
);
expect(screen.getByText('••••3456')).toBeInTheDocument();
});
it('offers the re-enter action only on mismatch', async () => {
const user = userEvent.setup();
const onReenter = jest.fn();
render(
<ThemeProvider>
<BankStatusPanel
status="mismatch"
chipLabel="Mismatch"
title="Must be your own account"
body="Names do not match"
onReenter={onReenter}
reenterLabel="Enter another account"
/>
</ThemeProvider>,
);
await user.click(screen.getByText('Enter another account'));
expect(onReenter).toHaveBeenCalledTimes(1);
});
});
@@ -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<BankAccountStatus, StatusKind> = {
pending: 'pending',
verified: 'verified',
mismatch: 'rejected',
};
const ACCENT_TOKEN: Record<BankAccountStatus, string> = {
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<BankStatusPanelProps> = ({
status,
chipLabel,
title,
body,
ibanMasked,
ibanLabel,
bankName,
isPrimary = false,
primaryLabel,
onReenter,
reenterLabel,
}) => (
<Paper
elevation={0}
data-status={status}
sx={{
p: 2,
border: '1px solid',
borderColor: 'divider',
borderInlineStartWidth: 4,
borderInlineStartColor: ACCENT_TOKEN[status],
borderRadius: 2,
}}
>
<Stack sx={{ gap: 1.25 }}>
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<StatusChip status={STATUS_KIND[status]} label={chipLabel} />
{isPrimary && primaryLabel ? (
<StatusChip status="info" label={primaryLabel} />
) : null}
</Stack>
<Stack sx={{ gap: 0.25 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{title}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{body}
</Typography>
</Stack>
{ibanMasked ? (
<Stack direction="row" sx={{ alignItems: 'baseline', gap: 1 }}>
{ibanLabel ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{ibanLabel}
</Typography>
) : null}
<Typography sx={{ fontWeight: 600, letterSpacing: 1 }} dir="ltr">
{ibanMasked}
</Typography>
{bankName ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{bankName}
</Typography>
) : null}
</Stack>
) : null}
{status === 'mismatch' && onReenter && reenterLabel ? (
<AppButton color="primary" variant="outlined" startIcon="bank" onClick={onReenter} sx={{ m: 0, alignSelf: 'flex-start' }}>
{reenterLabel}
</AppButton>
) : null}
</Stack>
</Paper>
);
export default BankStatusPanel;
@@ -0,0 +1,4 @@
import BankStatusPanel from './BankStatusPanel';
export type { BankStatusPanelProps } from './BankStatusPanel';
export { BankStatusPanel as default, BankStatusPanel };
@@ -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(
<ThemeProvider>
<ConditionChips options={OPTIONS} value={value} onChange={onChange} />
</ThemeProvider>,
);
return { onChange };
}
describe('<ConditionChips/> 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([]);
});
});
@@ -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<ConditionChipsProps> = ({ options, value, onChange, disabled = false }) => {
const toggle = (code: string) => {
onChange(value.includes(code) ? value.filter((item) => item !== code) : [...value, code]);
};
return (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{options.map((option) => {
const selected = value.includes(option.code);
return (
<Chip
key={option.code}
label={option.label}
data-code={option.code}
aria-pressed={selected}
clickable
disabled={disabled}
color={selected ? 'primary' : 'default'}
variant={selected ? 'filled' : 'outlined'}
onClick={() => toggle(option.code)}
/>
);
})}
</Box>
);
};
export default ConditionChips;
@@ -0,0 +1,4 @@
import ConditionChips from './ConditionChips';
export type { ConditionChipsProps, ConditionOption } from './ConditionChips';
export { ConditionChips as default, ConditionChips };
@@ -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(
<ThemeProvider>
<GenderToggle value={value} onChange={onChange} maleLabel="Male" femaleLabel="Female" />
</ThemeProvider>,
);
return { ...utils, onChange };
}
describe('<GenderToggle/> 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();
});
});
@@ -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<GenderToggleProps> = ({
value,
onChange,
maleLabel,
femaleLabel,
error = false,
disabled = false,
ariaLabel,
}) => (
<ToggleButtonGroup
exclusive
value={value}
disabled={disabled}
aria-label={ariaLabel}
onChange={(_event, next: Gender | null) => {
if (next) onChange(next);
}}
sx={{
'& .MuiToggleButton-root': {
flex: 1,
py: 1.25,
fontWeight: 600,
borderColor: error ? 'var(--bal-error)' : undefined,
},
}}
>
<ToggleButton value="male" data-gender="male">
{maleLabel}
</ToggleButton>
<ToggleButton value="female" data-gender="female">
{femaleLabel}
</ToggleButton>
</ToggleButtonGroup>
);
export default GenderToggle;
@@ -0,0 +1,4 @@
import GenderToggle from './GenderToggle';
export type { GenderToggleProps } from './GenderToggle';
export { GenderToggle as default, GenderToggle };
@@ -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(
<ThemeProvider>
<PatientCard
patient={PATIENT}
relationLabel="Parent"
genderLabel="Female"
ageLabel="70 yrs"
conditionLabels={['Elderly']}
noConditionsLabel="No conditions"
onEdit={onEdit}
onArchive={onArchive}
editLabel="Edit"
archiveLabel="Archive"
/>
</ThemeProvider>,
);
return { onEdit, onArchive };
}
describe('<PatientCard/> 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);
});
});
@@ -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<PatientCardProps> = ({
patient,
relationLabel,
genderLabel,
ageLabel,
conditionLabels,
noConditionsLabel,
onEdit,
onArchive,
editLabel,
archiveLabel,
}) => {
const meta = [ageLabel, genderLabel].filter(Boolean).join(' · ');
return (
<Paper elevation={0} sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack direction="row" sx={{ alignItems: 'flex-start', gap: 1 }}>
<Stack sx={{ flexGrow: 1, gap: 0.75, minWidth: 0 }}>
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{patient.displayName}
</Typography>
{relationLabel ? (
<Chip size="small" label={relationLabel} sx={{ bgcolor: 'var(--bal-primary-soft)', fontWeight: 600 }} />
) : null}
</Stack>
{meta ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{meta}
</Typography>
) : null}
{conditionLabels.length > 0 ? (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mt: 0.25 }}>
{conditionLabels.map((label) => (
<Chip key={label} size="small" variant="outlined" label={label} />
))}
</Box>
) : (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{noConditionsLabel}
</Typography>
)}
</Stack>
<Stack direction="row" sx={{ flexShrink: 0 }}>
<AppIconButton icon="edit" title={editLabel} aria-label={editLabel} size="small" onClick={onEdit} />
<AppIconButton icon="archive" title={archiveLabel} aria-label={archiveLabel} size="small" onClick={onArchive} />
</Stack>
</Stack>
</Paper>
);
};
export default PatientCard;
@@ -0,0 +1,4 @@
import PatientCard from './PatientCard';
export type { PatientCardProps } from './PatientCard';
export { PatientCard as default, PatientCard };
@@ -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(
<ThemeProvider>
<PatientForm submitLabel="Save" onSubmit={onSubmit} />
</ThemeProvider>,
);
return { onSubmit };
}
describe('<PatientForm/> 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$/),
}),
);
});
});
@@ -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<Pick<Patient, 'displayName' | 'birthDate' | 'gender' | 'conditions' | 'relation'>>;
/** 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<CreatePatientInput, 'displayName' | 'firstName' | 'lastName'> {
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<PatientFormProps> = ({
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<Gender | null>(initial?.gender ?? null);
const [conditions, setConditions] = useState<string[]>(initial?.conditions ?? []);
const [relation, setRelation] = useState<Relation | null>(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 (
<Stack sx={{ gap: 2.5 }}>
<TextField
label={t('name_label')}
value={fullName}
onChange={(event) => {
setFullName(event.target.value);
if (nameError) setNameError(false);
}}
error={nameError}
helperText={nameError ? t('name_required') : undefined}
fullWidth
/>
<TextField
label={t('age_label')}
value={age}
onChange={(event) => {
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 }}
/>
<Stack sx={{ gap: 1 }}>
<FormLabel error={genderError}>{t('gender_label')}</FormLabel>
<GenderToggle
value={gender}
onChange={(next) => {
setGender(next);
if (genderError) setGenderError(false);
}}
maleLabel={t('gender_male')}
femaleLabel={t('gender_female')}
error={genderError}
ariaLabel={t('gender_label')}
/>
{genderError ? (
<Typography variant="caption" sx={{ color: 'var(--bal-error)' }}>
{t('gender_required')}
</Typography>
) : null}
</Stack>
<Stack sx={{ gap: 1 }}>
<FormLabel>{t('conditions_label')}</FormLabel>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('conditions_hint')}
</Typography>
<ConditionChips options={conditionOptions} value={conditions} onChange={setConditions} />
</Stack>
{showRelation ? (
<Stack sx={{ gap: 1 }}>
<FormLabel>{t('relation_title')}</FormLabel>
<RelationSelect
options={relationOptions}
value={relation}
onChange={(code) => setRelation(code as Relation)}
/>
</Stack>
) : null}
<Stack direction="row" sx={{ gap: 1, justifyContent: 'flex-end' }}>
{onCancel ? (
<AppButton variant="text" onClick={onCancel} disabled={submitting} sx={{ m: 0 }}>
{cancelLabel}
</AppButton>
) : null}
<AppButton
color="primary"
variant="contained"
onClick={handleSubmit}
disabled={submitting}
sx={{ m: 0 }}
>
{submitLabel}
</AppButton>
</Stack>
</Stack>
);
};
export default PatientForm;
@@ -0,0 +1,4 @@
import PatientForm from './PatientForm';
export type { PatientFormProps } from './PatientForm';
export { PatientForm as default, PatientForm };
@@ -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(
<ThemeProvider>
<RelationSelect options={OPTIONS} value={value} onChange={onChange} />
</ThemeProvider>,
);
return { ...utils, onChange };
}
describe('<RelationSelect/> 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');
});
});
@@ -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<RelationSelectProps> = ({ options, value, onChange }) => (
<Stack role="radiogroup" sx={{ gap: 1.5 }}>
{options.map((option) => {
const selected = value === option.code;
return (
<Paper
key={option.code}
role="radio"
aria-checked={selected}
tabIndex={0}
data-code={option.code}
elevation={0}
onClick={() => 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 ? <AppIcon icon={option.icon} size={26} color="var(--bal-primary)" /> : null}
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
{option.label}
</Typography>
</Paper>
);
})}
</Stack>
);
export default RelationSelect;
@@ -0,0 +1,4 @@
import RelationSelect from './RelationSelect';
export type { RelationSelectProps, RelationOption } from './RelationSelect';
export { RelationSelect as default, RelationSelect };
@@ -32,6 +32,11 @@ import CancelIcon from '@mui/icons-material/Cancel';
import MedicalServicesIcon from '@mui/icons-material/MedicalServices'; import MedicalServicesIcon from '@mui/icons-material/MedicalServices';
import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings'; import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings';
import AddIcon from '@mui/icons-material/Add'; 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 * List of all available Icon names
@@ -79,4 +84,9 @@ export const ICONS /* Note: Setting type disables property autocomplete :( was -
visits: MedicalServicesIcon, visits: MedicalServicesIcon,
admin: AdminPanelSettingsIcon, admin: AdminPanelSettingsIcon,
add: AddIcon, add: AddIcon,
edit: EditIcon,
archive: ArchiveIcon,
bank: BankIcon,
camera: CameraIcon,
warning: WarningIcon,
}; };
+26 -1
View File
@@ -6,10 +6,35 @@ import OtpInput from './OtpInput';
import PhoneNumberField from './PhoneNumberField'; import PhoneNumberField from './PhoneNumberField';
import StepperHeader from './StepperHeader'; import StepperHeader from './StepperHeader';
import StatusChip from './StatusChip'; 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 { PlaceholderScreenProps } from './PlaceholderScreen';
export type { OtpInputProps } from './OtpInput'; export type { OtpInputProps } from './OtpInput';
export type { PhoneNumberFieldProps } from './PhoneNumberField'; export type { PhoneNumberFieldProps } from './PhoneNumberField';
export type { StepperHeaderProps } from './StepperHeader'; export type { StepperHeaderProps } from './StepperHeader';
export type { StatusChipProps, StatusKind } from './StatusChip'; 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';
+4
View File
@@ -5,6 +5,8 @@ export const ROUTES = {
// Customer (family) app — mobile-first, bottom-tab nav // Customer (family) app — mobile-first, bottom-tab nav
HOME: '/', HOME: '/',
// First-login "who is care for?" flow (A3→A4); re-enterable from the patient list.
ONBOARDING: '/onboarding',
BOOKINGS: '/bookings', BOOKINGS: '/bookings',
PATIENTS: '/patients', PATIENTS: '/patients',
WALLET: '/wallet', WALLET: '/wallet',
@@ -12,6 +14,8 @@ export const ROUTES = {
// Nurse app // Nurse app
NURSE: '/nurse', NURSE: '/nurse',
NURSE_PROFILE: '/nurse/profile',
NURSE_BANK: '/nurse/bank',
NURSE_VERIFICATION: '/nurse/verification', NURSE_VERIFICATION: '/nurse/verification',
NURSE_VISITS: '/nurse/visits', NURSE_VISITS: '/nurse/visits',
+2
View File
@@ -18,6 +18,8 @@ const NurseLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
const sidebarItems: Array<LinkToPage> = useMemo( const sidebarItems: Array<LinkToPage> = useMemo(
() => [ () => [
{ title: t('dashboard'), path: ROUTES.NURSE, icon: 'dashboard' }, { 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('verification'), path: ROUTES.NURSE_VERIFICATION, icon: 'verification' },
{ title: t('visits'), path: ROUTES.NURSE_VISITS, icon: 'visits' }, { title: t('visits'), path: ROUTES.NURSE_VISITS, icon: 'visits' },
], ],
@@ -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<ApiEnvelope<NurseBankAccountDto[]>>(`${BASE}/list`)),
add: async (input: AddBankAccountInput) => {
const iban = normalizeSheba(input.iban);
return unwrap(
await clientFetch<ApiEnvelope<NurseBankAccountDto>>(`${BASE}/add`, {
method: 'POST',
body: JSON.stringify({ bankName: shebaBankName(iban), accountHolderName: input.accountHolderName, iban }),
}),
);
},
setPrimary: async (id: number) => {
await clientFetch<ApiEnvelope<void>>(`${BASE}/set_primary/${id}`, { method: 'POST' });
},
verifyOwnership: async (id: number) =>
unwrap(await clientFetch<ApiEnvelope<NurseBankAccountDto>>(`${BASE}/verify_ownership/${id}`, { method: 'POST' })),
};
+10
View File
@@ -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;
+81
View File
@@ -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<NurseBankAccountDto[]> => {
await sleep(MOCK_LATENCY_MS);
store.forEach(resolveIfDue);
return store.map((entry) => entry.dto);
},
add: async (input: AddBankAccountInput): Promise<NurseBankAccountDto> => {
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<void> => {
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<NurseBankAccountDto> => {
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;
},
};
+20
View File
@@ -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';
@@ -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() });
},
});
}
@@ -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;
},
});
}
@@ -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() });
},
});
}
+48
View File
@@ -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<string, string> = {
'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] ?? '';
}
+3
View File
@@ -0,0 +1,3 @@
export { useNurseBankAccounts } from './hooks/useNurseBankAccounts';
export { useAddNurseBankAccount } from './hooks/useAddNurseBankAccount';
export { useSetPrimaryBankAccount } from './hooks/useSetPrimaryBankAccount';
+5
View File
@@ -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,
};
+42
View File
@@ -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<NurseBankAccountDto, 'matchedNationalId'>): 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<NurseBankAccountDto[]>;
add(input: AddBankAccountInput): Promise<NurseBankAccountDto>;
setPrimary(id: number): Promise<void>;
verifyOwnership(id: number): Promise<NurseBankAccountDto>;
}
+22
View File
@@ -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;
}
+54 -13
View File
@@ -1,29 +1,70 @@
import { clientFetch } from '@/lib/api/client'; import { clientFetch } from '@/lib/api/client';
import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types'; 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<CreatePatientInput, 'relation' | 'conditions'>): 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 * Real HTTP implementation of the PatientsApi seam (b3 action-style routes). `clientFetch`
* returns the raw server envelope so each call reads the payload via `unwrap`. * returns the raw envelope, so each call reads its payload via `unwrap`. Selected once
* Not selected until USE_PATIENTS_MOCK is false and the endpoints exist. * USE_PATIENTS_MOCK is false and the relation/conditions fields land.
*/ */
export const patientsClientApi: PatientsApi = { export const patientsClientApi: PatientsApi = {
list: async (params) => { list: async (params) => {
const query = new URLSearchParams(); const query = new URLSearchParams();
if (params?.page) query.set('page', String(params.page)); 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 qs = query.toString();
const env = await clientFetch<ApiEnvelope<Paginated<Patient>>>(`${BASE}${qs ? `?${qs}` : ''}`); const page = unwrap(await clientFetch<ApiEnvelope<Paginated<PatientDto>>>(`${BASE}/list${qs ? `?${qs}` : ''}`));
return unwrap(env); return { ...page, items: page.items.map((dto) => toPatient(dto)) };
}, },
create: async (dto: CreatePatientDto) => { get: async (id) => toPatient(unwrap(await clientFetch<ApiEnvelope<PatientDto>>(`${BASE}/get/${id}`))),
const env = await clientFetch<ApiEnvelope<Patient>>(BASE, {
create: async (input) =>
toPatient(
unwrap(
await clientFetch<ApiEnvelope<PatientDto>>(`${BASE}/create`, {
method: 'POST', method: 'POST',
body: JSON.stringify(dto), body: JSON.stringify(toBody(input)),
}); }),
return unwrap(env); ),
input,
),
update: async (id, input) =>
toPatient(
unwrap(
await clientFetch<ApiEnvelope<PatientDto>>(`${BASE}/update/${id}`, {
method: 'POST',
body: JSON.stringify(toBody(input)),
}),
),
input,
),
archive: async (id) => {
await clientFetch<ApiEnvelope<void>>(`${BASE}/archive/${id}`, { method: 'POST' });
}, },
}; };
+55 -28
View File
@@ -1,45 +1,72 @@
import { sleep } from '@/utils'; import { sleep } from '@/utils';
import type { Paginated } from '@/lib/api/types'; import { ApiError } from '@/lib/api/errors';
import type { CreatePatientDto, Patient, PatientsApi } from '../types'; 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 // In-memory store, seeded **empty** so a single session can demo both the onboarding flow
// renders are stable; `create` stamps a real ISO time on the client. // (A3→A4 creates the first patient) and the E1 empty state. Archive is soft (isActive=false)
let store: Patient[] = [ // and never removes the row — the list simply hides inactive patients.
{ id: 2, fullName: 'زهرا محمدی', gender: 'female', createdAtUtc: '2026-05-12T08:30:00Z' }, let store: Patient[] = [];
{ id: 1, fullName: 'علی رضایی', gender: 'male', createdAtUtc: '2026-04-03T11:15:00Z' }, let nextId = 1;
];
let nextId = 3; 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 * In-memory mock behind the PatientsApi seam — the b3 endpoints are live but the wire shape
* `/patients` endpoints are merged. Mirrors the real shapes so swapping is a one-line * lacks relation/conditions (REQ-005), so this drives the UI until those land. Mirrors the
* change in constants.ts. * real shapes so swapping is a one-line change in constants.ts.
*/ */
export const patientsMockApi: PatientsApi = { export const patientsMockApi: PatientsApi = {
list: async (params): Promise<Paginated<Patient>> => { list: async (params?: PageParams): Promise<Paginated<Patient>> => {
await sleep(MOCK_LATENCY_MS); await sleep(MOCK_LATENCY_MS);
const active = store.filter((patient) => patient.isActive);
const page = params?.page ?? 1; const page = params?.page ?? 1;
const pageSize = params?.pageSize ?? 20; const pageSize = params?.pageSize ?? 50;
const start = (page - 1) * pageSize; const start = (page - 1) * pageSize;
return { return { items: active.slice(start, start + pageSize), total: active.length, page, pageSize };
items: store.slice(start, start + pageSize),
total: store.length,
page,
pageSize,
};
}, },
create: async (dto: CreatePatientDto): Promise<Patient> => { get: async (id: number): Promise<Patient> => {
await sleep(MOCK_LATENCY_MS); await sleep(MOCK_LATENCY_MS);
const patient: Patient = { const found = store.find((patient) => patient.id === id && patient.isActive);
id: nextId++, if (!found) throw new ApiError(404, 'Patient not found');
fullName: dto.fullName, return found;
gender: dto.gender, },
createdAtUtc: new Date().toISOString(),
}; create: async (input: CreatePatientInput): Promise<Patient> => {
await sleep(MOCK_LATENCY_MS);
const patient = build(nextId++, input, true);
store = [patient, ...store]; store = [patient, ...store];
return patient; return patient;
}, },
update: async (id: number, input: CreatePatientInput): Promise<Patient> => {
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<void> => {
await sleep(MOCK_LATENCY_MS);
store = store.map((patient) => (patient.id === id ? { ...patient, isActive: false } : patient));
},
}; };
+20 -2
View File
@@ -1,8 +1,26 @@
/** /**
* When true, the domain is served by the in-memory mock (apis/mockApi.ts) behind the * 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 * PatientsApi seam. The b3 `patients/*` endpoints are live, but the wire `PatientDto`
* component changes are needed (see dev/shared-working-context/reports/mocks-registry.md). * 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 USE_PATIENTS_MOCK = true;
export const PATIENTS_STALE_TIME = 60_000; 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;
@@ -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() });
},
});
}
@@ -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<Paginated<Patient>>({ queryKey: patientKeys.lists() });
previous.forEach(([key, data]) => {
if (!data) return;
queryClient.setQueryData<Paginated<Patient>>(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() });
},
});
}
@@ -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<Paginated<Patient>>({ queryKey: patientKeys.lists() }, (old) =>
old ? { ...old, items: [patient, ...old.items], total: old.total + 1 } : old,
);
queryClient.invalidateQueries({ queryKey: patientKeys.lists() });
},
});
}
@@ -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() });
},
});
}
+3 -1
View File
@@ -1,2 +1,4 @@
export { usePatients } from './hooks/usePatients'; 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';
+50 -16
View File
@@ -1,33 +1,67 @@
import type { PageParams, Paginated } from '@/lib/api/types'; import type { PageParams, Paginated } from '@/lib/api/types';
import { CONDITION_CODES, RELATION_CODES } from './constants';
/** /**
* Patients domain — the reference `services/{domain}` implementation every later * Patients domain — the care-recipient (patient) sub-domain, customer-scoped and
* frontend phase copies. Enums cross the wire as stable string codes (money-and-types.md); * tenancy-enforced server-side. Shapes mirror the b3 contract
* mirror them as string-literal unions and never hardcode a display label off the code. * (`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 * `relation` and `conditions` are **client-augmented**: they are not on the wire
* `dev/contracts/domains/<domain>.md` + `dev/contracts/openapi/swagger.v1.json`, mirror * `PatientDto` yet (filed as REQ-005 in requests/for-backend.md). The mock persists them;
* the wire exactly (field names + casing), and map enums to unions here. Until the real * the real client carries them through create/update so the just-edited card reflects the
* `/patients` endpoints exist, the shapes below are the agreed target the mock honours. * choice, but they are not round-tripped by the server until the backend adds the columns.
*/ */
export type Gender = 'male' | 'female'; 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; id: number;
fullName: string; displayName: string;
firstName: string;
lastName: string;
/** ISO date `YYYY-MM-DD`. */
birthDate: string;
gender: Gender; gender: Gender;
/** UTC ISO-8601; display via formatShamsiDate. */ bloodType: string | null;
createdAtUtc: string; /** Decrypted, owner-only free-text notes (E2 record viewer, deferred). */
initialMedicalNotes: string | null;
isActive: boolean;
} }
export interface CreatePatientDto { /** App-level patient = wire shape + the client-augmented relation/conditions. */
fullName: string; export interface Patient extends PatientDto {
gender: Gender; 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 { export interface PatientsApi {
list(params?: PageParams): Promise<Paginated<Patient>>; list(params?: PageParams): Promise<Paginated<Patient>>;
create(dto: CreatePatientDto): Promise<Patient>; get(id: number): Promise<Patient>;
create(input: CreatePatientInput): Promise<Patient>;
update(id: number, input: UpdatePatientInput): Promise<Patient>;
archive(id: number): Promise<void>;
} }
@@ -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<T>(promise: Promise<T>): Promise<T | null> {
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<ApiEnvelope<CustomerProfileDto>>(`${BASE}/customer_profiles/me`).then((env) =>
toCustomerProfile(unwrap(env)),
),
),
upsertCustomerProfile: async (input: UpsertCustomerProfileInput) =>
toCustomerProfile(
unwrap(
await clientFetch<ApiEnvelope<CustomerProfileDto>>(`${BASE}/customer_profiles/upsert`, {
method: 'POST',
body: JSON.stringify({
defaultEmergencyContactName: input.defaultEmergencyContactName,
defaultEmergencyContactPhone: input.defaultEmergencyContactPhone,
}),
}),
),
),
getNurseProfile: async () =>
orNull(
clientFetch<ApiEnvelope<NurseProfileDto>>(`${BASE}/nurse_profiles/me`).then((env) => toNurseProfile(unwrap(env))),
),
upsertNurseProfile: async (input: UpsertNurseProfileInput) =>
toNurseProfile(
unwrap(
await clientFetch<ApiEnvelope<NurseProfileDto>>(`${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<AvatarUploadResult> => {
throw new ApiError(501, 'Avatar upload has no backend route yet (REQ-006); served by the mock.');
},
};
@@ -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;
@@ -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<AvatarUploadResult> => {
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) };
},
};
+11
View File
@@ -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;
@@ -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,
});
}
@@ -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,
});
}
@@ -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),
});
}
@@ -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() });
},
});
}
@@ -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() });
},
});
}
+5
View File
@@ -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';
+9
View File
@@ -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,
};
+76
View File
@@ -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<CustomerProfile | null>;
upsertCustomerProfile(input: UpsertCustomerProfileInput): Promise<CustomerProfile>;
getNurseProfile(): Promise<NurseProfile | null>;
upsertNurseProfile(input: UpsertNurseProfileInput): Promise<NurseProfile>;
uploadAvatar(file: File): Promise<AvatarUploadResult>;
}
+4
View File
@@ -25,6 +25,8 @@
--bal-primary-light: #2f6b5e; --bal-primary-light: #2f6b5e;
--bal-primary-dark: #123029; --bal-primary-dark: #123029;
--bal-primary-contrast: #f3efe9; --bal-primary-contrast: #f3efe9;
/* Soft primary tint — selected chips, subtle info panels */
--bal-primary-soft: rgba(29, 74, 64, 0.10);
/* Secondary — terracotta */ /* Secondary — terracotta */
--bal-secondary: #d98c6a; --bal-secondary: #d98c6a;
@@ -61,6 +63,8 @@
--bal-primary-light: #8fd2c1; --bal-primary-light: #8fd2c1;
--bal-primary-dark: #3f8a78; --bal-primary-dark: #3f8a78;
--bal-primary-contrast: #06120f; --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 */ /* Secondary — warm terracotta-light */
--bal-secondary: #e6a98a; --bal-secondary: #e6a98a;
@@ -12,6 +12,33 @@ for awareness.
- **Requests filed:** frontend/requests/for-backend.md (yes/no) - **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 ## 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: - **Shipped:** `services/auth` rewritten for phone-OTP (types/keys/apis[client+mock+seam]/hooks:
`useRequestOtp`/`useVerifyOtp`/`useMe`/`useRefresh`/`useLogout`/`useSelectRole`/`useSessionRoleSync`) — `useRequestOtp`/`useVerifyOtp`/`useMe`/`useRefresh`/`useLogout`/`useSelectRole`/`useSessionRoleSync`) —
@@ -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 } }` - **Proposed shape:** `{ isSuccess: false, statusCode: 400, message: "…", code: "otp_locked", data: { retryAfterSeconds: 60 } }`
- **Status:** open - **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 ## 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 - **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. user who holds **both** `customer` and `nurse`, or whether the client should keep owning that choice.
@@ -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.
@@ -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 | | 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 | | `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 |
@@ -12,6 +12,7 @@
- An **admin** is provisioned internally with RBAC roles. - An **admin** is provisioned internally with RBAC roles.
- Each successful login creates a refresh-token session that can be revoked (logout, stolen-token detection). - 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 (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 ## (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). - Phone-OTP is the dominant Iranian login norm and is also the anchor for **Shahkar** SIM↔national-ID binding (Section 2).