From b638e25a0e5cbf9a41db607d2d46779dc264e227 Mon Sep 17 00:00:00 2001 From: hamid Date: Sun, 19 Jul 2026 15:14:44 +0330 Subject: [PATCH] ui phase 9 --- client/.env.sample | 8 +- client/CLAUDE.md | 20 +- client/messages/en.json | 101 +- client/messages/fa.json | 105 ++- client/package-lock.json | 25 + client/package.json | 2 + .../(customer)/addresses/page.tsx | 108 ++- .../(customer)/patients/[id]/record/page.tsx | 880 +++++++++++++----- .../(customer)/patients/page.tsx | 108 +-- .../(customer)/profile/page.tsx | 438 ++++++--- .../GenderToggle/GenderToggle.test.tsx | 5 + .../components/GenderToggle/GenderToggle.tsx | 1 + .../PatientCard/PatientCard.test.tsx | 39 + .../components/PatientCard/PatientCard.tsx | 55 +- .../PatientForm/PatientForm.test.tsx | 31 +- .../components/PatientForm/PatientForm.tsx | 104 ++- .../PatientHeader/PatientHeader.test.tsx | 5 + .../PatientHeader/PatientHeader.tsx | 54 +- .../ProfileSummary/ProfileSummary.test.tsx | 11 + .../ProfileSummary/ProfileSummary.tsx | 15 +- .../RelationSelect/RelationSelect.test.tsx | 8 + .../RelationSelect/RelationSelect.tsx | 6 +- .../VisitNoteCard/VisitNoteCard.test.tsx | 34 + .../VisitNoteCard/VisitNoteCard.tsx | 73 +- .../FormDialogShell/FormDialogShell.test.tsx | 65 ++ .../FormDialogShell/FormDialogShell.tsx | 98 ++ .../common/FormDialogShell/index.tsx | 4 + .../InitialsAvatar/InitialsAvatar.test.tsx | 37 + .../common/InitialsAvatar/InitialsAvatar.tsx | 60 ++ .../common/InitialsAvatar/index.tsx | 4 + client/src/components/common/index.tsx | 6 + .../components/geography/AddressCard.test.tsx | 18 + .../src/components/geography/AddressCard.tsx | 21 +- .../src/components/geography/AddressForm.tsx | 32 +- .../geography/AddressMapPicker.test.tsx | 5 +- .../components/geography/AddressMapPicker.tsx | 57 +- client/src/components/geography/NeshanMap.tsx | 232 +++++ client/src/config.ts | 8 + client/src/constants/routes.ts | 3 + client/src/services/geography/constants.ts | 11 + client/src/services/geography/neshan.ts | 65 ++ .../services/patientRecords/apis/mockApi.ts | 26 +- client/src/services/patientRecords/types.ts | 31 +- client/src/theme/tokens.css | 52 ++ client/src/utils/date.ts | 9 + .../frontend/requests/for-backend.md | 38 + .../reports/ui-phase-9-report.md | 176 ++++ 47 files changed, 2628 insertions(+), 666 deletions(-) create mode 100644 client/src/components/common/FormDialogShell/FormDialogShell.test.tsx create mode 100644 client/src/components/common/FormDialogShell/FormDialogShell.tsx create mode 100644 client/src/components/common/FormDialogShell/index.tsx create mode 100644 client/src/components/common/InitialsAvatar/InitialsAvatar.test.tsx create mode 100644 client/src/components/common/InitialsAvatar/InitialsAvatar.tsx create mode 100644 client/src/components/common/InitialsAvatar/index.tsx create mode 100644 client/src/components/geography/NeshanMap.tsx create mode 100644 client/src/services/geography/neshan.ts create mode 100644 dev/shared-working-context/reports/ui-phase-9-report.md diff --git a/client/.env.sample b/client/.env.sample index 0db0a4c..e962339 100644 --- a/client/.env.sample +++ b/client/.env.sample @@ -19,4 +19,10 @@ NEXT_PUBLIC_PUBLIC_URL = http://localhost:3000 # API/Backend basic URL (the Baya server) NEXT_PUBLIC_API_URL = https://localhost:5002 # NEXT_PUBLIC_API_URL = https://dev-api.domain.com -# NEXT_PUBLIC_API_URL = https://api.domain.com \ No newline at end of file +# NEXT_PUBLIC_API_URL = https://api.domain.com + +# Neshan **web** key (client-embeddable maps/search/reverse-geocode) — get one from +# https://platform.neshan.org (a separate key from the server's NeshanGeocoder key, which lives in +# server appsettings/user-secrets, never here). Leave unset to keep the address map-pin picker's +# bounded-canvas grid fallback (dev/CI/jsdom all work without a key). +# NEXT_PUBLIC_NESHAN_KEY = your-neshan-web-key \ No newline at end of file diff --git a/client/CLAUDE.md b/client/CLAUDE.md index c042207..47cc4e4 100644 --- a/client/CLAUDE.md +++ b/client/CLAUDE.md @@ -154,11 +154,11 @@ client/ │ │ │ │ ├── ScheduleStep.tsx # D4 تایید طرح و قرارداد — served repayment rows (InstallmentScheduleRow) + ownership note + contract-consent gate → useIssueBnplToken handoff │ │ │ │ ├── gateway/page.tsx # dev provider-handoff harness (TEST HARNESS; mock redirectUrl points here) → return; env-gated `notFound()` outside `development` (ui-phase-6 — was reachable in production builds) │ │ │ │ └── return/page.tsx # settle (useAcceptBnplSchedule) → invalidate → reused confirmation (?method=bnpl) / retry / card; the invalid-link CTA label now matches its destination (ui-phase-6 fix) - │ │ │ ├── patients/page.tsx # /patients — E1 list/CRUD (add/edit dialog reusing PatientForm, soft-archive); tapping a PatientCard opens the E2 record (f13) - │ │ │ ├── patients/[id]/record/page.tsx # /patients/[id]/record — f13 E2 care-record viewer (b14): reused PatientHeader + ownership banner + 4 tabs (داروها/روتین/سوابق/وظایف). Family-owned & patient-scoped — customer edits medications/routine/tasks (useUpdateCareRecord); سوابق = read-only nurse visit-note history (VisitNoteCard); access-denied is a first-class non-leaking state gated BEFORE any clinical fetch (services/patientRecords) - │ │ │ ├── addresses/page.tsx # /addresses — F3 address book (cascading region dropdowns + map-pin picker, set-primary) + │ │ │ ├── patients/page.tsx # /patients — the E1 «حلقهٔ مراقبت» (care-circle) list/CRUD, ui-phase-9 redesign: copy-level rename from «بیماران» (route/service/i18n key names unchanged), `FormDialogShell` (full-screen-on-mobile) replacing the `maxWidth="sm"` dialog, `PatientForm`'s `onDirtyChange` wired to its discard-confirm; error/empty/loading states unchanged (already fixed pre-phase-9); tapping a `PatientCard` (now a pressable surface with a chevron, not an invisible button) opens the E2 record (f13) + │ │ │ ├── patients/[id]/record/page.tsx # /patients/[id]/record — f13 E2 care-record viewer (b14), ui-phase-9 rebuild: reused PatientHeader (now with an avatar) + ownership banner + 4 tabs (داروها/روتین/سوابق/وظایف). The whole-list free-text edit mode is gone — each medication/routine/task row opens a per-item responsive sheet (`Drawer anchor="bottom"` mobile / `Dialog` desktop, its own dirty-gated discard-confirm) with structured dose amount+unit/frequency-preset+time-of-day-chip fields (REQ-027 addendum); سوابق (read-only nurse visit-note history, VisitNoteCard) is now a Shamsi month-grouped timeline with a booking link + task-done summary when derivable; access-denied is a first-class non-leaking state gated BEFORE any clinical fetch (services/patientRecords) — preserved verbatim + │ │ │ ├── addresses/page.tsx # /addresses — F3 address book, ui-phase-9 pass: `isError`→`ErrorState` (was silently absent), `FormDialogShell` replacing the cramped dialog, `AddressCard`'s new pin-quality cue (`hasPin`/pin_set/pin_missing) │ │ │ ├── wallet/ # /wallet — ui-phase-6 rebuilt into the customer money hub (page.tsx → WalletScreen.tsx: 4 Tabs, one shared `CONTENT_MAX_WIDTH`, no local width override): «پرداخت‌ها» (WalletPaymentHistory — card `usePaymentHistory` (REQ-047) + BNPL down-payment rows merged via the co-located `useWalletHistoryRows`), «اقساط» (WalletInstallments — the unchanged f11 D5 provider-reported outstanding balance + due list + early-pay hand-off, now section-only, no own heading/width), «استردادها» (WalletRefunds — `useMyRefunds`, REQ-048, one RefundStatusCard per refund), «رسیدها» (WalletReceipts — client-derived invoice deep-links off the same merged history rows, no endpoint) - │ │ │ ├── profile/page.tsx # /profile — customer profile + emergency contact (no national-ID) + │ │ │ ├── profile/page.tsx # /profile — ui-phase-9 rebuild into the customer's account hub (still the single route — sub-sections are `FormDialogShell` sheets, not sub-routes): `ProfileSummary` identity header (avatar/initials + name + server-masked phone), grouped tappable rows (اطلاعات شخصی/نشانی‌ها/زبان/اعلان‌ها/پشتیبانی/خروج — the زبان row owns the server-stored `preferredLanguage` and hosts the phase-2 `LocaleSwitcher`, never a second locale mechanism), an emergency-contact status card (tel:-only link when complete, a warm nudge otherwise), `ActorSwitcher` preserved; sign-out goes through `useLogout()` only. No national-ID. │ │ │ ├── support/tickets/ # /support/tickets — f14 "My Tickets" inbox (TicketInboxScreen role="customer") ↔ support/tickets/[id]/page.tsx thread (TicketThreadScreen); thin role-passing wrappers over @/components/messaging │ │ │ └── notifications/page.tsx # /notifications — f14 notification center (NotificationCenter role="customer"); the TopBar bell deep-links here │ │ ├── nurse/ # Nurse app (/nurse/…) — sidebar shell @@ -242,6 +242,8 @@ client/ │ │ ├── StickyActionBar/ # ui-phase-4 — a `position:sticky` bottom-pinned action-bar shell for a scrolling screen's primary CTA (C1's live-count CTA, C3's booking CTA); composes with the shell's existing BottomBar safe-area padding rather than reimplementing `env(safe-area-inset-bottom)` │ │ ├── LocaleSwitcher/ # ui-2 fa/en toggle preserving the current route (`router.replace(pathname, {locale})` via `@/i18n/navigation`); sidebar footers, the customer profile hub, the public shell (tested) │ │ ├── Pager/ # ui-phase-7 — the shared prev/next "page X of Y" control (common namespace i18n) replacing the near-identical inline pagers hand-rolled per list screen (nurse inbox tabs, payout history/earnings) (tested) + │ │ ├── InitialsAvatar/ # ui-phase-9 — warm auto-colored initials for a person with no photo: deterministic name-hash → one of 6 `--bal-avatar-*` token pairs (tokens.css, both scheme blocks); `aria-hidden` (decorative next to a visible name); used by `PatientHeader` and (via `ProfileSummary`'s new `initialsFallback` prop) the customer account hub (tested) + │ │ ├── FormDialogShell/ # ui-phase-9 — full-screen-below-`sm` form dialog (app-bar header + close) with a dirty-gated discard-confirm on close/backdrop/escape; the shared primitive behind the patient/address add-edit dialogs (the hosted form reports `dirty` via an `onDirtyChange` prop) (tested) │ │ └── index.tsx # barrel — keep next-intl-importing primitives (Money) below the presentational ones so the poisoning risk stays visible in review │ ├── PlaceholderScreen/ # Empty-state scaffold for not-yet-built screens │ ├── OtpInput/ # OTP code input (auto-advance, paste, RTL-safe) @@ -251,8 +253,8 @@ client/ │ ├── GenderToggle/ # Required male/female toggle (never defaulted) — drives same-gender matching; ui-phase-4 added an opt-in `allowAny` mode (discriminated-union props) adding a third «فرقی ندارد» option for C1's search facet — the default booking-context contract is unchanged │ ├── 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 (composes the shared PatientHeader) + edit/archive actions + optional onOpen tap-to-open (→ f13 E2 record) + │ ├── PatientForm/ # A4 patient form (first/last name, age/gender/conditions/relation) — reused create+edit; ui-phase-9 split the single full-name field into first/last (lastName falls back to firstName when blank — the wire requires it) and added an `onDirtyChange` prop for `FormDialogShell`'s discard-confirm + │ ├── PatientCard/ # E1 care-circle summary card (composes the shared PatientHeader, now with its avatar) + edit/archive actions; ui-phase-9 replaced the invisible tap target with a pressable `ButtonBase` surface (hover/press + a trailing chevron) and added an optional `lastVisitLabel` teaser (never populated without a `patientId` on the cached bookings list — REQ-057) │ ├── BankStatusPanel/ # Nurse bank-account ownership state (pending/verified/mismatch), masked IBAN │ ├── CategoryTile/ # f4 tappable service-category tile (icon+label; `selected` state for the builder) — Home grid + builder step 1 (tested) │ ├── PriceDisplay/ # f4 price renderer: money-util Toman + i18n unit label + unit-aware estimated total (never a total from price alone) (tested) @@ -282,10 +284,10 @@ client/ │ ├── RatingInput/ # f13 1–5 star input/display (custom, on AppIcon "star"; filled=var(--bal-warning), empty=var(--bal-divider)); interactive=radiogroup of radios, readOnly=static role="img"; used by the review form + review/my-review display (tested) │ ├── ReviewTagSelector/ # f13 multi-select review-tag chip group (selected=MUI palette primary, unselected=outlined); i18n-free (caller passes labelFor(code)); codes stay keyed off the stable vocabulary, never off the wire (tested) │ ├── VisitNoteCard/ # f13 one read-only nurse visit note: nurse name + Shamsi date + body + done/not-done task-result chips; presentational (caller formats the date); reused by the E2 سوابق tab + the nurse continuity view (tested) - │ ├── PatientHeader/ # f13 patient identity block (name + relation chip + "age · gender" meta + condition chips) extracted from PatientCard so the E1 card and the E2 record viewer share one header; tolerates null relation / empty conditions (tested) - │ ├── ProfileSummary/ # ui-2 the one identity card for chrome: avatar+name+masked phone+role label+optional TrustBadge, vertical (nurse sidebar) or `compact` horizontal chip (admin/partner TopBar); presentational — callers source data from useMe/profiles; replaces the starter UserInfo (tested) + │ ├── PatientHeader/ # f13 patient identity block (avatar + name + relation chip + "age · gender" meta + condition chips) extracted from PatientCard so the E1 card and the E2 record viewer share one header; tolerates null relation / empty conditions; ui-phase-9 added an `InitialsAvatar` slot (tested) + │ ├── ProfileSummary/ # ui-2 the one identity card for chrome: avatar+name+masked phone+role label+optional TrustBadge, vertical (nurse sidebar) or `compact` horizontal chip (admin/partner TopBar); presentational — callers source data from useMe/profiles; replaces the starter UserInfo; ui-phase-9 added `initialsFallback` (renders `InitialsAvatar` instead of MUI's generic silhouette when there's no `avatarUrl`) — the customer account hub's identity header (tested) │ ├── booking/ # f8 post-payment engagement composites (import from @/components/booking). BookingDetailView (both-roles smart container; ui-phase-5 hero: next-upcoming-session headline off the embedded sessions, a nurse-identity row, a client-only `.ics` add-to-calendar download (ics.ts, no backend seam), an EVV "پرستار در محل است" presence headline while checked in, role-conditioned EVV+gated care; ui-phase-7 added a standalone AddressCard below the hero — a `geo:`/Neshan-web map deep-link when the frozen snapshot carries lat/lng, a quiet nurse-only fallback note otherwise (REQ-051) — and a nurse-only in-visit «در حال ویزیت» banner promoting the check-out action), BookingStatusTimeline (server-truth 7-status timeline over the shared vertical StatusTimeline — the ui-phase-1 swap off StepperHeader), SessionList→SessionCard (per-session schedule/status/EVV CTA; ui-phase-5 aligned its card shell to SurfaceCard; ui-phase-7 made the EVV CTA the full-width hero action + an optional `serviceLabel` line, REQ-052), EvvStatusBanner (advisory in/out-of-range/no-gps), CareInstructionsCard (decrypted clinical read), CheckOutConfirmButton (ui-phase-7 — the shared check-out action + lightweight confirm dialog, used by both SessionCard and BookingDetailView's in-visit banner so "check-out ends the visit and starts the payout clock" always gets one confirm step), BookingMoneySummary (gross/commission/payout display-only); useEvvController (GPS-capture + check-in/out orchestration), format.ts + statusKind.ts + ics.ts helpers (kept internal — not in the barrel). Each composite tested; the BookingDetailView test proves the customer never fires the care query (two-stage-disclosure gate) - │ ├── geography/ # F3 geo composites: CascadingRegionSelect, AddressMapPicker (map-pin stand-in), AddressForm, AddressCard (each tested) + │ ├── geography/ # F3 geo composites: CascadingRegionSelect, AddressForm, AddressCard (ui-phase-9 added a pin-quality cue — hasPin/pinSetLabel/pinMissingLabel), and the map-pin picker boundary — `AddressMapPicker` now branches on `NESHAN_WEB_KEY` (`@/config`): real Neshan tiles via `NeshanMap` (Leaflet, dynamically imported `ssr:false`; search box + locate-me + draggable pin + reverse-geocoded preview via `services/geography/neshan.ts`'s direct third-party fetch client) when set, else the original bounded-canvas grid stand-in (kept, not deleted, for dev/CI/jsdom) — `{ latitude, longitude }` in/out is identical either way so `AddressForm` never changed (each tested; `NeshanMap` itself isn't unit-tested — jsdom+Leaflet integration — and is unreachable in tests since `NEXT_PUBLIC_NESHAN_KEY` is unset in CI) │ ├── messaging/ # f14 tickets composites (import from @/components/messaging). Screens shared by the customer+nurse pages (role decides chrome): TicketInboxScreen, TicketThreadScreen (+ TicketMessageList), ContactSupportDialog (new-ticket → shows referenceCode), MessageComposer (optimistic send, draft-preserving), BookingSupportEntry (page-local glue on f8 booking detail — reuses the cached booking + care query, no refetch). Pure/tested: MessageBubble (mine/theirs, RTL-mirrored, never any internal-note styling), TicketListCard (prominent referenceCode + unread indicator + null-safe link), EmergencyBanner (post-confirmation tel: playbook, no VoIP seam). Helpers: statusKind.ts, authorLabel.ts │ ├── notifications/ # f14 notification composites (import from @/components/notifications). NotificationBell (chrome container — subscribes to the polling count so only it re-renders) → NotificationBellView (pure, tested), NotificationRow (pure, tested: unread emphasis + server title/body), NotificationCenter (shared page body: unread-first, mark-read-on-open + mark-all, deep-links via notificationDeepLink). Helper: notificationIcon.ts │ └── auth/ # Auth-flow composites: LoginFlow, PhoneStep, OtpStep, RoleRouter, SelectRole, AuthCard, BrandMark, AuthSplash, RoleGuard (role-aware shell guard, tested), AuthAccountError (/me-failed recovery), useCountdown, useWebOtp (ui-phase-3 WebOTP autofill seam), AuthIllustration + TrustBullets (ui-phase-3 CSS/SVG login-hero treatment) diff --git a/client/messages/en.json b/client/messages/en.json index 53d3e8a..5dfcb7d 100644 --- a/client/messages/en.json +++ b/client/messages/en.json @@ -2,7 +2,7 @@ "nav": { "home": "Home", "bookings": "Bookings", - "patients": "Patients", + "patients": "Care Circle", "wallet": "Wallet", "profile": "Profile", "bank": "Bank account", @@ -66,7 +66,10 @@ "switch_locale": "Switch to {locale}", "page_prev": "Previous", "page_next": "Next", - "page_indicator": "Page {page} of {total}" + "page_indicator": "Page {page} of {total}", + "discard_title": "Discard changes?", + "discard_body": "Your unsaved changes will be lost.", + "discard_confirm": "Discard" }, "shell": { "customer_app": "Family app", @@ -86,13 +89,13 @@ "categories_title": "Services", "categories_error": "Couldn't load services.", "categories_empty": "No services have been set up yet.", - "nudge_patient_title": "Complete the patient record", + "nudge_patient_title": "Complete the care record", "nudge_patient_body": "Add conditions, medications and routine so nurses arrive prepared.", - "nudge_patient_cta": "Open patients", + "nudge_patient_cta": "View care circle", "nudge_profile_title": "Complete your profile", "nudge_profile_body": "Add an emergency contact to speed up bookings.", "nudge_profile_cta": "Go to profile", - "patients_error": "Couldn't load your patients.", + "patients_error": "Couldn't load your care circle.", "trust_escrow": "Secure escrow payment", "trust_verified_nurses": "Verified nurses", "trust_support": "Support", @@ -109,8 +112,9 @@ "relation_self": "Myself", "patient_title": "Register the patient", "patient_subtitle": "The person receiving care — not necessarily the account holder.", - "name_label": "Full name", - "name_required": "Enter the patient's name", + "first_name_label": "First name", + "last_name_label": "Last name", + "name_required": "Enter the first name", "age_label": "Age", "age_invalid": "Enter a valid age", "gender_label": "Gender", @@ -132,45 +136,51 @@ "welcome_cta": "Let's start" }, "patients": { - "title": "Patients", + "title": "Care Circle", "subtitle": "The people you arrange care for.", - "add": "Add patient", - "add_title": "Add patient", - "edit_title": "Edit patient", + "add": "Add person", + "add_title": "Add person", + "edit_title": "Edit details", "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.", + "empty_title": "No one added yet", + "empty_body": "Add your first person to get started.", + "archive_title": "Archive?", + "archive_body": "This person is removed from your list, but past booking records are kept.", "archive_confirm": "Archive", - "archived": "Patient archived", + "archived": "Moved to archive", "age_years": "{age} yrs", "conditions_none": "No recorded conditions", - "unavailable": "This patient isn't available to you.", + "unavailable": "This item isn't available to you.", "open_record": "Open {name}'s record", - "load_error": "Couldn't load your patients." + "load_error": "Couldn't load your care circle." }, "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", + "language_hint": "The language your messages and notifications are sent in.", "emergency_section": "Emergency contact", - "emergency_hint": "Who we call in an emergency.", + "emergency_hint": "The nurse needs to know who to 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.", + "saved": "Saved", "load_error": "Couldn't load your profile.", "app_language": "App language", - "sign_out": "Sign out" + "app_language_hint": "Choose the app's display language.", + "sign_out": "Sign out", + "sign_out_confirm_title": "Sign out?", + "sign_out_confirm_body": "You'll need to verify your phone number again to sign back in.", + "row_personal": "Personal info", + "row_addresses": "Addresses", + "row_language": "Language", + "row_notifications": "Notifications", + "row_support": "Support", + "edit": "Edit" }, "nurseProfile": { "title": "Nurse profile", @@ -295,6 +305,12 @@ "map_required": "Drop a pin on the map", "map_lat": "Lat", "map_lng": "Lng", + "map_search_placeholder": "Search address…", + "map_locate_me": "Locate me", + "map_pin_prefix": "Pin at:", + "map_resolving": "Finding address…", + "pin_set": "Pin set", + "pin_missing": "No pin", "manage_title": "Your addresses", "manage_body": "Manage the places nurses visit.", "manage_cta": "Manage addresses" @@ -1317,9 +1333,9 @@ "tab_tasks": "Tasks", "access_denied_title": "No access", "access_denied_body": "You don't have access to this record.", - "not_found_title": "Patient not found", - "not_found_body": "This patient could not be found.", - "back_to_patients": "Back to patients", + "not_found_title": "Person not found", + "not_found_body": "This person could not be found.", + "back_to_patients": "Back to care circle", "load_error": "Couldn't load the record.", "saved": "Record updated", "save_error": "Couldn't save your changes. Please try again.", @@ -1330,15 +1346,40 @@ "routine_empty": "No routine recorded.", "history_empty": "No notes recorded.", "tasks_empty": "No tasks recorded.", + "add_medication": "Add medication", + "edit_medication": "Edit medication", + "add_routine": "Add routine item", + "edit_routine": "Edit routine item", + "add_task": "Add task", + "edit_task": "Edit task", "med_name": "Medication", - "med_dosage": "Dosage", + "med_dose_amount": "Dose amount", + "med_dose_unit": "Dose unit", + "med_dose_unit_none": "No unit", "med_frequency": "Frequency", + "med_frequency_text": "Frequency notes", "med_timing": "Timing / notes", + "time_of_day": "Time of day", + "dose_unit_tablet": "Tablet", + "dose_unit_capsule": "Capsule", + "dose_unit_drop": "Drop", + "dose_unit_cc": "cc", + "dose_unit_unit": "Unit", + "frequency_once_daily": "Once daily", + "frequency_twice_daily": "Twice daily", + "frequency_three_times_daily": "3 times daily", + "frequency_every_8_hours": "Every 8 hours", + "frequency_as_needed": "As needed", + "time_morning": "Morning", + "time_noon": "Noon", + "time_evening": "Evening", + "time_night": "Night", "routine_label": "Routine", - "routine_time": "Time of day", "routine_note": "Note", "task_label": "Task", "task_done": "Done", + "task_summary": "{done} of {total} done", + "view_booking": "View booking", "author_fallback": "Nurse", "prev": "Previous", "next": "Next", diff --git a/client/messages/fa.json b/client/messages/fa.json index 60139cc..6e21720 100644 --- a/client/messages/fa.json +++ b/client/messages/fa.json @@ -2,7 +2,7 @@ "nav": { "home": "خانه", "bookings": "رزروها", - "patients": "بیماران", + "patients": "حلقهٔ مراقبت", "wallet": "کیف‌پول", "profile": "پروفایل", "bank": "حساب بانکی", @@ -66,7 +66,10 @@ "switch_locale": "تغییر به {locale}", "page_prev": "قبلی", "page_next": "بعدی", - "page_indicator": "صفحه {page} از {total}" + "page_indicator": "صفحه {page} از {total}", + "discard_title": "تغییرات نادیده گرفته شود؟", + "discard_body": "تغییراتی که ذخیره نشده از دست می‌رود.", + "discard_confirm": "بله، نادیده بگیر" }, "shell": { "customer_app": "اپلیکیشن خانواده", @@ -86,13 +89,13 @@ "categories_title": "خدمات", "categories_error": "خدمات بارگذاری نشد.", "categories_empty": "هنوز خدمتی تعریف نشده است.", - "nudge_patient_title": "تکمیل پروندهٔ بیمار", + "nudge_patient_title": "تکمیل پروندهٔ مراقبت", "nudge_patient_body": "وضعیت‌ها، داروها و روتین را اضافه کنید تا پرستار آماده حاضر شود.", - "nudge_patient_cta": "مشاهدهٔ بیماران", + "nudge_patient_cta": "مشاهدهٔ حلقهٔ مراقبت", "nudge_profile_title": "تکمیل پروفایل", "nudge_profile_body": "برای رزرو سریع‌تر، تماس اضطراری را اضافه کنید.", "nudge_profile_cta": "رفتن به پروفایل", - "patients_error": "بیماران شما بارگذاری نشد.", + "patients_error": "حلقهٔ مراقبت شما بارگذاری نشد.", "trust_escrow": "پرداخت امن امانی", "trust_verified_nurses": "پرستاران تاییدشده", "trust_support": "پشتیبانی", @@ -109,8 +112,9 @@ "relation_self": "خودم", "patient_title": "ثبت بیمار", "patient_subtitle": "فردی که مراقبت می‌شود — لزوماً صاحب حساب نیست.", - "name_label": "نام و نام خانوادگی", - "name_required": "نام بیمار را وارد کنید", + "first_name_label": "نام", + "last_name_label": "نام خانوادگی", + "name_required": "نام را وارد کنید", "age_label": "سن", "age_invalid": "سن معتبر وارد کنید", "gender_label": "جنسیت", @@ -132,45 +136,51 @@ "welcome_cta": "شروع می‌کنیم" }, "patients": { - "title": "بیماران", - "subtitle": "کسانی که برایشان مراقبت می‌گیرید.", - "add": "افزودن بیمار", - "add_title": "افزودن بیمار", - "edit_title": "ویرایش بیمار", + "title": "حلقهٔ مراقبت", + "subtitle": "افرادی که برایشان مراقبت می‌گیرید.", + "add": "افزودن فرد", + "add_title": "افزودن فرد", + "edit_title": "ویرایش اطلاعات", "edit": "ویرایش", "archive": "آرشیو", - "empty_title": "هنوز بیماری ثبت نشده", - "empty_body": "برای شروع، اولین بیمار را اضافه کنید.", - "archive_title": "آرشیو بیمار؟", - "archive_body": "این بیمار از فهرست شما حذف می‌شود اما سوابق رزروهای گذشته حفظ می‌ماند.", + "empty_title": "هنوز کسی اضافه نشده", + "empty_body": "برای شروع، اولین فرد را اضافه کنید.", + "archive_title": "آرشیو شود؟", + "archive_body": "این فرد از فهرست شما حذف می‌شود اما سوابق رزروهای گذشته حفظ می‌ماند.", "archive_confirm": "آرشیو", - "archived": "بیمار آرشیو شد", + "archived": "به آرشیو منتقل شد", "age_years": "{age} سال", "conditions_none": "وضعیت خاصی ثبت نشده", - "unavailable": "این بیمار در دسترس شما نیست.", + "unavailable": "این مورد در دسترس شما نیست.", "open_record": "مشاهدهٔ پروندهٔ {name}", - "load_error": "بیماران شما بارگذاری نشد." + "load_error": "حلقهٔ مراقبت شما بارگذاری نشد." }, "profile": { "title": "پروفایل", - "subtitle": "اطلاعات شما و تماس اضطراری.", "first_name": "نام", "last_name": "نام خانوادگی", "language": "زبان ترجیحی", "language_fa": "فارسی", "language_en": "انگلیسی", + "language_hint": "زبانی که پیام‌ها و اعلان‌های شما با آن ارسال می‌شود.", "emergency_section": "تماس اضطراری", - "emergency_hint": "شماره‌ای که در مواقع ضروری با آن تماس می‌گیریم.", + "emergency_hint": "پرستار باید بداند در شرایط اضطراری با چه کسی تماس بگیرد.", "emergency_name": "نام تماس اضطراری", "emergency_phone": "شماره تماس اضطراری", "emergency_phone_invalid": "شماره موبایل معتبر وارد کنید", - "save": "ذخیره", - "saved": "پروفایل ذخیره شد", - "completion_done": "پروفایل شما کامل است.", - "completion_todo": "برای رزرو سریع‌تر، پروفایل خود را کامل کنید.", + "saved": "ذخیره شد", "load_error": "پروفایل شما بارگذاری نشد.", "app_language": "زبان برنامه", - "sign_out": "خروج از حساب" + "app_language_hint": "زبان نمایش برنامه را انتخاب کنید.", + "sign_out": "خروج از حساب", + "sign_out_confirm_title": "خروج از حساب؟", + "sign_out_confirm_body": "برای ورود دوباره باید شمارهٔ موبایل خود را دوباره تایید کنید.", + "row_personal": "اطلاعات شخصی", + "row_addresses": "نشانی‌ها", + "row_language": "زبان", + "row_notifications": "اعلان‌ها", + "row_support": "پشتیبانی", + "edit": "ویرایش" }, "nurseProfile": { "title": "پروفایل پرستار", @@ -287,7 +297,7 @@ "title_placeholder": "مثلاً خانه، محل کار", "title_required": "برای این آدرس یک عنوان وارد کنید", "line_label": "نشانی کامل", - "line_hint": "پلاک، خیابان، واحد — جزئیاتی که پرستار برای یافتن در نیاز دارد.", + "line_hint": "پلاک، خیابان، واحد — جزئیاتی که پرستار برای یافتن درِ منزل نیاز دارد.", "line_required": "نشانی کامل را وارد کنید", "city_required": "شهر را انتخاب کنید", "set_primary_toggle": "به‌عنوان آدرس اصلی تنظیم شود", @@ -295,6 +305,12 @@ "map_required": "روی نقشه یک پین بگذارید", "map_lat": "عرض", "map_lng": "طول", + "map_search_placeholder": "جستجوی آدرس…", + "map_locate_me": "موقعیت من", + "map_pin_prefix": "پین روی:", + "map_resolving": "در حال یافتن آدرس…", + "pin_set": "پین ثبت شده", + "pin_missing": "پین ندارد", "manage_title": "آدرس‌های شما", "manage_body": "مکان‌هایی که پرستار به آن‌ها می‌رود را مدیریت کنید.", "manage_cta": "مدیریت آدرس‌ها" @@ -1317,9 +1333,9 @@ "tab_tasks": "وظایف", "access_denied_title": "دسترسی ندارید", "access_denied_body": "شما به این پرونده دسترسی ندارید.", - "not_found_title": "بیمار پیدا نشد", - "not_found_body": "این بیمار یافت نشد.", - "back_to_patients": "بازگشت به بیماران", + "not_found_title": "فرد پیدا نشد", + "not_found_body": "این فرد یافت نشد.", + "back_to_patients": "بازگشت به حلقهٔ مراقبت", "load_error": "بارگذاری پرونده ممکن نشد.", "saved": "پرونده به‌روزرسانی شد", "save_error": "ذخیرهٔ تغییرات ممکن نشد. دوباره تلاش کنید.", @@ -1330,15 +1346,40 @@ "routine_empty": "روتینی ثبت نشده است.", "history_empty": "یادداشتی ثبت نشده است.", "tasks_empty": "وظیفه‌ای ثبت نشده است.", + "add_medication": "افزودن دارو", + "edit_medication": "ویرایش دارو", + "add_routine": "افزودن روتین", + "edit_routine": "ویرایش روتین", + "add_task": "افزودن وظیفه", + "edit_task": "ویرایش وظیفه", "med_name": "دارو", - "med_dosage": "دوز", + "med_dose_amount": "مقدار دوز", + "med_dose_unit": "واحد دوز", + "med_dose_unit_none": "بدون واحد", "med_frequency": "دفعات مصرف", + "med_frequency_text": "توضیح دفعات مصرف", "med_timing": "زمان / توضیحات", + "time_of_day": "زمان روز", + "dose_unit_tablet": "قرص", + "dose_unit_capsule": "کپسول", + "dose_unit_drop": "قطره", + "dose_unit_cc": "سی‌سی", + "dose_unit_unit": "واحد", + "frequency_once_daily": "روزی ۱ بار", + "frequency_twice_daily": "روزی ۲ بار", + "frequency_three_times_daily": "روزی ۳ بار", + "frequency_every_8_hours": "هر ۸ ساعت", + "frequency_as_needed": "در صورت نیاز", + "time_morning": "صبح", + "time_noon": "ظهر", + "time_evening": "عصر", + "time_night": "شب", "routine_label": "روتین", - "routine_time": "زمان روز", "routine_note": "توضیح", "task_label": "وظیفه", "task_done": "انجام شد", + "task_summary": "{done} از {total} انجام شد", + "view_booking": "مشاهدهٔ رزرو", "author_fallback": "پرستار", "prev": "قبلی", "next": "بعدی", diff --git a/client/package-lock.json b/client/package-lock.json index 1b448fe..b816f45 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -22,6 +22,7 @@ "copy-to-clipboard": "latest", "jalaali-js": "^2.0.0", "js-cookie": "^3.0.8", + "leaflet": "^1.9.4", "next": "^16.2.9", "next-intl": "^4.13.0", "notistack": "^3.0.2", @@ -36,6 +37,7 @@ "@testing-library/user-event": "^14.6.1", "@types/jest": "^30.0.0", "@types/js-cookie": "^3.0.6", + "@types/leaflet": "^1.9.21", "@types/node": "^25.9.3", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", @@ -3361,6 +3363,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -3657,6 +3666,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/leaflet": { + "version": "1.9.21", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz", + "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/node": { "version": "25.9.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz", @@ -9128,6 +9147,12 @@ "node": ">=0.10" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", diff --git a/client/package.json b/client/package.json index 873704b..e6e4e59 100644 --- a/client/package.json +++ b/client/package.json @@ -30,6 +30,7 @@ "copy-to-clipboard": "latest", "jalaali-js": "^2.0.0", "js-cookie": "^3.0.8", + "leaflet": "^1.9.4", "next": "^16.2.9", "next-intl": "^4.13.0", "notistack": "^3.0.2", @@ -44,6 +45,7 @@ "@testing-library/user-event": "^14.6.1", "@types/jest": "^30.0.0", "@types/js-cookie": "^3.0.6", + "@types/leaflet": "^1.9.21", "@types/node": "^25.9.3", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", diff --git a/client/src/app/[locale]/(private-routes)/(customer)/addresses/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/addresses/page.tsx index 879a767..a161c6a 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/addresses/page.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/addresses/page.tsx @@ -2,8 +2,8 @@ import { useState } from 'react'; import { useLocale, useTranslations } from 'next-intl'; import { useSnackbar } from 'notistack'; -import { Box, Dialog, DialogActions, DialogContent, DialogTitle, Skeleton, Stack, Typography } from '@mui/material'; -import { AppButton, EmptyState } from '@/components'; +import { Box, Skeleton, Stack, Typography } from '@mui/material'; +import { AppButton, ConfirmDialog, EmptyState, ErrorState, FormDialogShell } from '@/components'; import { AddressCard, AddressForm } from '@/components/geography'; import { useAddresses, @@ -16,9 +16,9 @@ import type { CreateAddressInput, CustomerAddress } from '@/services/addresses/t /** * The customer address book — a cached, invalidate-on-mutation list of the customer's saved - * addresses with add/edit (the cascading dropdowns + map pin in a dialog), soft-delete (confirm), - * and set-primary (exactly one badge). Loading skeleton + empty state both handled. The chosen - * address later feeds the f7 booking request. + * addresses with add/edit (the cascading dropdowns + map pin in a full-screen-on-mobile dialog), + * soft-delete (confirm), and set-primary (exactly one badge). Loading skeleton, error (with + * retry), and empty states are all handled. The chosen address later feeds the f7 booking request. */ export default function AddressesPage() { const t = useTranslations('address'); @@ -26,13 +26,14 @@ export default function AddressesPage() { const locale = useLocale(); const { enqueueSnackbar } = useSnackbar(); - const { data, isLoading } = useAddresses(); + const { data, isLoading, isError, refetch } = useAddresses(); const createAddress = useCreateAddress(); const updateAddress = useUpdateAddress(); const deleteAddress = useDeleteAddress(); const setPrimary = useSetPrimaryAddress(); const [formOpen, setFormOpen] = useState(false); + const [formDirty, setFormDirty] = useState(false); const [editing, setEditing] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); @@ -78,7 +79,7 @@ export default function AddressesPage() { }; const addresses = data?.items ?? []; - const isEmpty = !isLoading && addresses.length === 0; + const isEmpty = !isLoading && !isError && addresses.length === 0; return ( @@ -91,7 +92,7 @@ export default function AddressesPage() { {t('subtitle')} - {!isEmpty ? ( + {!isEmpty && !isError ? ( {t('add')} @@ -104,6 +105,8 @@ export default function AddressesPage() { ))} + ) : isError ? ( + refetch()} /> ) : isEmpty ? ( openEdit(address)} onDelete={() => setDeleteTarget(address)} onSetPrimary={() => @@ -142,50 +148,50 @@ export default function AddressesPage() { )} - - {editing ? t('edit_title') : t('add_title')} - - - - - - + + + - setDeleteTarget(null)}> - {t('delete_title')} - - - {t('delete_body')} - - - - setDeleteTarget(null)}> - {tc('cancel')} - - - {t('delete_confirm')} - - - + setDeleteTarget(null)} + onConfirm={confirmDelete} + /> ); } diff --git a/client/src/app/[locale]/(private-routes)/(customer)/patients/[id]/record/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/patients/[id]/record/page.tsx index 83b5bfb..ce9b67f 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/patients/[id]/record/page.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/patients/[id]/record/page.tsx @@ -1,41 +1,55 @@ 'use client'; -import { useState } from 'react'; +import { FunctionComponent, ReactNode, useEffect, useState } from 'react'; import { useLocale, useTranslations } from 'next-intl'; import { useParams, useRouter } from 'next/navigation'; +import { useQueryClient } from '@tanstack/react-query'; import { useSnackbar } from 'notistack'; import { + Box, Checkbox, + Dialog, + Drawer, FormControlLabel, - IconButton, + MenuItem, Paper, Skeleton, Stack, Tab, Tabs, TextField, + ToggleButton, + ToggleButtonGroup, Typography, + useMediaQuery, } from '@mui/material'; -import { AppButton, AppIcon, EmptyState, PatientHeader, VisitNoteCard } from '@/components'; -import { ROUTES } from '@/constants'; -import { formatShamsiDate } from '@/utils'; +import { useTheme } from '@mui/material/styles'; +import { AppButton, AppIcon, ConfirmDialog, EmptyState, PatientHeader, VisitNoteCard } from '@/components'; +import { ROUTES, bookingDetailPath } from '@/constants'; +import { formatShamsiDate, formatShamsiMonthYear } from '@/utils'; +import { bookingKeys } from '@/services/bookings/keys'; import { usePatient } from '@/services/patients'; import { birthDateToAge } from '@/services/patients/age'; import { useRecordAccess, usePatientCareRecord, usePatientHistory, useUpdateCareRecord } from '@/services/patientRecords'; -import { CARE_RECORD_TABS } from '@/services/patientRecords/types'; +import { CARE_RECORD_TABS, DOSE_UNITS, FREQUENCY_PRESETS, TIME_OF_DAY_CODES } from '@/services/patientRecords/types'; import type { CareRecordTab, CareTask, + DoseUnit, FamilyCareRecord, + FrequencyPreset, Medication, RoutineItem, + TimeOfDayCode, + VisitNote, } from '@/services/patientRecords/types'; /** * E2 — Patient care-record viewer (پروندهٔ مراقبت). The **family-owned, patient-scoped** record with four - * tabs: داروها / روتین / سوابق / وظایف. The customer owns and edits medications/routine/tasks; the **سوابق** - * (nurse visit notes) are read-only to everyone. A persistent ownership banner states the record belongs to - * the family. The clinical-access check gates the whole screen (a `403` → a clear, non-leaking access-denied - * card — never partial clinical data). + * tabs: داروها / روتین / سوابق / وظایف. The customer owns and edits medications/routine/tasks via per-item + * bottom sheets (never a whole-list edit mode — switching tabs mid-edit loses nothing by construction); the + * **سوابق** (nurse visit notes) are read-only to everyone, grouped into a Shamsi month-by-month timeline. A + * persistent ownership banner states the record belongs to the family. The clinical-access check gates the + * whole screen (a `403` → a clear, non-leaking access-denied card — never partial clinical data). */ export default function PatientRecordPage() { const t = useTranslations('records'); @@ -157,15 +171,184 @@ function EditableTabs({ patientId, tab, canEdit }: { patientId: number; tab: Car }; if (tab === 'medications') { - return save({ medications: meds }, done)} />; + return ( + save({ medications: meds }, done)} + /> + ); } if (tab === 'routine') { - return save({ routine: items }, done)} />; + return ( + save({ routine: items }, done)} /> + ); } return save({ tasks }, done)} />; } +// ── The responsive per-item sheet: Drawer(bottom) on mobile, Dialog on desktop; dirty-gated close ───────── +function RecordItemSheet({ + open, + title, + dirty, + onClose, + children, +}: { + open: boolean; + title: string; + dirty: boolean; + onClose: () => void; + children: ReactNode; +}) { + const t = useTranslations('common'); + const theme = useTheme(); + const mobile = useMediaQuery(theme.breakpoints.down('sm')); + const [discardOpen, setDiscardOpen] = useState(false); + + const requestClose = () => { + if (dirty) setDiscardOpen(true); + else onClose(); + }; + + const body = ( + + + + {title} + + + + + + {children} + + ); + + return ( + <> + {mobile ? ( + + {body} + + ) : ( + + {body} + + )} + { + setDiscardOpen(false); + onClose(); + }} + onClose={() => setDiscardOpen(false)} + /> + + ); +} + +function SheetActions({ + onCancel, + onSave, + onDelete, + saving, + canSave, +}: { + onCancel: () => void; + onSave: () => void; + onDelete?: () => void; + saving: boolean; + canSave: boolean; +}) { + const t = useTranslations('records'); + const tc = useTranslations('common'); + return ( + + {onDelete ? ( + + {t('remove')} + + ) : null} + + + {tc('cancel')} + + + {saving ? tc('saving') : tc('save')} + + + + ); +} + +// A tappable row surface shared by the three editable tabs — mirrors PatientCard's press affordance. +function RowCard({ onOpen, children }: { onOpen?: () => void; children: ReactNode }) { + return ( + + {onOpen ? ( + + + {children} + + + + ) : ( + {children} + )} + + ); +} + +function TimeOfDayChipRow({ + value, + onChange, + disabled, +}: { + value: TimeOfDayCode[]; + onChange: (next: TimeOfDayCode[]) => void; + disabled?: boolean; +}) { + const t = useTranslations('records'); + return ( + onChange(next)} + disabled={disabled} + sx={{ flexWrap: 'wrap', gap: 1, '& .MuiToggleButtonGroup-grouped': { border: '1px solid', borderColor: 'divider !important', borderRadius: '999px !important', mx: 0 } }} + > + {TIME_OF_DAY_CODES.map((code) => ( + + {t(`time_${code}`)} + + ))} + + ); +} + // ── Medications ─────────────────────────────────────────────────────────────────────────────────────────── +function medicationSummary(m: Medication, t: ReturnType): string { + const dose = m.doseAmount ? `${m.doseAmount} ${m.doseUnit ? t(`dose_unit_${m.doseUnit}`) : ''}`.trim() : null; + const frequency = m.frequencyCode ? t(`frequency_${m.frequencyCode}`) : m.frequencyText; + return [dose, frequency].filter(Boolean).join(' · '); +} + function MedicationsTab({ data, canEdit, @@ -178,67 +361,193 @@ function MedicationsTab({ onSave: (medications: Medication[], onDone: () => void) => void; }) { const t = useTranslations('records'); - const [editing, setEditing] = useState(false); - const [draft, setDraft] = useState(data); + const [sheetItem, setSheetItem] = useState(null); + const [sheetDirty, setSheetDirty] = useState(false); + const close = () => setSheetItem(null); - const startEdit = () => { - setDraft(data.map((m) => ({ ...m }))); - setEditing(true); + const handleSave = (item: Medication) => { + const next = sheetItem === 'new' ? [...data, item] : data.map((m) => (m.id === item.id ? item : m)); + onSave(next, close); }; - const update = (id: string, patch: Partial) => setDraft((d) => d.map((m) => (m.id === id ? { ...m, ...patch } : m))); - const remove = (id: string) => setDraft((d) => d.filter((m) => m.id !== id)); - const add = () => setDraft((d) => [...d, { id: `new-${d.length}-${Date.now()}`, name: '', dosage: null, frequency: '', timingNote: null }]); - const canSave = draft.every((m) => m.name.trim().length > 0); - - if (!editing) { - return ( - - {data.map((m) => ( - - - - - {m.name} - {m.dosage ? ` — ${m.dosage}` : ''} - - - {(m.frequency || m.timingNote) && ( - - {[m.frequency, m.timingNote].filter(Boolean).join(' · ')} - - )} - - ))} - - ); - } + const handleDelete = (id: string) => onSave(data.filter((m) => m.id !== id), close); return ( - {draft.map((m) => ( - - - - update(m.id, { name: e.target.value })} size="small" fullWidth required /> - remove(m.id)} size="small"> - - - - - update(m.id, { dosage: e.target.value || null })} size="small" sx={{ flex: 1, minWidth: 120 }} /> - update(m.id, { frequency: e.target.value })} size="small" sx={{ flex: 1, minWidth: 120 }} /> - - update(m.id, { timingNote: e.target.value || null })} size="small" fullWidth /> - - - ))} - setEditing(false)} - onSave={() => onSave(draft, () => setEditing(false))} - saving={saving} - canSave={canSave} - /> + {data.length === 0 ? ( + + {t('medications_empty')} + + ) : ( + + {data.map((m) => ( + setSheetItem(m) : undefined}> + + + + {m.name} + + + {medicationSummary(m, t) ? ( + + {medicationSummary(m, t)} + + ) : null} + {m.timeOfDay.length > 0 ? ( + + {m.timeOfDay.map((code) => ( + + {t(`time_${code}`)} + + ))} + + ) : null} + + ))} + + )} + + {canEdit ? ( + setSheetItem('new')} sx={{ alignSelf: 'flex-start' }}> + {t('add_medication')} + + ) : null} + + + {sheetItem != null ? ( + handleDelete((sheetItem as Medication).id) : undefined} + onDirtyChange={setSheetDirty} + /> + ) : null} + + + ); +} + +function MedicationSheetBody({ + initial, + saving, + onCancel, + onSave, + onDelete, + onDirtyChange, +}: { + initial: Medication | null; + saving: boolean; + onCancel: () => void; + onSave: (item: Medication) => void; + onDelete?: () => void; + onDirtyChange: (dirty: boolean) => void; +}) { + const t = useTranslations('records'); + const [name, setName] = useState(initial?.name ?? ''); + const [doseAmount, setDoseAmount] = useState(initial?.doseAmount ?? ''); + const [doseUnit, setDoseUnit] = useState(initial?.doseUnit ?? ''); + const [frequencyCode, setFrequencyCode] = useState(initial?.frequencyCode ?? null); + const [frequencyText, setFrequencyText] = useState(initial?.frequencyText ?? ''); + const [timeOfDay, setTimeOfDay] = useState(initial?.timeOfDay ?? []); + const [timingNote, setTimingNote] = useState(initial?.timingNote ?? ''); + + useEffect(() => { + const dirty = + name !== (initial?.name ?? '') || + doseAmount !== (initial?.doseAmount ?? '') || + doseUnit !== (initial?.doseUnit ?? '') || + frequencyCode !== (initial?.frequencyCode ?? null) || + frequencyText !== (initial?.frequencyText ?? '') || + timingNote !== (initial?.timingNote ?? '') || + timeOfDay.length !== (initial?.timeOfDay ?? []).length || + timeOfDay.some((code) => !(initial?.timeOfDay ?? []).includes(code)); + onDirtyChange(dirty); + // eslint-disable-next-line react-hooks/exhaustive-deps -- `initial` is a fresh-mount snapshot (the sheet remounts per open), not reactive state. + }, [name, doseAmount, doseUnit, frequencyCode, frequencyText, timeOfDay, timingNote]); + + const canSave = name.trim().length > 0; + + const handleSave = () => { + onSave({ + id: initial?.id ?? `new-${Date.now()}`, + name: name.trim(), + doseAmount: doseAmount.trim() || null, + doseUnit: doseUnit || null, + frequencyCode, + frequencyText: frequencyCode ? null : frequencyText.trim() || null, + timeOfDay, + timingNote: timingNote.trim() || null, + }); + }; + + return ( + + setName(e.target.value)} fullWidth required autoFocus /> + + + setDoseAmount(e.target.value)} + sx={{ flex: 1 }} + /> + setDoseUnit(e.target.value as DoseUnit)} sx={{ flex: 1 }}> + {t('med_dose_unit_none')} + {DOSE_UNITS.map((unit) => ( + + {t(`dose_unit_${unit}`)} + + ))} + + + + + + {t('med_frequency')} + + + {FREQUENCY_PRESETS.map((preset) => ( + { + setFrequencyCode(frequencyCode === preset ? null : preset); + if (frequencyCode !== preset) setFrequencyText(''); + }} + sx={{ borderRadius: '999px' }} + > + {t(`frequency_${preset}`)} + + ))} + + {!frequencyCode ? ( + setFrequencyText(e.target.value)} + fullWidth + size="small" + /> + ) : null} + + + + + {t('time_of_day')} + + + + + setTimingNote(e.target.value)} fullWidth size="small" /> + + ); } @@ -256,60 +565,128 @@ function RoutineTab({ onSave: (routine: RoutineItem[], onDone: () => void) => void; }) { const t = useTranslations('records'); - const [editing, setEditing] = useState(false); - const [draft, setDraft] = useState(data); + const [sheetItem, setSheetItem] = useState(null); + const [sheetDirty, setSheetDirty] = useState(false); + const close = () => setSheetItem(null); - const startEdit = () => { - setDraft(data.map((r) => ({ ...r }))); - setEditing(true); + const handleSave = (item: RoutineItem) => { + const next = sheetItem === 'new' ? [...data, item] : data.map((r) => (r.id === item.id ? item : r)); + onSave(next, close); }; - const update = (id: string, patch: Partial) => setDraft((d) => d.map((r) => (r.id === id ? { ...r, ...patch } : r))); - const remove = (id: string) => setDraft((d) => d.filter((r) => r.id !== id)); - const add = () => setDraft((d) => [...d, { id: `new-${d.length}-${Date.now()}`, label: '', timeOfDay: null, note: null }]); - const canSave = draft.every((r) => r.label.trim().length > 0); - - if (!editing) { - return ( - - {data.map((r) => ( - - - - - {r.label} - {r.timeOfDay ? ` — ${r.timeOfDay}` : ''} - - - {r.note ? ( - - {r.note} - - ) : null} - - ))} - - ); - } + const handleDelete = (id: string) => onSave(data.filter((r) => r.id !== id), close); return ( - {draft.map((r) => ( - - - - update(r.id, { label: e.target.value })} size="small" fullWidth required /> - remove(r.id)} size="small"> - - - - - update(r.id, { timeOfDay: e.target.value || null })} size="small" sx={{ flex: 1, minWidth: 120 }} /> - update(r.id, { note: e.target.value || null })} size="small" sx={{ flex: 2, minWidth: 120 }} /> - - - - ))} - setEditing(false)} onSave={() => onSave(draft, () => setEditing(false))} saving={saving} canSave={canSave} /> + {data.length === 0 ? ( + + {t('routine_empty')} + + ) : ( + + {data.map((r) => ( + setSheetItem(r) : undefined}> + + + + {r.label} + + + {r.timeOfDay.length > 0 ? ( + + {r.timeOfDay.map((code) => ( + + {t(`time_${code}`)} + + ))} + + ) : null} + {r.note ? ( + + {r.note} + + ) : null} + + ))} + + )} + + {canEdit ? ( + setSheetItem('new')} sx={{ alignSelf: 'flex-start' }}> + {t('add_routine')} + + ) : null} + + + {sheetItem != null ? ( + handleDelete((sheetItem as RoutineItem).id) : undefined} + onDirtyChange={setSheetDirty} + /> + ) : null} + + + ); +} + +function RoutineSheetBody({ + initial, + saving, + onCancel, + onSave, + onDelete, + onDirtyChange, +}: { + initial: RoutineItem | null; + saving: boolean; + onCancel: () => void; + onSave: (item: RoutineItem) => void; + onDelete?: () => void; + onDirtyChange: (dirty: boolean) => void; +}) { + const t = useTranslations('records'); + const [label, setLabel] = useState(initial?.label ?? ''); + const [timeOfDay, setTimeOfDay] = useState(initial?.timeOfDay ?? []); + const [note, setNote] = useState(initial?.note ?? ''); + + useEffect(() => { + const dirty = + label !== (initial?.label ?? '') || + note !== (initial?.note ?? '') || + timeOfDay.length !== (initial?.timeOfDay ?? []).length || + timeOfDay.some((code) => !(initial?.timeOfDay ?? []).includes(code)); + onDirtyChange(dirty); + // eslint-disable-next-line react-hooks/exhaustive-deps -- `initial` is a fresh-mount snapshot (the sheet remounts per open), not reactive state. + }, [label, note, timeOfDay]); + + const canSave = label.trim().length > 0; + + const handleSave = () => + onSave({ + id: initial?.id ?? `new-${Date.now()}`, + label: label.trim(), + timeOfDay, + note: note.trim() || null, + }); + + return ( + + setLabel(e.target.value)} fullWidth required autoFocus /> + + + {t('time_of_day')} + + + + setNote(e.target.value)} fullWidth size="small" /> + ); } @@ -327,62 +704,132 @@ function TasksTab({ onSave: (tasks: CareTask[], onDone: () => void) => void; }) { const t = useTranslations('records'); - const [editing, setEditing] = useState(false); - const [draft, setDraft] = useState(data); + const [sheetItem, setSheetItem] = useState(null); + const [sheetDirty, setSheetDirty] = useState(false); + const close = () => setSheetItem(null); - const startEdit = () => { - setDraft(data.map((task) => ({ ...task }))); - setEditing(true); + const handleSave = (item: CareTask) => { + const next = sheetItem === 'new' ? [...data, item] : data.map((task) => (task.id === item.id ? item : task)); + onSave(next, close); }; - const update = (id: string, patch: Partial) => setDraft((d) => d.map((task) => (task.id === id ? { ...task, ...patch } : task))); - const remove = (id: string) => setDraft((d) => d.filter((task) => task.id !== id)); - const add = () => setDraft((d) => [...d, { id: `new-${d.length}-${Date.now()}`, label: '', done: false }]); - const canSave = draft.every((task) => task.label.trim().length > 0); - - if (!editing) { - return ( - - {data.map((task) => ( - - - - - {task.label} - - - - ))} - - ); - } + const handleDelete = (id: string) => onSave(data.filter((task) => task.id !== id), close); + const toggleDone = (task: CareTask) => onSave(data.map((t2) => (t2.id === task.id ? { ...t2, done: !t2.done } : t2)), () => {}); return ( - - {draft.map((task) => ( - - - update(task.id, { done: e.target.checked })} />} - label="" - sx={{ m: 0 }} - aria-label={t('task_done')} - /> - update(task.id, { label: e.target.value })} size="small" fullWidth required /> - remove(task.id)} size="small"> - - - - - ))} - setEditing(false)} onSave={() => onSave(draft, () => setEditing(false))} saving={saving} canSave={canSave} /> + + {data.length === 0 ? ( + + {t('tasks_empty')} + + ) : ( + + {data.map((task) => ( + + + (canEdit ? toggleDone(task) : undefined)} disabled={!canEdit || saving} />} + label="" + sx={{ m: 0 }} + aria-label={t('task_done')} + /> + setSheetItem(task)} + sx={{ flexGrow: 1, justifyContent: 'flex-start', textAlign: 'start', minWidth: 0 }} + > + + {task.label} + + + {canEdit ? : null} + + + ))} + + )} + + {canEdit ? ( + setSheetItem('new')} sx={{ alignSelf: 'flex-start' }}> + {t('add_task')} + + ) : null} + + + {sheetItem != null ? ( + handleDelete((sheetItem as CareTask).id) : undefined} + onDirtyChange={setSheetDirty} + /> + ) : null} + ); } -// ── History (سوابق) — patient-scoped, read-only, paged ──────────────────────────────────────────────────── +function TaskSheetBody({ + initial, + saving, + onCancel, + onSave, + onDelete, + onDirtyChange, +}: { + initial: CareTask | null; + saving: boolean; + onCancel: () => void; + onSave: (item: CareTask) => void; + onDelete?: () => void; + onDirtyChange: (dirty: boolean) => void; +}) { + const t = useTranslations('records'); + const [label, setLabel] = useState(initial?.label ?? ''); + const [done, setDone] = useState(initial?.done ?? false); + + useEffect(() => { + onDirtyChange(label !== (initial?.label ?? '') || done !== (initial?.done ?? false)); + // eslint-disable-next-line react-hooks/exhaustive-deps -- `initial` is a fresh-mount snapshot (the sheet remounts per open), not reactive state. + }, [label, done]); + + const canSave = label.trim().length > 0; + + return ( + + setLabel(e.target.value)} fullWidth required autoFocus /> + setDone(e.target.checked)} />} label={t('task_done')} /> + onSave({ id: initial?.id ?? `new-${Date.now()}`, label: label.trim(), done })} + onDelete={onDelete} + saving={saving} + canSave={canSave} + /> + + ); +} + +// ── History (سوابق) — patient-scoped, read-only, month-grouped, paged ───────────────────────────────────── +// Best-effort read of the frozen variant display name from a cached booking snapshot — mirrors +// BookingDetailView's helper; never fetched (would be an N+1 against the booking detail per note). +function variantName(snapshotJson: string): string | null { + try { + const parsed = JSON.parse(snapshotJson) as { displayName?: string }; + return parsed?.displayName ?? null; + } catch { + return null; + } +} + function HistoryTab({ patientId }: { patientId: number }) { const t = useTranslations('records'); const locale = useLocale(); + const router = useRouter(); + const queryClient = useQueryClient(); const [page, setPage] = useState(1); const history = usePatientHistory(patientId, page); @@ -413,10 +860,49 @@ function HistoryTab({ patientId }: { patientId: number }) { const totalPages = Math.max(1, Math.ceil(total / pageSize)); + // Group this page's notes by Shamsi month (notes already arrive newest-first from the server). + const groups: Array<{ month: string; notes: VisitNote[] }> = []; + for (const note of items) { + const month = formatShamsiMonthYear(note.recordedAt, locale); + const lastGroup = groups[groups.length - 1]; + if (lastGroup && lastGroup.month === month) lastGroup.notes.push(note); + else groups.push({ month, notes: [note] }); + } + return ( - - {items.map((note) => ( - + + {groups.map((group) => ( + + + + + + + + {group.month} + + {group.notes.map((note) => { + const doneCount = note.taskResults.filter((task) => task.done).length; + const booking = note.bookingId != null ? queryClient.getQueryData(bookingKeys.bookingDetail(note.bookingId)) : undefined; + const service = + booking && typeof booking === 'object' && 'variantSnapshotJson' in booking + ? variantName((booking as { variantSnapshotJson: string }).variantSnapshotJson) + : undefined; + return ( + 0 ? t('task_summary', { done: doneCount, total: note.taskResults.length }) : undefined} + serviceLabel={service ?? undefined} + bookingLinkLabel={note.bookingId != null ? t('view_booking') : undefined} + onOpenBooking={note.bookingId != null ? () => router.push(`/${locale}${bookingDetailPath(note.bookingId as number)}`) : undefined} + /> + ); + })} + + ))} {totalPages > 1 ? ( @@ -436,70 +922,6 @@ function HistoryTab({ patientId }: { patientId: number }) { } // ── Small shared bits ───────────────────────────────────────────────────────────────────────────────────── -function SectionShell({ - canEdit, - onEdit, - empty, - emptyLabel, - children, -}: { - canEdit: boolean; - onEdit: () => void; - empty: boolean; - emptyLabel: string; - children: React.ReactNode; -}) { - const tc = useTranslations('records'); - return ( - - {canEdit ? ( - - {tc('edit')} - - ) : null} - {empty ? ( - - {emptyLabel} - - ) : ( - {children} - )} - - ); -} - -function EditActions({ - onAdd, - onCancel, - onSave, - saving, - canSave, -}: { - onAdd: () => void; - onCancel: () => void; - onSave: () => void; - saving: boolean; - canSave: boolean; -}) { - const t = useTranslations('records'); - const tc = useTranslations('common'); - return ( - - - {t('add_item')} - - - - {tc('cancel')} - - - {saving ? tc('saving') : tc('save')} - - - - ); -} - function BackToPatients() { const t = useTranslations('records'); const router = useRouter(); diff --git a/client/src/app/[locale]/(private-routes)/(customer)/patients/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/patients/page.tsx index b420bcb..7b13faa 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/patients/page.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/patients/page.tsx @@ -3,26 +3,17 @@ import { useState } from 'react'; import { useLocale, useTranslations } from 'next-intl'; import { useRouter } from 'next/navigation'; import { useSnackbar } from 'notistack'; -import { - Box, - Dialog, - DialogActions, - DialogContent, - DialogTitle, - Skeleton, - Stack, - Typography, -} from '@mui/material'; -import { AppButton, EmptyState, ErrorState, PatientCard, PatientForm } from '@/components'; +import { Box, Skeleton, Stack, Typography } from '@mui/material'; +import { AppButton, ConfirmDialog, EmptyState, ErrorState, FormDialogShell, PatientCard, PatientForm } from '@/components'; import { patientRecordPath } from '@/constants'; import { usePatients, useCreatePatient, useUpdatePatient, useArchivePatient } from '@/services/patients'; import { birthDateToAge } from '@/services/patients/age'; import type { CreatePatientInput, Patient } from '@/services/patients/types'; /** - * E1 — the Patients tab: a cached, invalidate-on-mutation list of the customer's patients - * with add/edit (the A4 form reused in a dialog) and soft archive (confirm). Loading skeleton - * and an empty state with the add CTA are both handled. + * E1 — the care-circle tab («حلقه مراقبت»): a cached, invalidate-on-mutation list of the people the + * customer arranges care for, with add/edit (the A4 form in a full-screen-on-mobile dialog) and soft + * archive (confirm). Loading skeleton and an empty state with the add CTA are both handled. */ export default function PatientsPage() { const t = useTranslations('patients'); @@ -38,6 +29,7 @@ export default function PatientsPage() { const archivePatient = useArchivePatient(); const [formOpen, setFormOpen] = useState(false); + const [formDirty, setFormDirty] = useState(false); const [editing, setEditing] = useState(null); const [archiveTarget, setArchiveTarget] = useState(null); @@ -143,50 +135,52 @@ export default function PatientsPage() { )} - - {editing ? t('edit_title') : t('add_title')} - - - - - - + + + - setArchiveTarget(null)}> - {t('archive_title')} - - - {t('archive_body')} - - - - setArchiveTarget(null)}> - {tc('cancel')} - - - {t('archive_confirm')} - - - + setArchiveTarget(null)} + onConfirm={confirmArchive} + /> ); } diff --git a/client/src/app/[locale]/(private-routes)/(customer)/profile/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/profile/page.tsx index 6c9b100..af5fd82 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/profile/page.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/profile/page.tsx @@ -1,9 +1,18 @@ 'use client'; import { FunctionComponent, useState } from 'react'; import { useLocale, useTranslations } from 'next-intl'; +import { useRouter } from 'next/navigation'; import { useSnackbar } from 'notistack'; -import { Box, Divider, MenuItem, Paper, Stack, TextField, Typography } from '@mui/material'; -import { AppButton, AppIcon, AppLoading, ErrorState, PhoneNumberField } from '@/components'; +import { Box, Divider, MenuItem, Skeleton, Stack, TextField, Typography } from '@mui/material'; +import { + AppButton, + AppIcon, + ConfirmDialog, + ErrorState, + FormDialogShell, + PhoneNumberField, + ProfileSummary, +} from '@/components'; import LocaleSwitcher from '@/components/common/LocaleSwitcher'; import { isIranianMobile } from '@/components/PhoneNumberField'; import { ROUTES } from '@/constants'; @@ -13,51 +22,70 @@ import { useCustomerProfile, useUpsertCustomerProfile } from '@/services/profile import { useMe, useLogout } from '@/services/auth'; import type { CustomerProfile } from '@/services/profiles/types'; -/** Customer profile — name, preferred language, and the emergency contact. No national-ID KYC. */ +/** Customer profile — the account hub: identity header, grouped rows, emergency-contact status card. */ export default function CustomerProfilePage() { const t = useTranslations('profile'); const tc = useTranslations('common'); const { data: profile, isLoading, isError, refetch } = useCustomerProfile(); - const { data: me } = useMe(); - if (isLoading) return ; + const { data: me, isLoading: meLoading } = useMe(); + + if (isLoading || meLoading) return ; // The form must never render on a failed fetch — it would otherwise show blank/undefined fields // whose save could overwrite server truth. if (isError) return refetch()} />; - // The customer name is owned by `/me` (REQ-007), not `CustomerProfileDto` — prefill it from there so - // editing the emergency contact never blanks (and re-saves as null) the existing name. + return ( - + ); } -const CustomerProfileForm: FunctionComponent<{ +const AccountHub: FunctionComponent<{ initial: CustomerProfile | null; nameFallback: { firstName: string | null; lastName: string | null }; -}> = ({ initial, nameFallback }) => { + phone?: string; +}> = ({ initial, nameFallback, phone }) => { const t = useTranslations('profile'); - const ta = useTranslations('address'); const tc = useTranslations('common'); const locale = useLocale(); + const router = useRouter(); const { enqueueSnackbar } = useSnackbar(); const upsert = useUpsertCustomerProfile(); + const logout = useLogout(); + + const initialFirstName = initial?.firstName ?? nameFallback.firstName ?? ''; + const initialLastName = initial?.lastName ?? nameFallback.lastName ?? ''; + const initialLanguage = initial?.preferredLanguage ?? 'fa'; + const initialEmergencyName = initial?.defaultEmergencyContactName ?? ''; + const initialEmergencyPhone = digitsOnly(initial?.defaultEmergencyContactPhone ?? ''); + + const [firstName, setFirstName] = useState(initialFirstName); + const [lastName, setLastName] = useState(initialLastName); + const [language, setLanguage] = useState(initialLanguage); + const [emergencyName, setEmergencyName] = useState(initialEmergencyName); + const [emergencyPhone, setEmergencyPhone] = useState(initialEmergencyPhone); + + const [personalSheetOpen, setPersonalSheetOpen] = useState(false); + const [languageSheetOpen, setLanguageSheetOpen] = useState(false); + const [emergencySheetOpen, setEmergencySheetOpen] = useState(false); + const [signOutOpen, setSignOutOpen] = useState(false); - const [firstName, setFirstName] = useState(initial?.firstName ?? nameFallback.firstName ?? ''); - const [lastName, setLastName] = useState(initial?.lastName ?? nameFallback.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; + const displayName = [firstName, lastName].filter(Boolean).join(' ').trim() || phone || ''; + const emergencyComplete = Boolean(emergencyName.trim() && emergencyPhone); + // Every sheet saves the FULL profile object (the wire upsert has no PATCH semantics) — `patch` + // carries just the fields that sheet owns, the rest come from the shared draft state so editing + // one section never blanks another (the bug the old flat form was one refactor away from). + const save = ( + patch: Partial>, + onDone: () => void, + ) => { upsert.mutate( { defaultEmergencyContactName: emergencyName.trim(), @@ -65,135 +93,291 @@ const CustomerProfileForm: FunctionComponent<{ firstName: firstName.trim() || null, lastName: lastName.trim() || null, preferredLanguage: language, + ...patch, + }, + { + onSuccess: () => { + enqueueSnackbar(t('saved'), { variant: 'success' }); + onDone(); + }, }, - { onSuccess: () => enqueueSnackbar(t('saved'), { variant: 'success' }) }, ); }; + const savePersonal = () => + save({ firstName: firstName.trim() || null, lastName: lastName.trim() || null }, () => setPersonalSheetOpen(false)); + const saveLanguage = () => save({ preferredLanguage: language }, () => setLanguageSheetOpen(false)); + const saveEmergency = () => { + const nameInvalid = emergencyName.trim().length === 0; + const phoneInvalid = !isIranianMobile(emergencyPhone); + setNameError(nameInvalid); + setPhoneError(phoneInvalid); + if (nameInvalid || phoneInvalid) return; + save({}, () => setEmergencySheetOpen(false)); + }; + + const personalDirty = firstName !== initialFirstName || lastName !== initialLastName; + const languageDirty = language !== initialLanguage; + const emergencyDirty = emergencyName !== initialEmergencyName || emergencyPhone !== initialEmergencyPhone; + + const goTo = (path: string) => router.push(`/${locale}${path}`); + + // Shared discard-confirm strings for every FormDialogShell instance below. + const closeLabel = tc('close'); + const discardTitle = tc('discard_title'); + const discardBody = tc('discard_body'); + const discardConfirmLabel = tc('discard_confirm'); + const cancelLabel = tc('cancel'); + return ( - - - {t('title')} - - - {t('subtitle')} - - - {isComplete ? t('completion_done') : t('completion_todo')} - - + - - setFirstName(e.target.value)} fullWidth /> - setLastName(e.target.value)} fullWidth /> + + setPersonalSheetOpen(true)} /> + setEmergencySheetOpen(true)} + /> + goTo(ROUTES.ADDRESSES)} /> + setLanguageSheetOpen(true)} /> + goTo(ROUTES.NOTIFICATIONS)} /> + goTo(ROUTES.SUPPORT_TICKETS)} /> - setLanguage(e.target.value)} - sx={{ maxWidth: 220 }} + {/* Renders nothing for a single-role session — see ActorSwitcher's own doc. */} + + + + + setSignOutOpen(true)} tone="error" /> + + + {/* اطلاعات شخصی */} + setPersonalSheetOpen(false)} + closeLabel={closeLabel} + discardTitle={discardTitle} + discardBody={discardBody} + discardConfirmLabel={discardConfirmLabel} + discardCancelLabel={cancelLabel} > - {t('language_fa')} - {t('language_en')} - + + setFirstName(e.target.value)} fullWidth /> + setLastName(e.target.value)} fullWidth /> + setPersonalSheetOpen(false)} onSave={savePersonal} saving={upsert.isPending} saveLabel={tc('save')} cancelLabel={cancelLabel} /> + + - - - - - {t('emergency_section')} - - - {t('emergency_hint')} - - - - { - setEmergencyName(e.target.value); - if (nameError) setNameError(false); - }} - error={nameError} - fullWidth - /> - { - setEmergencyPhone(value); - if (phoneError) setPhoneError(false); - }} - error={phoneError} - helperText={phoneError ? t('emergency_phone_invalid') : undefined} - fullWidth - /> - - setLanguageSheetOpen(false)} + closeLabel={closeLabel} + discardTitle={discardTitle} + discardBody={discardBody} + discardConfirmLabel={discardConfirmLabel} + discardCancelLabel={cancelLabel} > - {upsert.isPending ? tc('saving') : t('save')} - - - - - {/* Address book lives alongside the profile in the customer area; a booking (f7) needs a - chosen address, so the entry point is surfaced here on the settings hub. */} - - - - - - {ta('manage_title')} + + + + {t('app_language')} - {ta('manage_body')} + {t('app_language_hint')} - - + + + setLanguage(e.target.value)} + helperText={t('language_hint')} > - {ta('manage_cta')} - + {t('language_fa')} + {t('language_en')} + + setLanguageSheetOpen(false)} onSave={saveLanguage} saving={upsert.isPending} saveLabel={tc('save')} cancelLabel={cancelLabel} /> - + - - - {/* Account affordances: sign-out has no home anywhere in the customer shell yet (a full hub - redesign is deferred to phase 9) — one labeled row is enough for now. */} - - - + {/* مخاطب اضطراری */} + setEmergencySheetOpen(false)} + closeLabel={closeLabel} + discardTitle={discardTitle} + discardBody={discardBody} + discardConfirmLabel={discardConfirmLabel} + discardCancelLabel={cancelLabel} + > + - {t('app_language')} + {t('emergency_hint')} - + { + setEmergencyName(e.target.value); + if (nameError) setNameError(false); + }} + error={nameError} + fullWidth + /> + { + setEmergencyPhone(v); + if (phoneError) setPhoneError(false); + }} + error={phoneError} + helperText={phoneError ? t('emergency_phone_invalid') : undefined} + fullWidth + /> + setEmergencySheetOpen(false)} onSave={saveEmergency} saving={upsert.isPending} saveLabel={tc('save')} cancelLabel={cancelLabel} /> - + + + setSignOutOpen(false)} + onConfirm={() => { + setSignOutOpen(false); + logout.mutate(); + }} + /> + + ); +}; + +const AccountRow: FunctionComponent<{ + icon: string; + label: string; + onClick: () => void; + tone?: 'default' | 'error'; +}> = ({ icon, label, onClick, tone = 'default' }) => ( + + + + + {label} + + {tone !== 'error' ? : null} + + +); + +const EmergencyContactCard: FunctionComponent<{ + complete: boolean; + name: string; + phone: string; + onEdit: () => void; +}> = ({ complete, name, phone, onEdit }) => { + const t = useTranslations('profile'); + return ( + + + + + + {t('emergency_section')} + + {complete ? ( + + + {name} + + + {phone} + + + ) : ( + + {t('emergency_hint')} + + )} + + + {t('edit')} + ); }; -const SignOutRow: FunctionComponent = () => { - const t = useTranslations('profile'); - const { mutate: logout, isPending } = useLogout(); +const SheetActions: FunctionComponent<{ + onCancel: () => void; + onSave: () => void; + saving: boolean; + saveLabel: string; + cancelLabel: string; +}> = ({ onCancel, onSave, saving, saveLabel, cancelLabel }) => { + const tc = useTranslations('common'); return ( - logout()} disabled={isPending} sx={{ alignSelf: 'flex-start' }}> - {t('sign_out')} - + + + {cancelLabel} + + + {saving ? tc('saving') : saveLabel} + + ); }; + +function ProfileSkeleton() { + return ( + + + + + + + + {[0, 1, 2, 3, 4, 5].map((key) => ( + + ))} + + + ); +} diff --git a/client/src/components/GenderToggle/GenderToggle.test.tsx b/client/src/components/GenderToggle/GenderToggle.test.tsx index 522897c..2c4a514 100644 --- a/client/src/components/GenderToggle/GenderToggle.test.tsx +++ b/client/src/components/GenderToggle/GenderToggle.test.tsx @@ -39,6 +39,11 @@ describe(' component', () => { await user.click(screen.getByText('Male')); expect(onChange).not.toHaveBeenCalled(); }); + + it('spans the full width so its flex:1 buttons actually split evenly', () => { + const { container } = renderToggle(null); + expect(container.querySelector('.MuiToggleButtonGroup-root')).toHaveStyle({ width: '100%' }); + }); }); describe(' allowAny mode', () => { diff --git a/client/src/components/GenderToggle/GenderToggle.tsx b/client/src/components/GenderToggle/GenderToggle.tsx index cc22f06..288286e 100644 --- a/client/src/components/GenderToggle/GenderToggle.tsx +++ b/client/src/components/GenderToggle/GenderToggle.tsx @@ -61,6 +61,7 @@ const GenderToggle: FunctionComponent = (props) => { aria-label={ariaLabel} onChange={(_event, next: Gender | 'any' | null) => handleChange(next)} sx={{ + width: '100%', '& .MuiToggleButton-root': { flex: 1, py: 1.25, diff --git a/client/src/components/PatientCard/PatientCard.test.tsx b/client/src/components/PatientCard/PatientCard.test.tsx index 098f180..b7d732e 100644 --- a/client/src/components/PatientCard/PatientCard.test.tsx +++ b/client/src/components/PatientCard/PatientCard.test.tsx @@ -68,4 +68,43 @@ describe(' component', () => { expect(onEdit).not.toHaveBeenCalled(); expect(onArchive).not.toHaveBeenCalled(); }); + + it('renders the last-visit teaser only when provided', () => { + const { rerender } = render( + + + , + ); + expect(screen.queryByText(/Last visit/)).not.toBeInTheDocument(); + + rerender( + + + , + ); + expect(screen.getByText('Last visit: Jul 3')).toBeInTheDocument(); + }); }); diff --git a/client/src/components/PatientCard/PatientCard.tsx b/client/src/components/PatientCard/PatientCard.tsx index 48133e7..9cdcc7a 100644 --- a/client/src/components/PatientCard/PatientCard.tsx +++ b/client/src/components/PatientCard/PatientCard.tsx @@ -1,9 +1,11 @@ 'use client'; import { FunctionComponent } from 'react'; import Box from '@mui/material/Box'; +import ButtonBase from '@mui/material/ButtonBase'; import Skeleton from '@mui/material/Skeleton'; import Stack from '@mui/material/Stack'; -import { AppIconButton, SurfaceCard } from '@/components/common'; +import Typography from '@mui/material/Typography'; +import { AppIcon, AppIconButton, SurfaceCard } from '@/components/common'; import PatientHeader from '@/components/PatientHeader'; import type { Patient } from '@/services/patients/types'; @@ -26,6 +28,12 @@ export interface PatientCardProps { onOpen?: () => void; /** Accessible label for the tappable identity area (required when `onOpen` is set). */ openLabel?: string; + /** + * Pre-formatted "آخرین ویزیت: …" teaser, best-effort from the cached bookings list. Omitted (never + * fabricated) when no cached booking can be matched to this patient — the server has no + * `lastVisitAt` field yet (filed as a REQ). + */ + lastVisitLabel?: string; } /** @@ -48,41 +56,48 @@ const PatientCard: FunctionComponent = ({ archiveLabel, onOpen, openLabel, + lastVisitLabel, }) => { const header = ( - + <> + + {lastVisitLabel ? ( + + {lastVisitLabel} + + ) : null} + ); return ( {onOpen ? ( - - {header} - + {header} + + ) : ( {header} )} diff --git a/client/src/components/PatientForm/PatientForm.test.tsx b/client/src/components/PatientForm/PatientForm.test.tsx index 24e8f80..fbc6c03 100644 --- a/client/src/components/PatientForm/PatientForm.test.tsx +++ b/client/src/components/PatientForm/PatientForm.test.tsx @@ -6,11 +6,11 @@ jest.mock('next-intl', () => ({ useTranslations: () => (key: string) => key })); import PatientForm from './PatientForm'; -function renderForm() { +function renderForm(extra: Partial> = {}) { const onSubmit = jest.fn(); render( - + , ); return { onSubmit }; @@ -20,17 +20,19 @@ describe(' component', () => { it('blocks submit and flags gender when it is missing', async () => { const user = userEvent.setup(); const { onSubmit } = renderForm(); - await user.type(screen.getByLabelText('name_label'), 'Ali Rezaei'); + await user.type(screen.getByLabelText('first_name_label'), 'Ali'); + await user.type(screen.getByLabelText('last_name_label'), '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 () => { + it('submits the mapped patient input once first/last 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('first_name_label'), 'Ali'); + await user.type(screen.getByLabelText('last_name_label'), 'Rezaei'); await user.type(screen.getByLabelText('age_label'), '40'); await user.click(screen.getByText('gender_male')); await user.click(screen.getByText('Save')); @@ -47,4 +49,23 @@ describe(' component', () => { }), ); }); + + it('falls back the last name to the first name when left blank (the wire requires it)', async () => { + const user = userEvent.setup(); + const { onSubmit } = renderForm(); + await user.type(screen.getByLabelText('first_name_label'), 'Ali'); + await user.type(screen.getByLabelText('age_label'), '40'); + await user.click(screen.getByText('gender_male')); + await user.click(screen.getByText('Save')); + expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ displayName: 'Ali', firstName: 'Ali', lastName: 'Ali' })); + }); + + it('reports dirty state changes via onDirtyChange', async () => { + const user = userEvent.setup(); + const onDirtyChange = jest.fn(); + renderForm({ onDirtyChange }); + expect(onDirtyChange).toHaveBeenLastCalledWith(false); + await user.type(screen.getByLabelText('first_name_label'), 'A'); + expect(onDirtyChange).toHaveBeenLastCalledWith(true); + }); }); diff --git a/client/src/components/PatientForm/PatientForm.tsx b/client/src/components/PatientForm/PatientForm.tsx index fe61a7c..7d3aa74 100644 --- a/client/src/components/PatientForm/PatientForm.tsx +++ b/client/src/components/PatientForm/PatientForm.tsx @@ -1,5 +1,5 @@ 'use client'; -import { FunctionComponent, useState } from 'react'; +import { FunctionComponent, useEffect, useState } from 'react'; import { useTranslations } from 'next-intl'; import FormLabel from '@mui/material/FormLabel'; import Stack from '@mui/material/Stack'; @@ -18,7 +18,7 @@ const MAX_AGE = 120; export interface PatientFormProps { /** Prefill for edit, or a relation carried from the onboarding relation step. */ - initial?: Partial>; + initial?: Partial>; /** Show the relation picker (E1 add/edit). Onboarding hides it — A3 already chose the relation. */ showRelation?: boolean; submitLabel: string; @@ -26,21 +26,16 @@ export interface PatientFormProps { onSubmit: (input: CreatePatientInput) => void; onCancel?: () => void; cancelLabel?: string; -} - -// A single full-name field (per the wireframe) maps to the contract's first/last/display. -function splitName(fullName: string): Pick { - const displayName = fullName.trim(); - const parts = displayName.split(/\s+/); - const firstName = parts[0] ?? ''; - const lastName = parts.slice(1).join(' ') || firstName; - return { displayName, firstName, lastName }; + /** Reports whether any field differs from `initial` — drives a host `FormDialogShell`'s discard-confirm. */ + onDirtyChange?: (dirty: boolean) => void; } /** - * 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. + * The A4 patient form — first/last 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` (never rendered back as a fabricated full date — display stays age-only). + * `lastName` falls back to `firstName` only when left blank (the wire's `lastName` is required), + * never guessed by splitting a single field. Strings come from the `onboarding` namespace. * @component PatientForm */ const PatientForm: FunctionComponent = ({ @@ -51,17 +46,26 @@ const PatientForm: FunctionComponent = ({ onSubmit, onCancel, cancelLabel, + onDirtyChange, }) => { const t = useTranslations('onboarding'); - const [fullName, setFullName] = useState(initial?.displayName ?? ''); - const [age, setAge] = useState(() => { - const initialAge = birthDateToAge(initial?.birthDate); - return initialAge == null ? '' : String(initialAge); - }); - const [gender, setGender] = useState(initial?.gender ?? null); - const [conditions, setConditions] = useState(initial?.conditions ?? []); - const [relation, setRelation] = useState(initial?.relation ?? null); + const initialFirstName = initial?.firstName ?? ''; + const initialLastName = initial?.lastName ?? ''; + const initialAge = (() => { + const age = birthDateToAge(initial?.birthDate); + return age == null ? '' : String(age); + })(); + const initialGender = initial?.gender ?? null; + const initialConditions = initial?.conditions ?? []; + const initialRelation = initial?.relation ?? null; + + const [firstName, setFirstName] = useState(initialFirstName); + const [lastName, setLastName] = useState(initialLastName); + const [age, setAge] = useState(initialAge); + const [gender, setGender] = useState(initialGender); + const [conditions, setConditions] = useState(initialConditions); + const [relation, setRelation] = useState(initialRelation); const [nameError, setNameError] = useState(false); const [ageError, setAgeError] = useState(false); @@ -70,10 +74,28 @@ const PatientForm: FunctionComponent = ({ const conditionOptions = CONDITION_CODES.map((code) => ({ code, label: t(`condition_${code}`) })); const relationOptions = RELATION_CODES.map((code) => ({ code, label: t(`relation_${code}`) })); + useEffect(() => { + if (!onDirtyChange) return; + const dirty = + firstName !== initialFirstName || + lastName !== initialLastName || + age !== initialAge || + gender !== initialGender || + relation !== initialRelation || + conditions.length !== initialConditions.length || + conditions.some((code) => !initialConditions.includes(code as ConditionCode)); + onDirtyChange(dirty); + // eslint-disable-next-line react-hooks/exhaustive-deps -- initial* are derived from the `initial` prop once per mount (form is re-keyed by the caller on edit target change), not reactive state. + }, [firstName, lastName, age, gender, relation, conditions]); + const handleSubmit = () => { - const name = fullName.trim(); + const first = firstName.trim(); + const enteredLast = lastName.trim(); + // The wire's `lastName` is a required string, so it falls back to the first name when left + // blank — but `displayName` reflects only what was actually entered (never a duplicated name). + const last = enteredLast || first; const ageNum = Number(digitsOnly(age)); - const nameInvalid = name.length === 0; + const nameInvalid = first.length === 0; const ageInvalid = age.trim().length === 0 || !Number.isInteger(ageNum) || ageNum < 0 || ageNum > MAX_AGE; const genderInvalid = gender == null; @@ -83,7 +105,9 @@ const PatientForm: FunctionComponent = ({ if (nameInvalid || ageInvalid || genderInvalid) return; onSubmit({ - ...splitName(name), + displayName: [first, enteredLast].filter(Boolean).join(' '), + firstName: first, + lastName: last, birthDate: ageToBirthDate(ageNum), gender: gender as Gender, bloodType: null, @@ -95,17 +119,25 @@ const PatientForm: FunctionComponent = ({ return ( - { - setFullName(event.target.value); - if (nameError) setNameError(false); - }} - error={nameError} - helperText={nameError ? t('name_required') : undefined} - fullWidth - /> + + { + setFirstName(event.target.value); + if (nameError) setNameError(false); + }} + error={nameError} + helperText={nameError ? t('name_required') : undefined} + fullWidth + /> + setLastName(event.target.value)} + fullWidth + /> + component', () => { // meta line still renders age · gender expect(screen.getByText('70 yrs · Female')).toBeInTheDocument(); }); + + it('renders a colored-initials avatar from the display name', () => { + renderHeader(); + expect(screen.getByText('ZM')).toBeInTheDocument(); + }); }); diff --git a/client/src/components/PatientHeader/PatientHeader.tsx b/client/src/components/PatientHeader/PatientHeader.tsx index 7408508..142b961 100644 --- a/client/src/components/PatientHeader/PatientHeader.tsx +++ b/client/src/components/PatientHeader/PatientHeader.tsx @@ -4,6 +4,7 @@ import Box from '@mui/material/Box'; import Chip from '@mui/material/Chip'; import Stack from '@mui/material/Stack'; import Typography from '@mui/material/Typography'; +import { InitialsAvatar } from '@/components/common'; export interface PatientHeaderProps { /** The patient's display name. */ @@ -37,33 +38,36 @@ const PatientHeader: FunctionComponent = ({ const meta = [ageLabel, genderLabel].filter(Boolean).join(' · '); return ( - - - - {displayName} - - {relationLabel ? ( - + + + + + + {displayName} + + {relationLabel ? ( + + ) : null} + + + {meta ? ( + + {meta} + ) : null} + + {conditionLabels.length > 0 ? ( + + {conditionLabels.map((label) => ( + + ))} + + ) : ( + + {noConditionsLabel} + + )} - - {meta ? ( - - {meta} - - ) : null} - - {conditionLabels.length > 0 ? ( - - {conditionLabels.map((label) => ( - - ))} - - ) : ( - - {noConditionsLabel} - - )} ); }; diff --git a/client/src/components/ProfileSummary/ProfileSummary.test.tsx b/client/src/components/ProfileSummary/ProfileSummary.test.tsx index 643125a..e76d71d 100644 --- a/client/src/components/ProfileSummary/ProfileSummary.test.tsx +++ b/client/src/components/ProfileSummary/ProfileSummary.test.tsx @@ -46,4 +46,15 @@ describe(' component', () => { expect(screen.getByText('مالی')).toBeInTheDocument(); expect(container.querySelector('.MuiAvatar-root')).toHaveStyle({ width: '32px', height: '32px' }); }); + + it('renders colored initials from initialsFallback when there is no avatarUrl', () => { + renderSummary({ initialsFallback: 'سارا احمدی' }); + expect(screen.getByText('سا')).toBeInTheDocument(); + }); + + it('prefers a real avatarUrl over the initials fallback', () => { + const { container } = renderSummary({ avatarUrl: '/img/avatar.png', initialsFallback: 'سارا احمدی' }); + expect(screen.queryByText('سا')).not.toBeInTheDocument(); + expect(container.querySelector('img[src="/img/avatar.png"]')).toBeInTheDocument(); + }); }); diff --git a/client/src/components/ProfileSummary/ProfileSummary.tsx b/client/src/components/ProfileSummary/ProfileSummary.tsx index 18cb36c..088e719 100644 --- a/client/src/components/ProfileSummary/ProfileSummary.tsx +++ b/client/src/components/ProfileSummary/ProfileSummary.tsx @@ -1,6 +1,7 @@ import { FunctionComponent } from 'react'; import { Avatar, Skeleton, Stack, Typography } from '@mui/material'; import TrustBadge from '../TrustBadge'; +import InitialsAvatar from '../common/InitialsAvatar'; import type { BadgeState } from '@/services/verification/types'; export interface ProfileSummaryProps { @@ -11,6 +12,12 @@ export interface ProfileSummaryProps { /** Already-translated role/fine-grained-role label (e.g. "پرستار", "مالی"). */ roleLabel?: string; avatarUrl?: string | null; + /** + * When there is no `avatarUrl` (the customer identity has no photo concept), renders a warm + * auto-colored `InitialsAvatar` from this name instead of MUI's generic person-silhouette + * fallback. Ignored once `avatarUrl` is set. + */ + initialsFallback?: string; /** Renders a `TrustBadge` next to the name when set (nurse identity only). */ trustState?: BadgeState; /** True while the identity is still resolving — renders skeleton placeholders instead of text. */ @@ -31,6 +38,7 @@ const ProfileSummary: FunctionComponent = ({ phone, roleLabel, avatarUrl, + initialsFallback, trustState, loading = false, compact = false, @@ -49,7 +57,12 @@ const ProfileSummary: FunctionComponent = ({ ); } - const avatar = ; + const avatar = + !avatarUrl && initialsFallback ? ( + + ) : ( + + ); if (compact) { return ( diff --git a/client/src/components/RelationSelect/RelationSelect.test.tsx b/client/src/components/RelationSelect/RelationSelect.test.tsx index ebc63f8..a8f68a8 100644 --- a/client/src/components/RelationSelect/RelationSelect.test.tsx +++ b/client/src/components/RelationSelect/RelationSelect.test.tsx @@ -37,4 +37,12 @@ describe(' component', () => { await user.click(screen.getByText('Parent')); expect(onChange).toHaveBeenCalledWith('parent'); }); + + it('renders a check icon on the selected card, not only a border color (WCAG 1.4.1)', () => { + const { container } = renderSelect('self'); + const selectedCard = container.querySelector('[data-code="self"]'); + const unselectedCard = container.querySelector('[data-code="parent"]'); + expect(selectedCard?.querySelector('svg')).toBeInTheDocument(); + expect(unselectedCard?.querySelector('svg')).not.toBeInTheDocument(); + }); }); diff --git a/client/src/components/RelationSelect/RelationSelect.tsx b/client/src/components/RelationSelect/RelationSelect.tsx index 9420ae7..5e4a91e 100644 --- a/client/src/components/RelationSelect/RelationSelect.tsx +++ b/client/src/components/RelationSelect/RelationSelect.tsx @@ -53,12 +53,16 @@ const RelationSelect: FunctionComponent = ({ options, value border: '2px solid', borderColor: selected ? 'primary.main' : 'divider', borderRadius: 2, + bgcolor: selected ? 'var(--bal-primary-soft)' : 'transparent', + '&:hover': { borderColor: selected ? 'primary.main' : 'text.secondary' }, + '&:focus-visible': { outline: '2px solid var(--bal-focus-ring)', outlineOffset: 2 }, }} > {option.icon ? : null} - + {option.label} + {selected ? : null} ); })} diff --git a/client/src/components/VisitNoteCard/VisitNoteCard.test.tsx b/client/src/components/VisitNoteCard/VisitNoteCard.test.tsx index 7cc15d7..91d0798 100644 --- a/client/src/components/VisitNoteCard/VisitNoteCard.test.tsx +++ b/client/src/components/VisitNoteCard/VisitNoteCard.test.tsx @@ -42,4 +42,38 @@ describe(' component', () => { renderCard({ ...NOTE, nurseDisplayName: null }); expect(screen.getByText('پرستار')).toBeInTheDocument(); }); + + it('renders the task summary line and service label when provided', () => { + render( + + + , + ); + expect(screen.getByText('۱ از ۲ انجام شد')).toBeInTheDocument(); + expect(screen.getByText('· مراقبت پایه')).toBeInTheDocument(); + }); + + it('renders a booking link only when bookingId is set and fires onOpenBooking', () => { + const onOpenBooking = jest.fn(); + const { rerender } = render( + + + , + ); + screen.getByText('مشاهده رزرو').click(); + expect(onOpenBooking).toHaveBeenCalledTimes(1); + + rerender( + + + , + ); + expect(screen.queryByText('مشاهده رزرو')).not.toBeInTheDocument(); + }); }); diff --git a/client/src/components/VisitNoteCard/VisitNoteCard.tsx b/client/src/components/VisitNoteCard/VisitNoteCard.tsx index d35d7c1..8a402d8 100644 --- a/client/src/components/VisitNoteCard/VisitNoteCard.tsx +++ b/client/src/components/VisitNoteCard/VisitNoteCard.tsx @@ -5,7 +5,7 @@ import Chip from '@mui/material/Chip'; import Skeleton from '@mui/material/Skeleton'; import Stack from '@mui/material/Stack'; import Typography from '@mui/material/Typography'; -import { AppIcon, SurfaceCard } from '@/components/common'; +import { AppButton, AppIcon, SurfaceCard } from '@/components/common'; import type { VisitNote } from '@/services/patientRecords/types'; export interface VisitNoteCardProps { @@ -15,6 +15,13 @@ export interface VisitNoteCardProps { dateLabel: string; /** Shown when the note has no recorded nurse name. */ authorFallback: string; + /** Pre-computed "X از Y انجام شد" summary — the caller owns the i18n/pluralization. */ + taskSummaryLabel?: string; + /** Best-effort service name derived from a cached booking snapshot — omitted, never fabricated, when unknown. */ + serviceLabel?: string; + /** Renders a «مشاهده رزرو» link when `note.bookingId` is non-null; the caller owns navigation. */ + bookingLinkLabel?: string; + onOpenBooking?: () => void; } /** @@ -24,7 +31,15 @@ export interface VisitNoteCardProps { * continuity view. Clinical text is rendered verbatim and never logged. * @component VisitNoteCard */ -const VisitNoteCard: FunctionComponent = ({ note, dateLabel, authorFallback }) => { +const VisitNoteCard: FunctionComponent = ({ + note, + dateLabel, + authorFallback, + taskSummaryLabel, + serviceLabel, + bookingLinkLabel, + onOpenBooking, +}) => { return ( @@ -32,6 +47,11 @@ const VisitNoteCard: FunctionComponent = ({ note, dateLabel, {note.nurseDisplayName?.trim() || authorFallback} + {serviceLabel ? ( + + · {serviceLabel} + + ) : null} {dateLabel} @@ -42,24 +62,37 @@ const VisitNoteCard: FunctionComponent = ({ note, dateLabel, {note.taskResults.length > 0 ? ( - - {note.taskResults.map((task, index) => ( - - } - label={task.label} - sx={{ color: task.done ? undefined : 'text.secondary' }} - /> - ))} - + <> + {taskSummaryLabel ? ( + + {taskSummaryLabel} + + ) : null} + + {note.taskResults.map((task, index) => ( + + } + label={task.label} + sx={{ color: task.done ? undefined : 'text.secondary' }} + /> + ))} + + + ) : null} + + {note.bookingId != null && bookingLinkLabel ? ( + + {bookingLinkLabel} + ) : null} ); diff --git a/client/src/components/common/FormDialogShell/FormDialogShell.test.tsx b/client/src/components/common/FormDialogShell/FormDialogShell.test.tsx new file mode 100644 index 0000000..aeeb01a --- /dev/null +++ b/client/src/components/common/FormDialogShell/FormDialogShell.test.tsx @@ -0,0 +1,65 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { ThemeProvider } from '../../../theme'; +import FormDialogShell from './FormDialogShell'; + +describe('', () => { + const wrap = (ui: React.ReactNode) => render({ui}); + + const baseProps = { + open: true, + title: 'Add patient', + closeLabel: 'Close', + discardTitle: 'Discard changes?', + discardBody: 'Your changes will be lost.', + discardConfirmLabel: 'Discard', + discardCancelLabel: 'Keep editing', + }; + + it('renders the title and children', () => { + wrap( + +
form body
+
, + ); + expect(screen.getByText('Add patient')).toBeInTheDocument(); + expect(screen.getByText('form body')).toBeInTheDocument(); + }); + + it('closes immediately when not dirty', () => { + const onClose = jest.fn(); + wrap( + +
+ , + ); + fireEvent.click(screen.getByLabelText('Close')); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('shows a discard confirm instead of closing when dirty', () => { + const onClose = jest.fn(); + wrap( + +
+ , + ); + fireEvent.click(screen.getByLabelText('Close')); + expect(onClose).not.toHaveBeenCalled(); + expect(screen.getByText('Discard changes?')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Discard' })); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('keeps editing (does not close) when the discard confirm is cancelled', () => { + const onClose = jest.fn(); + wrap( + +
+ , + ); + fireEvent.click(screen.getByLabelText('Close')); + fireEvent.click(screen.getByRole('button', { name: 'Keep editing' })); + expect(onClose).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/components/common/FormDialogShell/FormDialogShell.tsx b/client/src/components/common/FormDialogShell/FormDialogShell.tsx new file mode 100644 index 0000000..20cfe12 --- /dev/null +++ b/client/src/components/common/FormDialogShell/FormDialogShell.tsx @@ -0,0 +1,98 @@ +'use client'; +import { FunctionComponent, ReactNode, useState } from 'react'; +import AppBar from '@mui/material/AppBar'; +import Dialog from '@mui/material/Dialog'; +import DialogContent from '@mui/material/DialogContent'; +import IconButton from '@mui/material/IconButton'; +import Toolbar from '@mui/material/Toolbar'; +import Typography from '@mui/material/Typography'; +import useMediaQuery from '@mui/material/useMediaQuery'; +import { useTheme } from '@mui/material/styles'; +import AppIcon from '../AppIcon'; +import ConfirmDialog from '../ConfirmDialog'; + +export interface FormDialogShellProps { + open: boolean; + /** App-bar title (the form's add/edit heading). */ + title: string; + /** Whether the hosted form has unsaved changes — gates the discard-confirm on close/backdrop/escape. */ + dirty: boolean; + /** The real close — called immediately when not dirty, or after the discard is confirmed. */ + onClose: () => void; + closeLabel: string; + discardTitle: string; + discardBody: string; + discardConfirmLabel: string; + discardCancelLabel: string; + children: ReactNode; +} + +/** + * Full-screen-on-mobile form dialog: below the `sm` breakpoint the form takes over the whole + * viewport (a single scroll region, an app-bar header with a close button) instead of a cramped + * `maxWidth="sm"` modal double-scrolling against the keyboard; at `sm`+ it's a normal sized + * dialog. Closing (the app-bar button, backdrop click, or Escape) while the form is `dirty` shows + * a discard-confirm instead of silently dropping the draft. The hosted form keeps its own + * save/cancel row inside `children` — this shell only owns the chrome + the dirty guard. + * @component FormDialogShell + */ +const FormDialogShell: FunctionComponent = ({ + open, + title, + dirty, + onClose, + closeLabel, + discardTitle, + discardBody, + discardConfirmLabel, + discardCancelLabel, + children, +}) => { + const theme = useTheme(); + const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); + const [discardOpen, setDiscardOpen] = useState(false); + + const requestClose = () => { + if (dirty) setDiscardOpen(true); + else onClose(); + }; + + return ( + <> + + + + + + + + {title} + + + + {children} + + + { + setDiscardOpen(false); + onClose(); + }} + onClose={() => setDiscardOpen(false)} + /> + + ); +}; + +export default FormDialogShell; diff --git a/client/src/components/common/FormDialogShell/index.tsx b/client/src/components/common/FormDialogShell/index.tsx new file mode 100644 index 0000000..466d7e8 --- /dev/null +++ b/client/src/components/common/FormDialogShell/index.tsx @@ -0,0 +1,4 @@ +import FormDialogShell from './FormDialogShell'; + +export default FormDialogShell; +export type { FormDialogShellProps } from './FormDialogShell'; diff --git a/client/src/components/common/InitialsAvatar/InitialsAvatar.test.tsx b/client/src/components/common/InitialsAvatar/InitialsAvatar.test.tsx new file mode 100644 index 0000000..9d389b5 --- /dev/null +++ b/client/src/components/common/InitialsAvatar/InitialsAvatar.test.tsx @@ -0,0 +1,37 @@ +import { render, screen } from '@testing-library/react'; +import { ThemeProvider } from '../../../theme'; +import InitialsAvatar from './InitialsAvatar'; + +describe('', () => { + const wrap = (ui: React.ReactNode) => render({ui}); + + it('renders initials from the first two words of the name', () => { + wrap(); + expect(screen.getByText('مر')).toBeInTheDocument(); + }); + + it('renders a single-word name as one initial', () => { + wrap(); + expect(screen.getByText('م')).toBeInTheDocument(); + }); + + it('is hidden from assistive tech (decorative next to a visible name)', () => { + wrap(); + expect(screen.getByText('مر')).toHaveAttribute('aria-hidden', 'true'); + }); + + it('picks the same color index for the same name every render', () => { + const { container: first } = render( + + + , + ); + const { container: second } = render( + + + , + ); + const bg = (el: HTMLElement) => el.querySelector('.MuiAvatar-root')?.getAttribute('style'); + expect(bg(first.firstChild as HTMLElement)).toEqual(bg(second.firstChild as HTMLElement)); + }); +}); diff --git a/client/src/components/common/InitialsAvatar/InitialsAvatar.tsx b/client/src/components/common/InitialsAvatar/InitialsAvatar.tsx new file mode 100644 index 0000000..4618d42 --- /dev/null +++ b/client/src/components/common/InitialsAvatar/InitialsAvatar.tsx @@ -0,0 +1,60 @@ +'use client'; +import { FunctionComponent, useMemo } from 'react'; +import Avatar from '@mui/material/Avatar'; + +/** Number of `--bal-avatar-*` color pairs defined in tokens.css (both scheme blocks). */ +const AVATAR_TOKEN_COUNT = 6; + +// Two-letter initials from the first two whitespace-separated words; Persian names have no case, +// so this stays a plain substring pick (never .toUpperCase() on non-Latin scripts). +function initialsOf(name: string): string { + const parts = name.trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) return ''; + const first = parts[0].charAt(0); + const second = parts.length > 1 ? parts[1].charAt(0) : ''; + return `${first}${second}`; +} + +// A stable (non-cryptographic) hash so the same name always lands on the same token pair, +// across reloads and between the E1 card and E2 record header. +function colorIndexOf(name: string): number { + let hash = 0; + for (let i = 0; i < name.length; i += 1) hash = (hash * 31 + name.charCodeAt(i)) >>> 0; + return (hash % AVATAR_TOKEN_COUNT) + 1; +} + +export interface InitialsAvatarProps { + /** The person's display name — source of both the initials and the deterministic color. */ + name: string; + size?: number; +} + +/** + * Warm auto-colored initials avatar — the identity face for a person with no photo (a care-circle + * patient, the account holder). Deterministic per name (stable hash → one of 6 `--bal-avatar-*` + * token pairs, both color-scheme blocks in `tokens.css`), so the same person always renders the + * same color. Purely decorative next to a visible name, so it's hidden from assistive tech. + * @component InitialsAvatar + */ +const InitialsAvatar: FunctionComponent = ({ name, size = 40 }) => { + const index = useMemo(() => colorIndexOf(name), [name]); + const initials = useMemo(() => initialsOf(name), [name]); + + return ( + + {initials} + + ); +}; + +export default InitialsAvatar; diff --git a/client/src/components/common/InitialsAvatar/index.tsx b/client/src/components/common/InitialsAvatar/index.tsx new file mode 100644 index 0000000..5d8e158 --- /dev/null +++ b/client/src/components/common/InitialsAvatar/index.tsx @@ -0,0 +1,4 @@ +import InitialsAvatar from './InitialsAvatar'; + +export default InitialsAvatar; +export type { InitialsAvatarProps } from './InitialsAvatar'; diff --git a/client/src/components/common/index.tsx b/client/src/components/common/index.tsx index 715640d..01603bf 100644 --- a/client/src/components/common/index.tsx +++ b/client/src/components/common/index.tsx @@ -20,6 +20,8 @@ import JalaliDateIntentPicker from './JalaliDateIntentPicker'; import LocaleSwitcher from './LocaleSwitcher'; import StickyActionBar from './StickyActionBar'; import Pager from './Pager'; +import InitialsAvatar from './InitialsAvatar'; +import FormDialogShell from './FormDialogShell'; export { ErrorBoundary, @@ -44,6 +46,8 @@ export { LocaleSwitcher, StickyActionBar, Pager, + InitialsAvatar, + FormDialogShell, }; export type { EmptyStateProps } from './EmptyState'; export type { ErrorStateProps } from './ErrorState'; @@ -59,3 +63,5 @@ export type { JalaliDateFieldProps } from './JalaliDateField'; export type { JalaliDateIntentPickerProps } from './JalaliDateIntentPicker'; export type { StickyActionBarProps } from './StickyActionBar'; export type { PagerProps } from './Pager'; +export type { InitialsAvatarProps } from './InitialsAvatar'; +export type { FormDialogShellProps } from './FormDialogShell'; diff --git a/client/src/components/geography/AddressCard.test.tsx b/client/src/components/geography/AddressCard.test.tsx index e9e7854..745edcd 100644 --- a/client/src/components/geography/AddressCard.test.tsx +++ b/client/src/components/geography/AddressCard.test.tsx @@ -15,6 +15,9 @@ const BASE: AddressCardProps = { editLabel: 'Edit', deleteLabel: 'Delete', setPrimaryLabel: 'Set as primary', + hasPin: true, + pinSetLabel: 'Pin set', + pinMissingLabel: 'No pin', }; function renderCard(overrides: Partial = {}) { @@ -56,4 +59,19 @@ describe(' component', () => { expect(props.onEdit).toHaveBeenCalledTimes(1); expect(props.onDelete).toHaveBeenCalledTimes(1); }); + + it('shows the pin-quality cue for both states', () => { + const { rerender } = render( + + + , + ); + expect(screen.getByText('No pin')).toBeInTheDocument(); + rerender( + + + , + ); + expect(screen.getByText('Pin set')).toBeInTheDocument(); + }); }); diff --git a/client/src/components/geography/AddressCard.tsx b/client/src/components/geography/AddressCard.tsx index d39339f..f75d6df 100644 --- a/client/src/components/geography/AddressCard.tsx +++ b/client/src/components/geography/AddressCard.tsx @@ -3,7 +3,7 @@ import { FunctionComponent } from 'react'; import Paper from '@mui/material/Paper'; import Stack from '@mui/material/Stack'; import Typography from '@mui/material/Typography'; -import { AppButton, AppIconButton } from '@/components/common'; +import { AppButton, AppIcon, AppIconButton } from '@/components/common'; import StatusChip from '@/components/StatusChip'; export interface AddressCardProps { @@ -21,6 +21,11 @@ export interface AddressCardProps { setPrimaryLabel: string; /** Disables the set-primary action while its mutation is in flight. */ settingPrimary?: boolean; + /** Whether the address has a resolved map pin (`latitude`/`longitude` non-null). */ + hasPin: boolean; + pinSetLabel: string; + /** Shown with a warm fix-it nudge (a missing pin degrades nurse arrival + EVV). */ + pinMissingLabel: string; } /** @@ -43,6 +48,9 @@ const AddressCard: FunctionComponent = ({ deleteLabel, setPrimaryLabel, settingPrimary = false, + hasPin, + pinSetLabel, + pinMissingLabel, }) => ( @@ -64,6 +72,17 @@ const AddressCard: FunctionComponent = ({ ) : null} + + + + {hasPin ? pinSetLabel : pinMissingLabel} + + + {!isPrimary ? ( void; onCancel?: () => void; + /** Reports whether any field differs from `initial` — drives a host `FormDialogShell`'s discard-confirm. */ + onDirtyChange?: (dirty: boolean) => void; } // Only prefill the region when we have the province — a city id without its province can't drive @@ -50,21 +52,41 @@ function initialPin(initial?: AddressFormInitial): LatLng | null { * `CreateAddressInput` carrying the picked coordinates. Reused for create and edit. * @component AddressForm */ -const AddressForm: FunctionComponent = ({ initial, submitting = false, onSubmit, onCancel }) => { +const AddressForm: FunctionComponent = ({ initial, submitting = false, onSubmit, onCancel, onDirtyChange }) => { const t = useTranslations('address'); const tc = useTranslations('common'); - const [title, setTitle] = useState(initial?.title ?? ''); + const initialTitle = initial?.title ?? ''; + const initialAddressLine = initial?.addressLine ?? ''; + const initialIsPrimary = initial?.isPrimary ?? false; + + const [title, setTitle] = useState(initialTitle); const [region, setRegion] = useState(() => initialRegion(initial)); - const [addressLine, setAddressLine] = useState(initial?.addressLine ?? ''); + const [addressLine, setAddressLine] = useState(initialAddressLine); const [pin, setPin] = useState(() => initialPin(initial)); - const [isPrimary, setIsPrimary] = useState(initial?.isPrimary ?? false); + const [isPrimary, setIsPrimary] = useState(initialIsPrimary); const [titleError, setTitleError] = useState(false); const [cityError, setCityError] = useState(false); const [lineError, setLineError] = useState(false); const [pinError, setPinError] = useState(false); + useEffect(() => { + if (!onDirtyChange) return; + const initialPinValue = initialPin(initial); + const dirty = + title !== initialTitle || + addressLine !== initialAddressLine || + isPrimary !== initialIsPrimary || + region.provinceId !== (initial?.provinceId ?? null) || + region.cityId !== (initial?.cityId ?? null) || + region.districtId !== (initial?.districtId ?? null) || + pin?.latitude !== initialPinValue?.latitude || + pin?.longitude !== initialPinValue?.longitude; + onDirtyChange(dirty); + // eslint-disable-next-line react-hooks/exhaustive-deps -- `initial` is a stable prefill snapshot (the caller re-keys the form on edit-target change), not reactive state to track. + }, [title, addressLine, isPrimary, region, pin]); + const handleSubmit = () => { const titleInvalid = title.trim().length === 0; const cityInvalid = region.cityId == null; diff --git a/client/src/components/geography/AddressMapPicker.test.tsx b/client/src/components/geography/AddressMapPicker.test.tsx index 32f275a..3af2101 100644 --- a/client/src/components/geography/AddressMapPicker.test.tsx +++ b/client/src/components/geography/AddressMapPicker.test.tsx @@ -1,8 +1,11 @@ import { render, screen, fireEvent } from '@testing-library/react'; import { ThemeProvider } from '../../theme'; -import AddressMapPicker from './AddressMapPicker'; import type { LatLng } from '@/services/addresses/types'; +jest.mock('next-intl', () => ({ useTranslations: () => (key: string) => key })); + +import AddressMapPicker from './AddressMapPicker'; + function renderPicker(value: LatLng | null, center?: LatLng) { const onChange = jest.fn(); const utils = render( diff --git a/client/src/components/geography/AddressMapPicker.tsx b/client/src/components/geography/AddressMapPicker.tsx index 4af026a..10f4392 100644 --- a/client/src/components/geography/AddressMapPicker.tsx +++ b/client/src/components/geography/AddressMapPicker.tsx @@ -1,12 +1,23 @@ 'use client'; import { FunctionComponent, MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent, useRef, useState } from 'react'; +import dynamic from 'next/dynamic'; +import { useTranslations } from 'next-intl'; import Box from '@mui/material/Box'; +import Skeleton from '@mui/material/Skeleton'; import Stack from '@mui/material/Stack'; import Typography from '@mui/material/Typography'; import AppIcon from '@/components/common/AppIcon'; +import { NESHAN_WEB_KEY } from '@/config'; import { IRAN_CENTROID } from '@/services/geography/constants'; import type { LatLng } from '@/services/addresses/types'; +// Leaflet touches `window`/`document` at import time — never render it during SSR. The loading +// skeleton matches the real map's height so there is no layout jump once it hydrates. +const NeshanMap = dynamic(() => import('./NeshanMap'), { + ssr: false, + loading: () => , +}); + export interface AddressMapPickerProps { value: LatLng | null; onChange: (value: LatLng) => void; @@ -19,6 +30,36 @@ export interface AddressMapPickerProps { errorText?: string; } +/** + * The address map-pin picker's component boundary — real Neshan tiles behind a client-config + * check, falling back to the bounded-canvas grid stand-in when `NEXT_PUBLIC_NESHAN_KEY` is unset + * (dev/CI/jsdom keep working with zero network dependency). `{ latitude, longitude }` in/out is + * unchanged either way, so `AddressForm` never needs to know which mode is active. + * @component AddressMapPicker + */ +const AddressMapPicker: FunctionComponent = (props) => { + const t = useTranslations('address'); + + if (NESHAN_WEB_KEY) { + return ( + + ); + } + + return ; +}; + // Half-degree span each side of the centre (~±6 km) — enough precision for the later EVV check // while keeping the whole stand-in viewport around one city. const SPAN = 0.06; @@ -27,14 +68,14 @@ const round6 = (n: number) => Math.round(n * 1e6) / 1e6; /** * Lightweight **map-pin picker stand-in** — a draggable/tappable marker panel that emits real - * `{ latitude, longitude }`. It is NOT a real map (no Neshan/Google tiles), only a bounded - * canvas mapping the pointer position to coordinates around the chosen city's centroid, behind a - * small component boundary so a real map drops in later without touching the address form. The - * picked coordinates are what the create/update request sends; the pin only refines coordinates — - * the bookable geography is still the region dropdown choice. - * @component AddressMapPicker + * `{ latitude, longitude }`. It is NOT a real map (no tiles), only a bounded canvas mapping the + * pointer position to coordinates around the chosen city's centroid. This is the `NEXT_PUBLIC_NESHAN_KEY`-unset + * fallback (kept intentionally, not deleted) so dev/CI/jsdom keep working with no network + * dependency; the real experience is `NeshanMap` above. The picked coordinates are what the + * create/update request sends; the pin only refines coordinates — the bookable geography is still + * the region dropdown choice. */ -const AddressMapPicker: FunctionComponent = ({ +const GridFallbackMap: FunctionComponent = ({ value, onChange, center = IRAN_CENTROID, @@ -113,7 +154,7 @@ const AddressMapPicker: FunctionComponent = ({ sx={{ position: 'absolute', color: 'var(--bal-primary)', - filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.35))', + filter: 'drop-shadow(0 1px 2px var(--bal-pin-shadow))', pointerEvents: 'none', }} > diff --git a/client/src/components/geography/NeshanMap.tsx b/client/src/components/geography/NeshanMap.tsx new file mode 100644 index 0000000..8fb9c47 --- /dev/null +++ b/client/src/components/geography/NeshanMap.tsx @@ -0,0 +1,232 @@ +'use client'; +import { FunctionComponent, useEffect, useRef, useState } from 'react'; +import 'leaflet/dist/leaflet.css'; +import L from 'leaflet'; +import Box from '@mui/material/Box'; +import CircularProgress from '@mui/material/CircularProgress'; +import IconButton from '@mui/material/IconButton'; +import InputAdornment from '@mui/material/InputAdornment'; +import List from '@mui/material/List'; +import ListItemButton from '@mui/material/ListItemButton'; +import ListItemText from '@mui/material/ListItemText'; +import Paper from '@mui/material/Paper'; +import Stack from '@mui/material/Stack'; +import TextField from '@mui/material/TextField'; +import Typography from '@mui/material/Typography'; +import AppIcon from '@/components/common/AppIcon'; +import { NESHAN_TILE_ATTRIBUTION, NESHAN_TILE_URL_TEMPLATE, NESHAN_DEFAULT_ZOOM } from '@/services/geography/constants'; +import { searchNeshan, reverseGeocodeNeshan, type NeshanSearchResult } from '@/services/geography/neshan'; +import type { LatLng } from '@/services/addresses/types'; + +export interface NeshanMapProps { + value: LatLng | null; + onChange: (value: LatLng) => void; + center: LatLng; + neshanKey: string; + searchPlaceholder: string; + locateMeLabel: string; + pinPreviewPrefix: string; + resolvingLabel: string; + error?: boolean; + errorText?: string; +} + +// A brand-colored teardrop pin as a DivIcon (never Leaflet's default marker images — those need +// bundler-specific asset-path patching that a DivIcon sidesteps entirely, and this keeps the pin +// visually identical to the grid fallback's `AppIcon icon="location"`). +const PIN_HTML = + ''; + +/** + * Real Neshan-tiled map (Leaflet), dynamically imported with `ssr: false` by `AddressMapPicker` + * when `NEXT_PUBLIC_NESHAN_KEY` is set. Address search box (Neshan geocode), locate-me (browser + * GPS), a draggable/tappable pin, and a reverse-geocoded preview line — coordinates are never + * rendered to the user. The container stays `dir="ltr"` (Leaflet's own pixel math assumes LTR; + * the stylis RTL plugin would otherwise mirror its internally-managed absolute positioning), the + * same hazard `AddressMapPicker`'s grid stand-in documents for its own marker. + * @component NeshanMap + */ +const NeshanMap: FunctionComponent = ({ + value, + onChange, + center, + neshanKey, + searchPlaceholder, + locateMeLabel, + pinPreviewPrefix, + resolvingLabel, + error = false, + errorText, +}) => { + const containerRef = useRef(null); + const mapRef = useRef(null); + const markerRef = useRef(null); + + const [searchTerm, setSearchTerm] = useState(''); + const [results, setResults] = useState([]); + const [searching, setSearching] = useState(false); + const [locating, setLocating] = useState(false); + const [pinAddress, setPinAddress] = useState(null); + const [resolvingAddress, setResolvingAddress] = useState(false); + + const placePin = (point: LatLng, panTo = false) => { + onChange(point); + const map = mapRef.current; + if (!map) return; + const latlng = L.latLng(point.latitude, point.longitude); + if (markerRef.current) markerRef.current.setLatLng(latlng); + if (panTo) map.setView(latlng, Math.max(map.getZoom(), NESHAN_DEFAULT_ZOOM)); + }; + + // Mount the map once; teardown on unmount. + useEffect(() => { + if (!containerRef.current || mapRef.current) return; + const map = L.map(containerRef.current, { zoomControl: true }).setView( + [value?.latitude ?? center.latitude, value?.longitude ?? center.longitude], + NESHAN_DEFAULT_ZOOM, + ); + L.tileLayer(NESHAN_TILE_URL_TEMPLATE.replace('{key}', neshanKey), { attribution: NESHAN_TILE_ATTRIBUTION, maxZoom: 19 }).addTo(map); + + const icon = L.divIcon({ html: PIN_HTML, className: '', iconSize: [32, 32], iconAnchor: [16, 32] }); + const marker = L.marker([value?.latitude ?? center.latitude, value?.longitude ?? center.longitude], { + icon, + draggable: true, + }).addTo(map); + marker.on('dragend', () => { + const pos = marker.getLatLng(); + placePin({ latitude: pos.lat, longitude: pos.lng }); + }); + map.on('click', (e: L.LeafletMouseEvent) => placePin({ latitude: e.latlng.lat, longitude: e.latlng.lng })); + + mapRef.current = map; + markerRef.current = marker; + + return () => { + map.remove(); + mapRef.current = null; + markerRef.current = null; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- the map/marker are created exactly once; later `value` changes are applied imperatively (below), not by recreating the map. + }, []); + + // Reverse-geocode whenever the pin settles on a new point. + useEffect(() => { + if (!value) return; + let cancelled = false; + // Deliberate setState — flips the "resolving…" label on immediately when the pin moves. + // eslint-disable-next-line react-hooks/set-state-in-effect + setResolvingAddress(true); + reverseGeocodeNeshan(value).then((address) => { + if (!cancelled) { + setPinAddress(address); + setResolvingAddress(false); + } + }); + return () => { + cancelled = true; + }; + }, [value]); + + const runSearch = async () => { + setSearching(true); + try { + const found = await searchNeshan(searchTerm, value ?? center); + setResults(found); + } finally { + setSearching(false); + } + }; + + const pickResult = (result: NeshanSearchResult) => { + placePin(result.location, true); + setResults([]); + setSearchTerm(result.title); + }; + + const locateMe = () => { + if (typeof navigator === 'undefined' || !navigator.geolocation) return; + setLocating(true); + navigator.geolocation.getCurrentPosition( + (pos) => { + placePin({ latitude: pos.coords.latitude, longitude: pos.coords.longitude }, true); + setLocating(false); + }, + () => setLocating(false), // denied/unavailable — advisory, the user can still tap/drag the pin + { enableHighAccuracy: true, timeout: 10_000, maximumAge: 0 }, + ); + }; + + return ( + + + setSearchTerm(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + runSearch(); + } + }} + placeholder={searchPlaceholder} + fullWidth + size="small" + slotProps={{ + input: { + endAdornment: ( + + {searching ? ( + + ) : ( + + + + )} + + ), + }, + }} + /> + + {locating ? : } + + + + {results.length > 0 ? ( + + + {results.map((result, index) => ( + pickResult(result)}> + + + ))} + + + ) : null} + + + + {error && errorText ? ( + + {errorText} + + ) : value ? ( + + {resolvingAddress ? resolvingLabel : `${pinPreviewPrefix} ${pinAddress ?? ''}`.trim()} + + ) : null} + + ); +}; + +export default NeshanMap; diff --git a/client/src/config.ts b/client/src/config.ts index 0b8e80b..59373fd 100644 --- a/client/src/config.ts +++ b/client/src/config.ts @@ -9,10 +9,18 @@ export const PUBLIC_URL = process.env.NEXT_PUBLIC_PUBLIC_URL; // Variant 2: .env export const API_URL = envRequired(process.env.NEXT_PUBLIC_API_URL); +/** + * The Neshan **web** key (client-embeddable maps/search), separate from the server's + * `NeshanGeocoder` server key (refinement phase 8). Optional — `AddressMapPicker` falls back to + * the bounded-canvas grid stand-in when unset, so dev/CI/jsdom keep working without it. + */ +export const NESHAN_WEB_KEY = process.env.NEXT_PUBLIC_NESHAN_KEY || undefined; + IS_DEBUG && console.log('@/config', { IS_DEBUG, IS_PRODUCTION, PUBLIC_URL, API_URL, + NESHAN_WEB_KEY: NESHAN_WEB_KEY ? '(set)' : undefined, }); diff --git a/client/src/constants/routes.ts b/client/src/constants/routes.ts index e878b68..8b445d9 100644 --- a/client/src/constants/routes.ts +++ b/client/src/constants/routes.ts @@ -121,6 +121,9 @@ export const adminPayoutBatchPath = (batchId: number | string): string => export const adminPartnerCenterPath = (centerId: number | string): string => `${ROUTES.ADMIN_PARTNERS}/${centerId}`; +/** The customer booking-detail view (f8) — the E2 care-record history's «مشاهده رزرو» link lands here. */ +export const bookingDetailPath = (bookingId: number | string): string => `${ROUTES.BOOKINGS}/${bookingId}`; + /** A booking's invoice view (f9) — keyed by the booking the invoice belongs to. */ export const bookingInvoicePath = (bookingId: number | string): string => `${ROUTES.BOOKINGS}/${bookingId}/invoice`; diff --git a/client/src/services/geography/constants.ts b/client/src/services/geography/constants.ts index aaef6c9..9eb642d 100644 --- a/client/src/services/geography/constants.ts +++ b/client/src/services/geography/constants.ts @@ -40,3 +40,14 @@ export function cityCentroid(cityId?: number | null): { latitude: number; longit if (cityId != null && CITY_CENTROIDS[cityId]) return CITY_CENTROIDS[cityId]; return IRAN_CENTROID; } + +/** + * Neshan raster tile URL template (Leaflet `L.tileLayer` `{z}/{x}/{y}` placeholders + `{key}` for + * `NEXT_PUBLIC_NESHAN_KEY`). Set from Neshan's web-SDK conventions without live verification (this + * repo's sandbox has no route to platform.neshan.org) — **re-check against the current Neshan + * developer portal (platform.neshan.org) before relying on it with a real key**; if the host/path + * has moved, only this constant needs to change; `NeshanMap` never hardcodes the URL. + */ +export const NESHAN_TILE_URL_TEMPLATE = 'https://static.neshan.org/atlas/style/standard-day/{z}/{x}/{y}.png?key={key}'; +export const NESHAN_TILE_ATTRIBUTION = '© Neshan'; +export const NESHAN_DEFAULT_ZOOM = 15; diff --git a/client/src/services/geography/neshan.ts b/client/src/services/geography/neshan.ts new file mode 100644 index 0000000..23d6bd5 --- /dev/null +++ b/client/src/services/geography/neshan.ts @@ -0,0 +1,65 @@ +import { NESHAN_WEB_KEY } from '@/config'; +import type { LatLng } from '@/services/addresses/types'; + +/** + * Thin client for Neshan's public Search + Reverse-geocoding REST APIs (`api.neshan.org`) — the + * address map's search box and reverse-geocoded pin preview (ui-phase-9). This is a **direct + * third-party call, not our backend**: `clientFetch` is deliberately NOT used here, since it would + * prepend `NEXT_PUBLIC_API_URL` and attach our own bearer token to a third-party request (a + * credential leak) — a plain `fetch` with Neshan's own `Api-Key` header is the correct boundary. + * + * Endpoint paths/headers are confirmed against Neshan's public documentation; the exact response + * field names below (`items[].location.x/y`, `formatted_address`) were **not live-verified** in + * this sandbox (no network route to platform.neshan.org) — every read here is defensive + * (optional-chained, wrapped in try/catch) so a shape drift degrades to "no results" rather than a + * crash. Re-verify against platform.neshan.org if search/reverse silently return nothing with a + * real key configured. + */ + +const SEARCH_URL = 'https://api.neshan.org/v1/search'; +const REVERSE_URL = 'https://api.neshan.org/v5/reverse'; + +export interface NeshanSearchResult { + title: string; + address: string; + location: LatLng; +} + +async function neshanGet(url: string): Promise { + if (!NESHAN_WEB_KEY) return null; + try { + const response = await fetch(url, { headers: { 'Api-Key': NESHAN_WEB_KEY } }); + if (!response.ok) return null; + return await response.json(); + } catch { + return null; + } +} + +/** Free-text address search near a point (the map's current center) — `[]` on any failure. */ +export async function searchNeshan(term: string, near: LatLng): Promise { + const trimmed = term.trim(); + if (!trimmed) return []; + const url = `${SEARCH_URL}?term=${encodeURIComponent(trimmed)}&lat=${near.latitude}&lng=${near.longitude}`; + const data = (await neshanGet(url)) as { items?: unknown[] } | null; + if (!Array.isArray(data?.items)) return []; + + const results: NeshanSearchResult[] = []; + for (const raw of data.items) { + const item = raw as { title?: string; address?: string; location?: { x?: number; y?: number } }; + if (typeof item.location?.x !== 'number' || typeof item.location?.y !== 'number') continue; + results.push({ + title: item.title ?? '', + address: item.address ?? '', + location: { latitude: item.location.y, longitude: item.location.x }, + }); + } + return results; +} + +/** Reverse-geocodes a pin to a human-readable address — `null` on any failure/unset key. */ +export async function reverseGeocodeNeshan(point: LatLng): Promise { + const url = `${REVERSE_URL}?lat=${point.latitude}&lng=${point.longitude}`; + const data = (await neshanGet(url)) as { formatted_address?: string } | null; + return typeof data?.formatted_address === 'string' && data.formatted_address.length > 0 ? data.formatted_address : null; +} diff --git a/client/src/services/patientRecords/apis/mockApi.ts b/client/src/services/patientRecords/apis/mockApi.ts index 9232559..e6eae7a 100644 --- a/client/src/services/patientRecords/apis/mockApi.ts +++ b/client/src/services/patientRecords/apis/mockApi.ts @@ -48,12 +48,30 @@ function defaultFamilyRecord(patientId: number): FamilyCareRecord { return { patientId, medications: [ - { id: 'm1', name: 'متفورمین ۵۰۰', dosage: '۱ قرص', frequency: 'روزی دو بار', timingNote: 'صبح و شب، بعد از غذا' }, - { id: 'm2', name: 'لوزارتان ۲۵', dosage: '۱ قرص', frequency: 'روزی یک بار', timingNote: 'صبح' }, + { + id: 'm1', + name: 'متفورمین ۵۰۰', + doseAmount: '۱', + doseUnit: 'tablet', + frequencyCode: 'twice_daily', + frequencyText: null, + timeOfDay: ['morning', 'night'], + timingNote: 'بعد از غذا', + }, + { + id: 'm2', + name: 'لوزارتان ۲۵', + doseAmount: '۱', + doseUnit: 'tablet', + frequencyCode: 'once_daily', + frequencyText: null, + timeOfDay: ['morning'], + timingNote: null, + }, ], routine: [ - { id: 'r1', label: 'اندازه‌گیری فشار خون', timeOfDay: 'صبح', note: 'پیش از داروی فشار' }, - { id: 'r2', label: 'پیاده‌روی کوتاه', timeOfDay: 'عصر', note: null }, + { id: 'r1', label: 'اندازه‌گیری فشار خون', timeOfDay: ['morning'], note: 'پیش از داروی فشار' }, + { id: 'r2', label: 'پیاده‌روی کوتاه', timeOfDay: ['evening'], note: null }, ], tasks: [ { id: 't1', label: 'دادن متفورمین', done: false }, diff --git a/client/src/services/patientRecords/types.ts b/client/src/services/patientRecords/types.ts index 2b0aa9f..7c1b87e 100644 --- a/client/src/services/patientRecords/types.ts +++ b/client/src/services/patientRecords/types.ts @@ -11,6 +11,9 @@ import type { PageParams, Paginated } from '@/lib/api/types'; * 2. **The family-owned editable record** (داروها/روتین/وظایف — medications/routine/tasks) — **NO backend * exists** (neither the b14 contract nor the data model has it; **REQ-027**). The customer maintains it; * it is mocked behind this seam. The domain is therefore **mock-primary** (see `constants.ts`). + * ui-phase-9 added the structured shape (dose amount/unit, frequency preset codes, time-of-day codes) + * that replaced free-text dose/frequency/routine-time editing — recorded as a REQ-027 addendum, not a + * new REQ number, in `requests/for-backend.md`. * * Load-bearing rules (contract + phase §5): * - **Family-owned & patient-scoped.** The customer owns/edits medications/routine/tasks; the record @@ -31,12 +34,34 @@ export const CARE_RECORD_TABS: readonly CareRecordTab[] = ['medications', 'routi // ── Family-owned editable record (REQ-027 — customer-maintained, no backend) ────────────────────────────── +/** Structured dose unit codes (ui-phase-9 REQ-027 addendum) — stable codes, never a raw label on the wire. */ +export type DoseUnit = 'tablet' | 'capsule' | 'drop' | 'cc' | 'unit'; +export const DOSE_UNITS: readonly DoseUnit[] = ['tablet', 'capsule', 'drop', 'cc', 'unit'] as const; + +/** Frequency presets (ui-phase-9 REQ-027 addendum); `null` + `frequencyText` is the free-text fallback. */ +export type FrequencyPreset = 'once_daily' | 'twice_daily' | 'three_times_daily' | 'every_8_hours' | 'as_needed'; +export const FREQUENCY_PRESETS: readonly FrequencyPreset[] = [ + 'once_daily', + 'twice_daily', + 'three_times_daily', + 'every_8_hours', + 'as_needed', +] as const; + +/** Time-of-day chip codes (ui-phase-9 REQ-027 addendum) — shared by medications and routine items. */ +export type TimeOfDayCode = 'morning' | 'noon' | 'evening' | 'night'; +export const TIME_OF_DAY_CODES: readonly TimeOfDayCode[] = ['morning', 'noon', 'evening', 'night'] as const; + /** A medication the family tracks. `id` is a stable client id (the record has no server identity yet). */ export interface Medication { id: string; name: string; - dosage: string | null; - frequency: string; + doseAmount: string | null; + doseUnit: DoseUnit | null; + frequencyCode: FrequencyPreset | null; + /** Free-text fallback — used when no preset fits (`frequencyCode` is `null`). */ + frequencyText: string | null; + timeOfDay: TimeOfDayCode[]; timingNote: string | null; } @@ -44,7 +69,7 @@ export interface Medication { export interface RoutineItem { id: string; label: string; - timeOfDay: string | null; + timeOfDay: TimeOfDayCode[]; note: string | null; } diff --git a/client/src/theme/tokens.css b/client/src/theme/tokens.css index 093f109..404f1d0 100644 --- a/client/src/theme/tokens.css +++ b/client/src/theme/tokens.css @@ -119,6 +119,27 @@ AA at small sizes on white: #d98c6a ≈ 2:1). Never use --bal-secondary for small money text; use this instead. */ --bal-money-emphasis: #8a4a2f; + + /* Avatar — warm auto-colored initials (ui-phase-9). A person with no photo (a care-circle + patient, the account holder) gets a deterministic hue from this 6-pair palette (name hash → + index), never a hex baked into a component. Each pair is a distinct warm/calm hue harmonized + with the brand teal/terracotta, not reused from primary/secondary/feedback. */ + --bal-avatar-1: #2f6b5e; + --bal-avatar-1-contrast: #f3efe9; + --bal-avatar-2: #b56b3f; + --bal-avatar-2-contrast: #f3efe9; + --bal-avatar-3: #6b5b95; + --bal-avatar-3-contrast: #f3efe9; + --bal-avatar-4: #3f7690; + --bal-avatar-4-contrast: #f3efe9; + --bal-avatar-5: #8a6418; + --bal-avatar-5-contrast: #f3efe9; + --bal-avatar-6: #5c7a4a; + --bal-avatar-6-contrast: #f3efe9; + + /* Map pin drop-shadow — was a hard-coded rgba() in AddressMapPicker; a plain black shadow reads + the same in both schemes so it isn't teal-tinted like --bal-shadow-*. */ + --bal-pin-shadow: rgba(0, 0, 0, 0.35); } /* ── Dark scheme — OS-preference fallback for the cookie-less first visit ── */ @@ -170,6 +191,21 @@ --bal-trust-soft: rgba(127, 184, 230, 0.18); --bal-money-emphasis: #f0bfa3; + + --bal-avatar-1: #8fd2c1; + --bal-avatar-1-contrast: #06120f; + --bal-avatar-2: #e3a679; + --bal-avatar-2-contrast: #2a1a12; + --bal-avatar-3: #b3a4d6; + --bal-avatar-3-contrast: #1f1a2e; + --bal-avatar-4: #8fc4dc; + --bal-avatar-4-contrast: #08222b; + --bal-avatar-5: #e0b542; + --bal-avatar-5-contrast: #2a2107; + --bal-avatar-6: #9fc98a; + --bal-avatar-6-contrast: #142a0c; + + --bal-pin-shadow: rgba(0, 0, 0, 0.5); } } @@ -233,4 +269,20 @@ /* Money emphasis — lifted warm tone, readable on dark surfaces */ --bal-money-emphasis: #f0bfa3; + + /* Avatar — same lifted hues as the media-query fallback above (keep in sync) */ + --bal-avatar-1: #8fd2c1; + --bal-avatar-1-contrast: #06120f; + --bal-avatar-2: #e3a679; + --bal-avatar-2-contrast: #2a1a12; + --bal-avatar-3: #b3a4d6; + --bal-avatar-3-contrast: #1f1a2e; + --bal-avatar-4: #8fc4dc; + --bal-avatar-4-contrast: #08222b; + --bal-avatar-5: #e0b542; + --bal-avatar-5-contrast: #2a2107; + --bal-avatar-6: #9fc98a; + --bal-avatar-6-contrast: #142a0c; + + --bal-pin-shadow: rgba(0, 0, 0, 0.5); } diff --git a/client/src/utils/date.ts b/client/src/utils/date.ts index 8a9b3de..0f6d913 100644 --- a/client/src/utils/date.ts +++ b/client/src/utils/date.ts @@ -32,3 +32,12 @@ export function formatShamsiDateTime(iso: string | Date, locale: string = 'fa'): minute: '2-digit', }); } + +/** + * Month + year only (Shamsi for `fa`, Gregorian for `en`), e.g. «تیر ۱۴۰۳» — doubles as a stable + * group key for a month-grouped timeline (two timestamps in the same calendar month always format + * to the same string), so callers can group directly on this without separate Jalali arithmetic. + */ +export function formatShamsiMonthYear(iso: string | Date, locale: string = 'fa'): string { + return formatShamsiDate(iso, locale, { year: 'numeric', month: 'long' }); +} diff --git a/dev/shared-working-context/frontend/requests/for-backend.md b/dev/shared-working-context/frontend/requests/for-backend.md index 8b78e20..0d628e5 100644 --- a/dev/shared-working-context/frontend/requests/for-backend.md +++ b/dev/shared-working-context/frontend/requests/for-backend.md @@ -461,6 +461,19 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a until they land. **Note:** the wireframe's four-tab E2 record is not in the data model — please confirm whether the family-owned record is a real MVP entity or a future addition (the client treats it as forward-looking). - **Status:** delivered in refinement-phase-3 +- **ui-phase-9 addendum (2026-07-19):** the family-owned record's editing surface was rebuilt from a + whole-list free-text edit mode to per-item bottom sheets with **structured fields**, so the eventual + real table (whenever REQ-027's family-record half lands) should match this shape, not the original + free-text one quoted above: + - `Medication { id, name, doseAmount: string | null, doseUnit: 'tablet'|'capsule'|'drop'|'cc'|'unit' | + null, frequencyCode: 'once_daily'|'twice_daily'|'three_times_daily'|'every_8_hours'|'as_needed' | + null, frequencyText: string | null (free-text fallback when frequencyCode is null), + timeOfDay: ('morning'|'noon'|'evening'|'night')[], timingNote: string | null }`. + - `RoutineItem { id, label, timeOfDay: ('morning'|'noon'|'evening'|'night')[], note: string | null }` + (was a free-text `timeOfDay: string | null` — now the same stable chip codes as medications). + - `CareTask` is unchanged (`{ id, label, done }`). + - See `client/src/services/patientRecords/types.ts` for the authoritative current shape and + `apis/mockApi.ts` for the seeded example data. ## REQ-028 — Ticket inbox enrichment (unread + last-activity), message author name, by-booking lookup, optimistic idempotency — filed by frontend-phase-14-b15 — 2026-07-10 - **Need:** Four additions the f14 messaging UI renders that the b15 `TicketSummaryDto`/`TicketThreadDto`/message @@ -896,3 +909,28 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a doc's instruction); at authoring time no such read was found in the b6 contract or swagger snapshot. - **Status:** open — mock-only until served; the real path degrades to the pre-phase-8 blank-form behavior (documented, not a regression — just not yet as helpful as the mock demonstrates). + +## REQ-057 — Patient care metadata: `lastVisitAt` (+ optional `visitCount`) on the patient read model — filed by ui-phase-9 — 2026-07-19 +- **Need:** Add `lastVisitAt: string | null` (UTC ISO, the most recent completed visit) and optionally + `visitCount: number` to the patient read model served by `patients/list`/`patients/get` (`PatientDto`). +- **Why:** The care-circle card (phase §3.2.3) wants an «آخرین ویزیت: ۱۲ تیر» teaser per person, best-effort + from already-cached data if possible. It isn't possible: the customer booking-list row + (`BookingListItemDto`, `services/bookings/types.ts`) carries no `patientId` at all, so there is no + client-side join from the cached bookings list to a specific patient without an N+1 fetch per row (which + the client deliberately does not do — same rule as REQ-052's `variantLabel`). The `PatientCard` component + already accepts an optional `lastVisitLabel` prop for this (`client/src/components/PatientCard/PatientCard.tsx`) + but `patients/page.tsx` never populates it — the teaser is simply omitted today, never fabricated. +- **Proposed shape:** `PatientDto { …, lastVisitAt: string | null, visitCount?: number }`, populated from the + most recent `booking_sessions` (or booking) whose `patientId` matches, `status = completed`. +- **Status:** open — the card renders no teaser until this (or a `patientId` on `BookingListItemDto`) lands. + +## REQ-058 — Patient photo upload (optional, product-gated) — filed by ui-phase-9 — 2026-07-19 +- **Need:** An object-storage-backed avatar for a patient (mirroring nurse `avatarUrl` — REQ-006), e.g. + `avatarUrl: string | null` on `PatientDto` + an upload endpoint. +- **Why:** The care-circle reframe (phase §3.2.2) ships **warm auto-colored initials** for every person + (`InitialsAvatar`, deterministic per name, no backend needed) as the shipped solution — this REQ is filed + only so a future "real photo" upgrade has a tracked ask; it is explicitly optional and gated on product + wanting it (a family may not want to upload a photo of a parent/child at all). +- **Proposed shape:** `PatientDto { …, avatarUrl: string | null }` + `POST patients/{id}/avatar` (multipart), + same seam shape as the nurse profile's `uploadAvatar`. +- **Status:** deferred, non-blocking — no UI currently reads a patient `avatarUrl`; initials-only ships either way. diff --git a/dev/shared-working-context/reports/ui-phase-9-report.md b/dev/shared-working-context/reports/ui-phase-9-report.md new file mode 100644 index 0000000..40f221f --- /dev/null +++ b/dev/shared-working-context/reports/ui-phase-9-report.md @@ -0,0 +1,176 @@ +# UI Phase 9 — Customer account & care circle — Report (2026-07-19) + +## What was built + +### Account hub (`profile/page.tsx`) +- Rebuilt from a flat settings form into an account hub: `ProfileSummary` identity header (avatar/initials + via the new `initialsFallback` prop + name + server-masked phone), grouped tappable rows (اطلاعات شخصی / + مخاطب اضطراری / نشانی‌ها / زبان / اعلان‌ها / پشتیبانی / خروج), an emergency-contact status card (complete → + success check + `tel:`-only link; incomplete → a warm nudge), and a working خروج row (`ConfirmDialog` → + `useLogout()`, the single logout path). +- `/profile` stays the single route — اطلاعات شخصی/زبان/مخاطب اضطراری open `FormDialogShell` sheets, not + sub-routes. زبان's sheet hosts the phase-2 `LocaleSwitcher` (the actual UI locale switch) alongside the + server-stored `preferredLanguage` field — two distinct concerns, no second locale mechanism built. +- The silent-load-error/blank-form defect (§3.1.4 of the phase doc) was **already fixed** before this phase + started (confirmed against the current code, not the audit's snapshot — see "Docs updated" below); this + phase's profile work is the hub redesign, not that fix. +- `ActorSwitcher` preserved verbatim (renders nothing for a single-role session). + +### Care-circle reframe (`patients/page.tsx`, `PatientCard`, `PatientHeader`) +- **Naming decision:** «بیماران» → **«حلقهٔ مراقبت»** for the list/page title, nav label, and empty + states (the safer default per the audit, given the «خودم» self-care relation). Individual-action copy + (the add button, archive confirm) uses the neutral **«فرد»** ("person") rather than «عزیز» ("loved one"), + which reads oddly for self-care. This is a **copy-only** rename — the `/patients` route, `services/patients` + module, and every `patients.*`/`nav.patients` i18n **key name** are unchanged; only the translated + **values** changed (both `en.json` and `fa.json`). Applied consistently: page title, nav tab, empty + states, and the Home screen's `nudge_patient_*`/`patients_error` copy. +- **Avatars:** a new `InitialsAvatar` component (warm auto-colored initials, deterministic per-name hash → + one of 6 new `--bal-avatar-*` token pairs added to `tokens.css`, both scheme blocks) is now a slot on the + shared `PatientHeader`, so both the E1 card and the E2 record viewer got a face in one change. + `ProfileSummary` also gained an `initialsFallback` prop (same util) for the account hub's identity header. +- **Visible record affordance:** `PatientCard`'s invisible `component="button"` area is now a pressable + `ButtonBase` surface (hover/press + a trailing «forward» chevron) with an optional `lastVisitLabel` prop — + **never populated** (the cached customer bookings list, `BookingListItemDto`, carries no `patientId` at + all, so no client-side join is possible without an N+1 fetch per row; filed as REQ-057). +- Soft-archive semantics + copy kept (generalized wording, same behavior); `isError`/empty-state handling + was already correct (see note above) and is unchanged. + +### Care-record editing (`patients/[id]/record/page.tsx`) +- Replaced the whole-list free-text edit mode with **per-item bottom sheets** (`Drawer anchor="bottom"` on + mobile, `Dialog` on desktop, each with its own dirty-gated discard-confirm): medications get name + + structured **dose amount + unit** (قرص/کپسول/قطره/سی‌سی/واحد) + **frequency presets** + (روزی ۱/۲/۳ بار، هر ۸ ساعت، در صورت نیاز + a free-text fallback when no preset fits) + **time-of-day + chips** (stable codes `morning|noon|evening|night`); routine items get the same time-of-day chip row + instead of the old free-text `routine_time`; tasks get a per-item sheet too (label + done), with a quick + inline checkbox for toggling done without opening a sheet. Killing the whole-list mode removes the + silent tab-switch draft loss by construction (each sheet is its own short-lived mount). +- **REQ-027 addendum** recorded in `requests/for-backend.md` (not a new REQ number): the structured shape + (dose amount/unit, frequency preset codes, time-of-day codes) so the eventual real table matches what the + UI now collects. `services/patientRecords/types.ts` + `apis/mockApi.ts` updated to the new shape. +- **سوابق (history)** is now a Shamsi month-grouped timeline (a subtle rail), each `VisitNoteCard` gains a + done/undone task-summary line (`{done} از {total} انجام شد`) and, when `VisitNote.bookingId` is non-null, a + «مشاهده رزرو» link to `/bookings/[id]` — the service name is shown only when best-effort derivable from an + **already-cached** booking snapshot (`queryClient.getQueryData`, never fetched per-note — that would be an + N+1). Paging kept (grouped within a page). +- The access gate (`useRecordAccess` before any clinical fetch, the access-denied card, the ownership + banner) is untouched, verbatim. + +### Addresses + the real map (`addresses/page.tsx`, `components/geography/`) +- `isError` was **not** handled before this phase (confirmed against current code) — fixed: `ErrorState` + + retry, no more false-empty. +- **Real Neshan map behind the existing `AddressMapPicker` boundary.** `{ latitude, longitude }` in/out is + unchanged, so `AddressForm` needed zero changes. `AddressMapPicker` now branches on `NESHAN_WEB_KEY` + (`@/config`, from `NEXT_PUBLIC_NESHAN_KEY`): + - **Set:** dynamically imports (`next/dynamic`, `ssr:false`) a new `NeshanMap` component — Leaflet + (`leaflet` + `@types/leaflet`, newly added `npm` dependencies) with a Neshan raster tile layer, an + address search box (`services/geography/neshan.ts`'s `searchNeshan`), a locate-me button (browser + Geolocation), a draggable/tappable pin (a brand-colored `L.divIcon`, never Leaflet's default marker + images), and a reverse-geocoded preview line (`reverseGeocodeNeshan`) — raw coordinates are never + rendered. The container stays `dir="ltr"` (same RTL hazard `AddressMapPicker`'s own doc comment already + called out for the grid stand-in's marker). + - **Unset:** falls back to the original bounded-canvas grid stand-in, **kept verbatim** (including its + raw lat/lng captions — this is the preserved dev/CI/jsdom path, not the primary experience) except the + hard-coded `rgba()` pin drop-shadow is now the new `--bal-pin-shadow` token. + - **Honest flag (operating-rules §9 — external unknown, not stalled on):** the Neshan tile URL template + (`services/geography/constants.ts`'s `NESHAN_TILE_URL_TEMPLATE`) and the Search/Reverse response field + names (`services/geography/neshan.ts`) were set from Neshan's public documentation **without live + verification** — this sandbox has no network route to `platform.neshan.org`/`api.neshan.org` for a + fetch tool, only for `WebSearch`. Endpoints (`api.neshan.org/v1/search`, `api.neshan.org/v5/reverse`) + and the `Api-Key` header are confirmed via search-result snippets; the exact JSON field names + (`items[].location.x/y`, `formatted_address`) and the raster tile host/path are best-effort. Every read + is defensive (optional-chained, try/catch), so a shape drift degrades to "no results"/"no tiles" rather + than a crash. **Re-verify against the current Neshan developer portal before relying on this with a + real key.** +- `AddressCard` gained the pin-quality cue: «پین ثبت شده» / «پین ندارد» (warm warning tone) from + `Address.latitude`/`longitude` being non-null. +- Fixed the `line_hint` fa copy bug (`messages/fa.json`) — «برای یافتن در نیاز دارد» → «برای یافتن درِ منزل + نیاز دارد» — flagging it here so phase 12's global copy sweep doesn't double-edit it. +- Patient (`patients/page.tsx`) and address (`addresses/page.tsx`) forms now open in the new shared + `FormDialogShell` (`components/common/FormDialogShell/`): full-screen below the `sm` breakpoint (a single + scroll region, fixing the old double-scroll-against-keyboard defect), an app-bar header (title + close), + and a dirty-gated discard-confirm on close/backdrop/escape — `PatientForm`/`AddressForm` report `dirty` + via a new `onDirtyChange` prop (compares current fields to the `initial` prop). + +### Form quality +- `PatientForm`: the single guessed full-name field is now **نام** / **نام خانوادگی** (structured, the wire + already carries `firstName`/`lastName`); `lastName` falls back to `firstName` only when left blank (the + wire's `lastName` is a required string) — never a client-side guess-split. +- Verified no surface renders the fabricated Jan-1 `birthDate` — every display path already went through + `birthDateToAge` before this phase; age-only, as required. +- `GenderToggle` gained `width: '100%'` so its `flex: 1` children actually split evenly. +- `RelationSelect`'s selected state gained a check icon (+ a soft-tint background), no longer color-only + (WCAG 1.4.1). +- Profile's `AppLoading` full-page spinner replaced with a form-shaped `Skeleton` (identity header + row + placeholders), matching the loading language of patients/addresses/record. + +## What is now testable (and exactly how) + +1. Log in as a seeded customer (`0912000000x`) on a mobile viewport at `/fa`. Open the Profile tab → identity + header (initials + name + masked LTR phone), rows for اطلاعات شخصی/مخاطب اضطراری/نشانی‌ها/زبان/اعلان‌ها/ + پشتیبانی/خروج. Tap خروج → confirm → lands on `/login`, session revoked. +2. Clear the emergency contact (via the row's edit) → the incomplete-state warm nudge; fill it in → a + success check + `tel:` link. +3. Stop the API, reload `/profile`, `/patients`, `/addresses` → each shows `ErrorState` with retry (no + blank form, no false-empty). Start the API, tap retry → data returns without a full reload. +4. Open the care circle (`/patients`) → every person has a colored-initials avatar and a visible chevron + affordance; tap the card body → the record opens; the renamed «حلقهٔ مراقبت» title appears in the tab + bar and the page header. +5. In the record, tap "افزودن دارو" → the bottom sheet (mobile) with dose amount + unit, frequency preset + chips (+ free-text fallback when none is chosen), and صبح/ظهر/عصر/شب chips; save → the row shows the + structured summary. Switch tabs mid-edit and come back → nothing lost (the sheet only exists while open). + Try closing a dirty sheet → discard-confirm. +6. Open سوابق → notes grouped by Shamsi month on a rail; a note whose `bookingId` is set shows «مشاهده رزرو» + linking to `/bookings/[id]`. +7. Add an address on mobile → the form opens full-screen with an app-bar header. Without + `NEXT_PUBLIC_NESHAN_KEY` set (the default in this environment), the grid stand-in still works exactly as + before (raw lat/lng shown — the documented fallback). With a real key set, the real map (search box, + locate-me, draggable pin, reverse-geocoded preview) should appear instead — **not verified end-to-end + in this sandbox** (no real Neshan key available; see the honest flag above). Back on the list, an + address without a pin shows «پین ندارد»; one with a pin shows «پین ثبت شده». +8. Repeat 1, 4, 5, 7 on `/en` (LTR) and in dark mode. + +## What is mocked / waiting on a real service + +- `patientRecords` stays **mock-primary** for the family-owned record (REQ-027 — unchanged domain + boundary); this phase only changed the **shape** collected/persisted by the mock (see the REQ-027 + addendum above), not the mock/real seam itself. +- The Neshan web map is **client config**, not a backend seam (per the phase doc: "not a backend seam — + with a grid-stand-in fallback when unset") — no `mocks-registry.md` entry filed; the fallback grid *is* + the registered dev/CI behavior, already documented in `AddressMapPicker`'s own doc comment. +- `services/geography/neshan.ts`'s tile URL and response-shape assumptions are unverified against live + Neshan documentation (see the honest flag above) — this is a **content-accuracy risk to re-check before + production use with a real key**, not a mock. + +## Contracts + +- Consumed: no new backend contract this phase (the touched domains — profiles/patients/addresses/ + patientRecords — already had their contracts from earlier phases). +- Filed to `dev/shared-working-context/frontend/requests/for-backend.md`: + - **REQ-027 addendum** (not a new number) — the structured medication/routine field shape. + - **REQ-057** — `lastVisitAt` (+ optional `visitCount`) on the patient read model (`PatientDto`). + - **REQ-058** (deferred, non-blocking) — optional patient photo upload; the shipped solution is + initials-only and needs no backend. + +## Docs updated + +- `client/CLAUDE.md` "Project Structure": `profile/`, `patients/`, `patients/[id]/record/`, `addresses/` + route notes; the `components/common/` list (new `InitialsAvatar/`, `FormDialogShell/`); `PatientForm`, + `PatientCard`, `PatientHeader`, `ProfileSummary`, and `geography/` component bullets. +- `client/.env.sample`: documented `NEXT_PUBLIC_NESHAN_KEY` (the client web key — separate from the + server's `NeshanGeocoder` key, which lives in server config and is never touched here). +- **Correction to the phase's own premise:** the audit (`dev/post-phase/ui/audit/customer-account.md`, + authored 2026-07-17 13:22) predates a same-day `ui phase 2` commit (2026-07-17 19:05) that already added + the `isError`/`ErrorState` handling the audit describes as missing in `profile/page.tsx` and + `patients/page.tsx`. Verified against the current code (not re-derived from the stale audit text) before + starting — `addresses/page.tsx` genuinely still lacked it and was fixed in this phase. Flagging this so a + future phase doesn't re-"fix" an already-fixed defect based on the audit's original wording. + +## Follow-ups for later phases + +- Re-verify `services/geography/neshan.ts` + `NESHAN_TILE_URL_TEMPLATE` against the current + `platform.neshan.org` developer docs once a real web key is available to test against. +- REQ-057 (`lastVisitAt`) — once served, wire `PatientCard`'s existing `lastVisitLabel` prop in + `patients/page.tsx`. +- Phase 12's global copy sweep: the `address.line_hint` fa fix already landed here — don't re-edit it. +- A read-mode "daily schedule" view (meds grouped صبح/ظهر/شب) and an `AddressCard` static-map thumbnail are + both explicitly deferred per the phase doc (§3.3.3, §3.4.3) — not started.