ui phase 9
This commit is contained in:
+7
-1
@@ -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
|
||||
# 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
|
||||
+11
-9
@@ -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)
|
||||
|
||||
+71
-30
@@ -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",
|
||||
|
||||
+73
-32
@@ -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": "بعدی",
|
||||
|
||||
Generated
+25
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<CustomerAddress | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<CustomerAddress | null>(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 (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
@@ -91,7 +92,7 @@ export default function AddressesPage() {
|
||||
{t('subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
{!isEmpty ? (
|
||||
{!isEmpty && !isError ? (
|
||||
<AppButton color="primary" variant="contained" startIcon="add" onClick={openAdd} sx={{ flexShrink: 0 }}>
|
||||
{t('add')}
|
||||
</AppButton>
|
||||
@@ -104,6 +105,8 @@ export default function AddressesPage() {
|
||||
<Skeleton key={key} variant="rounded" height={104} />
|
||||
))}
|
||||
</Stack>
|
||||
) : isError ? (
|
||||
<ErrorState message={t('load_error')} retryLabel={tc('retry')} onRetry={() => refetch()} />
|
||||
) : isEmpty ? (
|
||||
<EmptyState
|
||||
icon="location"
|
||||
@@ -125,6 +128,9 @@ export default function AddressesPage() {
|
||||
addressLine={address.addressLine}
|
||||
isPrimary={address.isPrimary}
|
||||
primaryLabel={t('primary')}
|
||||
hasPin={address.latitude != null && address.longitude != null}
|
||||
pinSetLabel={t('pin_set')}
|
||||
pinMissingLabel={t('pin_missing')}
|
||||
onEdit={() => openEdit(address)}
|
||||
onDelete={() => setDeleteTarget(address)}
|
||||
onSetPrimary={() =>
|
||||
@@ -142,50 +148,50 @@ export default function AddressesPage() {
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Dialog open={formOpen} onClose={closeForm} fullWidth maxWidth="sm">
|
||||
<DialogTitle>{editing ? t('edit_title') : t('add_title')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ pt: 1 }}>
|
||||
<AddressForm
|
||||
key={editing?.id ?? 'new'}
|
||||
initial={
|
||||
editing
|
||||
? {
|
||||
title: editing.title,
|
||||
provinceId: editing.provinceId,
|
||||
cityId: editing.cityId,
|
||||
districtId: editing.districtId,
|
||||
addressLine: editing.addressLine,
|
||||
latitude: editing.latitude,
|
||||
longitude: editing.longitude,
|
||||
isPrimary: editing.isPrimary,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
submitting={createAddress.isPending || updateAddress.isPending}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={closeForm}
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<FormDialogShell
|
||||
open={formOpen}
|
||||
title={editing ? t('edit_title') : t('add_title')}
|
||||
dirty={formDirty}
|
||||
onClose={closeForm}
|
||||
closeLabel={tc('close')}
|
||||
discardTitle={tc('discard_title')}
|
||||
discardBody={tc('discard_body')}
|
||||
discardConfirmLabel={tc('discard_confirm')}
|
||||
discardCancelLabel={tc('cancel')}
|
||||
>
|
||||
<AddressForm
|
||||
key={editing?.id ?? 'new'}
|
||||
initial={
|
||||
editing
|
||||
? {
|
||||
title: editing.title,
|
||||
provinceId: editing.provinceId,
|
||||
cityId: editing.cityId,
|
||||
districtId: editing.districtId,
|
||||
addressLine: editing.addressLine,
|
||||
latitude: editing.latitude,
|
||||
longitude: editing.longitude,
|
||||
isPrimary: editing.isPrimary,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
submitting={createAddress.isPending || updateAddress.isPending}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={closeForm}
|
||||
onDirtyChange={setFormDirty}
|
||||
/>
|
||||
</FormDialogShell>
|
||||
|
||||
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)}>
|
||||
<DialogTitle>{t('delete_title')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('delete_body')}
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<AppButton variant="text" onClick={() => setDeleteTarget(null)}>
|
||||
{tc('cancel')}
|
||||
</AppButton>
|
||||
<AppButton color="error" variant="contained" onClick={confirmDelete}>
|
||||
{t('delete_confirm')}
|
||||
</AppButton>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
<ConfirmDialog
|
||||
open={Boolean(deleteTarget)}
|
||||
title={t('delete_title')}
|
||||
body={t('delete_body')}
|
||||
confirmLabel={t('delete_confirm')}
|
||||
cancelLabel={tc('cancel')}
|
||||
confirmColor="error"
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
onConfirm={confirmDelete}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
+651
-229
@@ -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 <MedicationsTab data={data.medications} canEdit={canEdit} saving={update.isPending} onSave={(meds, done) => save({ medications: meds }, done)} />;
|
||||
return (
|
||||
<MedicationsTab
|
||||
data={data.medications}
|
||||
canEdit={canEdit}
|
||||
saving={update.isPending}
|
||||
onSave={(meds, done) => save({ medications: meds }, done)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (tab === 'routine') {
|
||||
return <RoutineTab data={data.routine} canEdit={canEdit} saving={update.isPending} onSave={(items, done) => save({ routine: items }, done)} />;
|
||||
return (
|
||||
<RoutineTab data={data.routine} canEdit={canEdit} saving={update.isPending} onSave={(items, done) => save({ routine: items }, done)} />
|
||||
);
|
||||
}
|
||||
return <TasksTab data={data.tasks} canEdit={canEdit} saving={update.isPending} onSave={(tasks, done) => 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 = (
|
||||
<Box sx={{ p: 2.5, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Stack direction="row" sx={{ alignItems: 'center' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, flexGrow: 1 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
<AppButton variant="text" color="inherit" onClick={requestClose} aria-label={t('close')} sx={{ minWidth: 0, p: 1 }}>
|
||||
<AppIcon icon="close" size={20} />
|
||||
</AppButton>
|
||||
</Stack>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{mobile ? (
|
||||
<Drawer
|
||||
anchor="bottom"
|
||||
open={open}
|
||||
onClose={requestClose}
|
||||
slotProps={{ paper: { sx: { borderTopLeftRadius: 'var(--bal-radius-lg)', borderTopRightRadius: 'var(--bal-radius-lg)', maxHeight: '88vh' } } }}
|
||||
>
|
||||
{body}
|
||||
</Drawer>
|
||||
) : (
|
||||
<Dialog open={open} onClose={requestClose} fullWidth maxWidth="xs">
|
||||
{body}
|
||||
</Dialog>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
open={discardOpen}
|
||||
title={t('discard_title')}
|
||||
body={t('discard_body')}
|
||||
confirmLabel={t('discard_confirm')}
|
||||
cancelLabel={t('cancel')}
|
||||
confirmColor="error"
|
||||
onConfirm={() => {
|
||||
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 (
|
||||
<Stack direction="row" sx={{ gap: 1, justifyContent: onDelete ? 'space-between' : 'flex-end', alignItems: 'center' }}>
|
||||
{onDelete ? (
|
||||
<AppButton variant="text" color="error" onClick={onDelete} disabled={saving}>
|
||||
{t('remove')}
|
||||
</AppButton>
|
||||
) : null}
|
||||
<Stack direction="row" sx={{ gap: 1 }}>
|
||||
<AppButton variant="text" onClick={onCancel} disabled={saving}>
|
||||
{tc('cancel')}
|
||||
</AppButton>
|
||||
<AppButton variant="contained" color="primary" onClick={onSave} disabled={saving || !canSave}>
|
||||
{saving ? tc('saving') : tc('save')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// A tappable row surface shared by the three editable tabs — mirrors PatientCard's press affordance.
|
||||
function RowCard({ onOpen, children }: { onOpen?: () => void; children: ReactNode }) {
|
||||
return (
|
||||
<Paper elevation={0} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, overflow: 'hidden' }}>
|
||||
{onOpen ? (
|
||||
<AppButton
|
||||
variant="text"
|
||||
color="inherit"
|
||||
onClick={onOpen}
|
||||
sx={{ width: '100%', p: 1.5, justifyContent: 'flex-start', textAlign: 'start', borderRadius: 0, '&:hover': { bgcolor: 'action.hover' } }}
|
||||
>
|
||||
<Stack direction="row" sx={{ alignItems: 'center', gap: 1.5, width: '100%' }}>
|
||||
<Box sx={{ flexGrow: 1, minWidth: 0 }}>{children}</Box>
|
||||
<AppIcon icon="forward" size={18} color="var(--bal-text-secondary)" />
|
||||
</Stack>
|
||||
</AppButton>
|
||||
) : (
|
||||
<Box sx={{ p: 1.5 }}>{children}</Box>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function TimeOfDayChipRow({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
value: TimeOfDayCode[];
|
||||
onChange: (next: TimeOfDayCode[]) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const t = useTranslations('records');
|
||||
return (
|
||||
<ToggleButtonGroup
|
||||
value={value}
|
||||
onChange={(_, next: TimeOfDayCode[]) => 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) => (
|
||||
<ToggleButton key={code} value={code} size="small" sx={{ textTransform: 'none', px: 1.5, borderRadius: '999px' }}>
|
||||
{t(`time_${code}`)}
|
||||
</ToggleButton>
|
||||
))}
|
||||
</ToggleButtonGroup>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Medications ───────────────────────────────────────────────────────────────────────────────────────────
|
||||
function medicationSummary(m: Medication, t: ReturnType<typeof useTranslations>): 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<Medication[]>(data);
|
||||
const [sheetItem, setSheetItem] = useState<Medication | 'new' | null>(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<Medication>) => 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 (
|
||||
<SectionShell canEdit={canEdit} onEdit={startEdit} empty={data.length === 0} emptyLabel={t('medications_empty')}>
|
||||
{data.map((m) => (
|
||||
<Paper key={m.id} elevation={0} sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Stack direction="row" sx={{ alignItems: 'center', gap: 1 }}>
|
||||
<AppIcon icon="medication" size={20} color="var(--bal-primary)" />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{m.name}
|
||||
{m.dosage ? ` — ${m.dosage}` : ''}
|
||||
</Typography>
|
||||
</Stack>
|
||||
{(m.frequency || m.timingNote) && (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
|
||||
{[m.frequency, m.timingNote].filter(Boolean).join(' · ')}
|
||||
</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
))}
|
||||
</SectionShell>
|
||||
);
|
||||
}
|
||||
const handleDelete = (id: string) => onSave(data.filter((m) => m.id !== id), close);
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
{draft.map((m) => (
|
||||
<Paper key={m.id} elevation={0} sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<Stack direction="row" sx={{ gap: 1 }}>
|
||||
<TextField label={t('med_name')} value={m.name} onChange={(e) => update(m.id, { name: e.target.value })} size="small" fullWidth required />
|
||||
<IconButton aria-label={t('remove')} onClick={() => remove(m.id)} size="small">
|
||||
<AppIcon icon="delete" size={20} color="var(--bal-error)" />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
|
||||
<TextField label={t('med_dosage')} value={m.dosage ?? ''} onChange={(e) => update(m.id, { dosage: e.target.value || null })} size="small" sx={{ flex: 1, minWidth: 120 }} />
|
||||
<TextField label={t('med_frequency')} value={m.frequency} onChange={(e) => update(m.id, { frequency: e.target.value })} size="small" sx={{ flex: 1, minWidth: 120 }} />
|
||||
</Stack>
|
||||
<TextField label={t('med_timing')} value={m.timingNote ?? ''} onChange={(e) => update(m.id, { timingNote: e.target.value || null })} size="small" fullWidth />
|
||||
</Stack>
|
||||
</Paper>
|
||||
))}
|
||||
<EditActions
|
||||
onAdd={add}
|
||||
onCancel={() => setEditing(false)}
|
||||
onSave={() => onSave(draft, () => setEditing(false))}
|
||||
saving={saving}
|
||||
canSave={canSave}
|
||||
/>
|
||||
{data.length === 0 ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('medications_empty')}
|
||||
</Typography>
|
||||
) : (
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
{data.map((m) => (
|
||||
<RowCard key={m.id} onOpen={canEdit ? () => setSheetItem(m) : undefined}>
|
||||
<Stack direction="row" sx={{ alignItems: 'center', gap: 1 }}>
|
||||
<AppIcon icon="medication" size={20} color="var(--bal-primary)" />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{m.name}
|
||||
</Typography>
|
||||
</Stack>
|
||||
{medicationSummary(m, t) ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
|
||||
{medicationSummary(m, t)}
|
||||
</Typography>
|
||||
) : null}
|
||||
{m.timeOfDay.length > 0 ? (
|
||||
<Stack direction="row" sx={{ gap: 0.5, mt: 0.5, flexWrap: 'wrap' }}>
|
||||
{m.timeOfDay.map((code) => (
|
||||
<Typography
|
||||
key={code}
|
||||
variant="caption"
|
||||
sx={{ px: 1, py: 0.25, borderRadius: '999px', bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)' }}
|
||||
>
|
||||
{t(`time_${code}`)}
|
||||
</Typography>
|
||||
))}
|
||||
</Stack>
|
||||
) : null}
|
||||
</RowCard>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{canEdit ? (
|
||||
<AppButton variant="outlined" color="primary" startIcon="add" onClick={() => setSheetItem('new')} sx={{ alignSelf: 'flex-start' }}>
|
||||
{t('add_medication')}
|
||||
</AppButton>
|
||||
) : null}
|
||||
|
||||
<RecordItemSheet open={sheetItem != null} title={sheetItem === 'new' ? t('add_medication') : t('edit_medication')} dirty={sheetDirty} onClose={close}>
|
||||
{sheetItem != null ? (
|
||||
<MedicationSheetBody
|
||||
initial={sheetItem === 'new' ? null : sheetItem}
|
||||
saving={saving}
|
||||
onCancel={close}
|
||||
onSave={handleSave}
|
||||
onDelete={sheetItem !== 'new' ? () => handleDelete((sheetItem as Medication).id) : undefined}
|
||||
onDirtyChange={setSheetDirty}
|
||||
/>
|
||||
) : null}
|
||||
</RecordItemSheet>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
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<DoseUnit | ''>(initial?.doseUnit ?? '');
|
||||
const [frequencyCode, setFrequencyCode] = useState<FrequencyPreset | null>(initial?.frequencyCode ?? null);
|
||||
const [frequencyText, setFrequencyText] = useState(initial?.frequencyText ?? '');
|
||||
const [timeOfDay, setTimeOfDay] = useState<TimeOfDayCode[]>(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 (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<TextField label={t('med_name')} value={name} onChange={(e) => setName(e.target.value)} fullWidth required autoFocus />
|
||||
|
||||
<Stack direction="row" sx={{ gap: 1.5 }}>
|
||||
<TextField
|
||||
label={t('med_dose_amount')}
|
||||
value={doseAmount}
|
||||
onChange={(e) => setDoseAmount(e.target.value)}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
<TextField select label={t('med_dose_unit')} value={doseUnit} onChange={(e) => setDoseUnit(e.target.value as DoseUnit)} sx={{ flex: 1 }}>
|
||||
<MenuItem value="">{t('med_dose_unit_none')}</MenuItem>
|
||||
{DOSE_UNITS.map((unit) => (
|
||||
<MenuItem key={unit} value={unit}>
|
||||
{t(`dose_unit_${unit}`)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
</Stack>
|
||||
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('med_frequency')}
|
||||
</Typography>
|
||||
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
|
||||
{FREQUENCY_PRESETS.map((preset) => (
|
||||
<AppButton
|
||||
key={preset}
|
||||
variant={frequencyCode === preset ? 'contained' : 'outlined'}
|
||||
color="primary"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setFrequencyCode(frequencyCode === preset ? null : preset);
|
||||
if (frequencyCode !== preset) setFrequencyText('');
|
||||
}}
|
||||
sx={{ borderRadius: '999px' }}
|
||||
>
|
||||
{t(`frequency_${preset}`)}
|
||||
</AppButton>
|
||||
))}
|
||||
</Stack>
|
||||
{!frequencyCode ? (
|
||||
<TextField
|
||||
label={t('med_frequency_text')}
|
||||
value={frequencyText}
|
||||
onChange={(e) => setFrequencyText(e.target.value)}
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('time_of_day')}
|
||||
</Typography>
|
||||
<TimeOfDayChipRow value={timeOfDay} onChange={setTimeOfDay} />
|
||||
</Stack>
|
||||
|
||||
<TextField label={t('med_timing')} value={timingNote} onChange={(e) => setTimingNote(e.target.value)} fullWidth size="small" />
|
||||
|
||||
<SheetActions onCancel={onCancel} onSave={handleSave} onDelete={onDelete} saving={saving} canSave={canSave} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -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<RoutineItem[]>(data);
|
||||
const [sheetItem, setSheetItem] = useState<RoutineItem | 'new' | null>(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<RoutineItem>) => 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 (
|
||||
<SectionShell canEdit={canEdit} onEdit={startEdit} empty={data.length === 0} emptyLabel={t('routine_empty')}>
|
||||
{data.map((r) => (
|
||||
<Paper key={r.id} elevation={0} sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Stack direction="row" sx={{ alignItems: 'center', gap: 1 }}>
|
||||
<AppIcon icon="routine" size={20} color="var(--bal-primary)" />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{r.label}
|
||||
{r.timeOfDay ? ` — ${r.timeOfDay}` : ''}
|
||||
</Typography>
|
||||
</Stack>
|
||||
{r.note ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
|
||||
{r.note}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Paper>
|
||||
))}
|
||||
</SectionShell>
|
||||
);
|
||||
}
|
||||
const handleDelete = (id: string) => onSave(data.filter((r) => r.id !== id), close);
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
{draft.map((r) => (
|
||||
<Paper key={r.id} elevation={0} sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<Stack direction="row" sx={{ gap: 1 }}>
|
||||
<TextField label={t('routine_label')} value={r.label} onChange={(e) => update(r.id, { label: e.target.value })} size="small" fullWidth required />
|
||||
<IconButton aria-label={t('remove')} onClick={() => remove(r.id)} size="small">
|
||||
<AppIcon icon="delete" size={20} color="var(--bal-error)" />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
|
||||
<TextField label={t('routine_time')} value={r.timeOfDay ?? ''} onChange={(e) => update(r.id, { timeOfDay: e.target.value || null })} size="small" sx={{ flex: 1, minWidth: 120 }} />
|
||||
<TextField label={t('routine_note')} value={r.note ?? ''} onChange={(e) => update(r.id, { note: e.target.value || null })} size="small" sx={{ flex: 2, minWidth: 120 }} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
))}
|
||||
<EditActions onAdd={add} onCancel={() => setEditing(false)} onSave={() => onSave(draft, () => setEditing(false))} saving={saving} canSave={canSave} />
|
||||
{data.length === 0 ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('routine_empty')}
|
||||
</Typography>
|
||||
) : (
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
{data.map((r) => (
|
||||
<RowCard key={r.id} onOpen={canEdit ? () => setSheetItem(r) : undefined}>
|
||||
<Stack direction="row" sx={{ alignItems: 'center', gap: 1 }}>
|
||||
<AppIcon icon="routine" size={20} color="var(--bal-primary)" />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{r.label}
|
||||
</Typography>
|
||||
</Stack>
|
||||
{r.timeOfDay.length > 0 ? (
|
||||
<Stack direction="row" sx={{ gap: 0.5, mt: 0.5, flexWrap: 'wrap' }}>
|
||||
{r.timeOfDay.map((code) => (
|
||||
<Typography
|
||||
key={code}
|
||||
variant="caption"
|
||||
sx={{ px: 1, py: 0.25, borderRadius: '999px', bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)' }}
|
||||
>
|
||||
{t(`time_${code}`)}
|
||||
</Typography>
|
||||
))}
|
||||
</Stack>
|
||||
) : null}
|
||||
{r.note ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
|
||||
{r.note}
|
||||
</Typography>
|
||||
) : null}
|
||||
</RowCard>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{canEdit ? (
|
||||
<AppButton variant="outlined" color="primary" startIcon="add" onClick={() => setSheetItem('new')} sx={{ alignSelf: 'flex-start' }}>
|
||||
{t('add_routine')}
|
||||
</AppButton>
|
||||
) : null}
|
||||
|
||||
<RecordItemSheet open={sheetItem != null} title={sheetItem === 'new' ? t('add_routine') : t('edit_routine')} dirty={sheetDirty} onClose={close}>
|
||||
{sheetItem != null ? (
|
||||
<RoutineSheetBody
|
||||
initial={sheetItem === 'new' ? null : sheetItem}
|
||||
saving={saving}
|
||||
onCancel={close}
|
||||
onSave={handleSave}
|
||||
onDelete={sheetItem !== 'new' ? () => handleDelete((sheetItem as RoutineItem).id) : undefined}
|
||||
onDirtyChange={setSheetDirty}
|
||||
/>
|
||||
) : null}
|
||||
</RecordItemSheet>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
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<TimeOfDayCode[]>(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 (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<TextField label={t('routine_label')} value={label} onChange={(e) => setLabel(e.target.value)} fullWidth required autoFocus />
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('time_of_day')}
|
||||
</Typography>
|
||||
<TimeOfDayChipRow value={timeOfDay} onChange={setTimeOfDay} />
|
||||
</Stack>
|
||||
<TextField label={t('routine_note')} value={note} onChange={(e) => setNote(e.target.value)} fullWidth size="small" />
|
||||
<SheetActions onCancel={onCancel} onSave={handleSave} onDelete={onDelete} saving={saving} canSave={canSave} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -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<CareTask[]>(data);
|
||||
const [sheetItem, setSheetItem] = useState<CareTask | 'new' | null>(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<CareTask>) => 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 (
|
||||
<SectionShell canEdit={canEdit} onEdit={startEdit} empty={data.length === 0} emptyLabel={t('tasks_empty')}>
|
||||
{data.map((task) => (
|
||||
<Paper key={task.id} elevation={0} sx={{ p: 1.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Stack direction="row" sx={{ alignItems: 'center', gap: 1 }}>
|
||||
<AppIcon icon={task.done ? 'verified' : 'tasks'} size={20} color={task.done ? 'var(--bal-success)' : 'var(--bal-text-secondary)'} />
|
||||
<Typography variant="body2" sx={{ color: task.done ? 'text.secondary' : undefined }}>
|
||||
{task.label}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Paper>
|
||||
))}
|
||||
</SectionShell>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
{draft.map((task) => (
|
||||
<Paper key={task.id} elevation={0} sx={{ p: 1, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Stack direction="row" sx={{ alignItems: 'center', gap: 1 }}>
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={task.done} onChange={(e) => update(task.id, { done: e.target.checked })} />}
|
||||
label=""
|
||||
sx={{ m: 0 }}
|
||||
aria-label={t('task_done')}
|
||||
/>
|
||||
<TextField label={t('task_label')} value={task.label} onChange={(e) => update(task.id, { label: e.target.value })} size="small" fullWidth required />
|
||||
<IconButton aria-label={t('remove')} onClick={() => remove(task.id)} size="small">
|
||||
<AppIcon icon="delete" size={20} color="var(--bal-error)" />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
</Paper>
|
||||
))}
|
||||
<EditActions onAdd={add} onCancel={() => setEditing(false)} onSave={() => onSave(draft, () => setEditing(false))} saving={saving} canSave={canSave} />
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
{data.length === 0 ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('tasks_empty')}
|
||||
</Typography>
|
||||
) : (
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
{data.map((task) => (
|
||||
<Paper key={task.id} elevation={0} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, p: 1 }}>
|
||||
<Stack direction="row" sx={{ alignItems: 'center', gap: 1 }}>
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={task.done} onChange={() => (canEdit ? toggleDone(task) : undefined)} disabled={!canEdit || saving} />}
|
||||
label=""
|
||||
sx={{ m: 0 }}
|
||||
aria-label={t('task_done')}
|
||||
/>
|
||||
<AppButton
|
||||
variant="text"
|
||||
color="inherit"
|
||||
disabled={!canEdit}
|
||||
onClick={() => setSheetItem(task)}
|
||||
sx={{ flexGrow: 1, justifyContent: 'flex-start', textAlign: 'start', minWidth: 0 }}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: task.done ? 'text.secondary' : undefined }}>
|
||||
{task.label}
|
||||
</Typography>
|
||||
</AppButton>
|
||||
{canEdit ? <AppIcon icon="forward" size={16} color="var(--bal-text-secondary)" /> : null}
|
||||
</Stack>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{canEdit ? (
|
||||
<AppButton variant="outlined" color="primary" startIcon="add" onClick={() => setSheetItem('new')} sx={{ alignSelf: 'flex-start' }}>
|
||||
{t('add_task')}
|
||||
</AppButton>
|
||||
) : null}
|
||||
|
||||
<RecordItemSheet open={sheetItem != null} title={sheetItem === 'new' ? t('add_task') : t('edit_task')} dirty={sheetDirty} onClose={close}>
|
||||
{sheetItem != null ? (
|
||||
<TaskSheetBody
|
||||
initial={sheetItem === 'new' ? null : sheetItem}
|
||||
saving={saving}
|
||||
onCancel={close}
|
||||
onSave={handleSave}
|
||||
onDelete={sheetItem !== 'new' ? () => handleDelete((sheetItem as CareTask).id) : undefined}
|
||||
onDirtyChange={setSheetDirty}
|
||||
/>
|
||||
) : null}
|
||||
</RecordItemSheet>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<TextField label={t('task_label')} value={label} onChange={(e) => setLabel(e.target.value)} fullWidth required autoFocus />
|
||||
<FormControlLabel control={<Checkbox checked={done} onChange={(e) => setDone(e.target.checked)} />} label={t('task_done')} />
|
||||
<SheetActions
|
||||
onCancel={onCancel}
|
||||
onSave={() => onSave({ id: initial?.id ?? `new-${Date.now()}`, label: label.trim(), done })}
|
||||
onDelete={onDelete}
|
||||
saving={saving}
|
||||
canSave={canSave}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
{items.map((note) => (
|
||||
<VisitNoteCard key={note.id} note={note} dateLabel={formatShamsiDate(note.recordedAt, locale)} authorFallback={t('author_fallback')} />
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
{groups.map((group) => (
|
||||
<Stack key={group.month} direction="row" sx={{ gap: 1.5 }}>
|
||||
<Stack sx={{ alignItems: 'center', pt: 0.5 }}>
|
||||
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: 'var(--bal-primary)' }} />
|
||||
<Box sx={{ width: '1px', flexGrow: 1, bgcolor: 'divider', mt: 0.5 }} />
|
||||
</Stack>
|
||||
<Stack sx={{ gap: 1.5, pb: 1, flexGrow: 1, minWidth: 0 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: 'text.secondary' }}>
|
||||
{group.month}
|
||||
</Typography>
|
||||
{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 (
|
||||
<VisitNoteCard
|
||||
key={note.id}
|
||||
note={note}
|
||||
dateLabel={formatShamsiDate(note.recordedAt, locale)}
|
||||
authorFallback={t('author_fallback')}
|
||||
taskSummaryLabel={note.taskResults.length > 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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Stack>
|
||||
))}
|
||||
{totalPages > 1 ? (
|
||||
<Stack direction="row" sx={{ gap: 1, justifyContent: 'center', alignItems: 'center' }}>
|
||||
@@ -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 (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
{canEdit ? (
|
||||
<AppButton variant="text" color="primary" startIcon="edit" onClick={onEdit} sx={{ alignSelf: 'flex-end' }}>
|
||||
{tc('edit')}
|
||||
</AppButton>
|
||||
) : null}
|
||||
{empty ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{emptyLabel}
|
||||
</Typography>
|
||||
) : (
|
||||
<Stack sx={{ gap: 1 }}>{children}</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<AppButton variant="outlined" color="primary" startIcon="add" onClick={onAdd} sx={{ alignSelf: 'flex-start' }}>
|
||||
{t('add_item')}
|
||||
</AppButton>
|
||||
<Stack direction="row" sx={{ gap: 1, justifyContent: 'flex-end' }}>
|
||||
<AppButton variant="text" color="primary" onClick={onCancel} disabled={saving}>
|
||||
{tc('cancel')}
|
||||
</AppButton>
|
||||
<AppButton variant="contained" color="primary" onClick={onSave} disabled={saving || !canSave}>
|
||||
{saving ? tc('saving') : tc('save')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function BackToPatients() {
|
||||
const t = useTranslations('records');
|
||||
const router = useRouter();
|
||||
|
||||
@@ -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<Patient | null>(null);
|
||||
const [archiveTarget, setArchiveTarget] = useState<Patient | null>(null);
|
||||
|
||||
@@ -143,50 +135,52 @@ export default function PatientsPage() {
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Dialog open={formOpen} onClose={closeForm} fullWidth maxWidth="sm">
|
||||
<DialogTitle>{editing ? t('edit_title') : t('add_title')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ pt: 1 }}>
|
||||
<PatientForm
|
||||
key={editing?.id ?? 'new'}
|
||||
initial={
|
||||
editing
|
||||
? {
|
||||
displayName: editing.displayName,
|
||||
birthDate: editing.birthDate,
|
||||
gender: editing.gender,
|
||||
conditions: editing.conditions,
|
||||
relation: editing.relation,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
showRelation
|
||||
submitLabel={tc('save')}
|
||||
submitting={createPatient.isPending || updatePatient.isPending}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={closeForm}
|
||||
cancelLabel={tc('cancel')}
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<FormDialogShell
|
||||
open={formOpen}
|
||||
title={editing ? t('edit_title') : t('add_title')}
|
||||
dirty={formDirty}
|
||||
onClose={closeForm}
|
||||
closeLabel={tc('close')}
|
||||
discardTitle={tc('discard_title')}
|
||||
discardBody={tc('discard_body')}
|
||||
discardConfirmLabel={tc('discard_confirm')}
|
||||
discardCancelLabel={tc('cancel')}
|
||||
>
|
||||
<PatientForm
|
||||
key={editing?.id ?? 'new'}
|
||||
initial={
|
||||
editing
|
||||
? {
|
||||
displayName: editing.displayName,
|
||||
firstName: editing.firstName,
|
||||
lastName: editing.lastName,
|
||||
birthDate: editing.birthDate,
|
||||
gender: editing.gender,
|
||||
conditions: editing.conditions,
|
||||
relation: editing.relation,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
showRelation
|
||||
submitLabel={tc('save')}
|
||||
submitting={createPatient.isPending || updatePatient.isPending}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={closeForm}
|
||||
cancelLabel={tc('cancel')}
|
||||
onDirtyChange={setFormDirty}
|
||||
/>
|
||||
</FormDialogShell>
|
||||
|
||||
<Dialog open={Boolean(archiveTarget)} onClose={() => setArchiveTarget(null)}>
|
||||
<DialogTitle>{t('archive_title')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('archive_body')}
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<AppButton variant="text" onClick={() => setArchiveTarget(null)}>
|
||||
{tc('cancel')}
|
||||
</AppButton>
|
||||
<AppButton color="error" variant="contained" onClick={confirmArchive}>
|
||||
{t('archive_confirm')}
|
||||
</AppButton>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
<ConfirmDialog
|
||||
open={Boolean(archiveTarget)}
|
||||
title={t('archive_title')}
|
||||
body={t('archive_body')}
|
||||
confirmLabel={t('archive_confirm')}
|
||||
cancelLabel={tc('cancel')}
|
||||
confirmColor="error"
|
||||
onClose={() => setArchiveTarget(null)}
|
||||
onConfirm={confirmArchive}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 <AppLoading />;
|
||||
const { data: me, isLoading: meLoading } = useMe();
|
||||
|
||||
if (isLoading || meLoading) return <ProfileSkeleton />;
|
||||
// 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 <ErrorState message={t('load_error')} retryLabel={tc('retry')} onRetry={() => 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 (
|
||||
<CustomerProfileForm initial={profile ?? null} nameFallback={{ firstName: me?.firstName ?? null, lastName: me?.lastName ?? null }} />
|
||||
<AccountHub
|
||||
initial={profile ?? null}
|
||||
nameFallback={{ firstName: me?.firstName ?? null, lastName: me?.lastName ?? null }}
|
||||
phone={me?.phone}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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<Record<'firstName' | 'lastName' | 'preferredLanguage', string | null>>,
|
||||
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 (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 520 }}>
|
||||
<Box>
|
||||
<Typography variant="h5" component="h1">
|
||||
{t('title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('subtitle')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mt: 0.5, color: isComplete ? 'var(--bal-success)' : 'text.secondary' }}>
|
||||
{isComplete ? t('completion_done') : t('completion_todo')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<ProfileSummary displayName={displayName} phone={phone} initialsFallback={displayName || undefined} />
|
||||
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} sx={{ gap: 2 }}>
|
||||
<TextField label={t('first_name')} value={firstName} onChange={(e) => setFirstName(e.target.value)} fullWidth />
|
||||
<TextField label={t('last_name')} value={lastName} onChange={(e) => setLastName(e.target.value)} fullWidth />
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<AccountRow icon="account" label={t('row_personal')} onClick={() => setPersonalSheetOpen(true)} />
|
||||
<EmergencyContactCard
|
||||
complete={emergencyComplete}
|
||||
name={emergencyName}
|
||||
phone={emergencyPhone}
|
||||
onEdit={() => setEmergencySheetOpen(true)}
|
||||
/>
|
||||
<AccountRow icon="location" label={t('row_addresses')} onClick={() => goTo(ROUTES.ADDRESSES)} />
|
||||
<AccountRow icon="language" label={t('row_language')} onClick={() => setLanguageSheetOpen(true)} />
|
||||
<AccountRow icon="notifications" label={t('row_notifications')} onClick={() => goTo(ROUTES.NOTIFICATIONS)} />
|
||||
<AccountRow icon="support" label={t('row_support')} onClick={() => goTo(ROUTES.SUPPORT_TICKETS)} />
|
||||
</Stack>
|
||||
|
||||
<TextField
|
||||
select
|
||||
label={t('language')}
|
||||
value={language}
|
||||
onChange={(e) => setLanguage(e.target.value)}
|
||||
sx={{ maxWidth: 220 }}
|
||||
{/* Renders nothing for a single-role session — see ActorSwitcher's own doc. */}
|
||||
<ActorSwitcher target="nurse" />
|
||||
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<Divider sx={{ my: 0.5 }} />
|
||||
<AccountRow icon="logout" label={t('sign_out')} onClick={() => setSignOutOpen(true)} tone="error" />
|
||||
</Stack>
|
||||
|
||||
{/* اطلاعات شخصی */}
|
||||
<FormDialogShell
|
||||
open={personalSheetOpen}
|
||||
title={t('row_personal')}
|
||||
dirty={personalDirty}
|
||||
onClose={() => setPersonalSheetOpen(false)}
|
||||
closeLabel={closeLabel}
|
||||
discardTitle={discardTitle}
|
||||
discardBody={discardBody}
|
||||
discardConfirmLabel={discardConfirmLabel}
|
||||
discardCancelLabel={cancelLabel}
|
||||
>
|
||||
<MenuItem value="fa">{t('language_fa')}</MenuItem>
|
||||
<MenuItem value="en">{t('language_en')}</MenuItem>
|
||||
</TextField>
|
||||
<Stack sx={{ gap: 2.5 }}>
|
||||
<TextField label={t('first_name')} value={firstName} onChange={(e) => setFirstName(e.target.value)} fullWidth />
|
||||
<TextField label={t('last_name')} value={lastName} onChange={(e) => setLastName(e.target.value)} fullWidth />
|
||||
<SheetActions onCancel={() => setPersonalSheetOpen(false)} onSave={savePersonal} saving={upsert.isPending} saveLabel={tc('save')} cancelLabel={cancelLabel} />
|
||||
</Stack>
|
||||
</FormDialogShell>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('emergency_section')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('emergency_hint')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<TextField
|
||||
label={t('emergency_name')}
|
||||
value={emergencyName}
|
||||
onChange={(e) => {
|
||||
setEmergencyName(e.target.value);
|
||||
if (nameError) setNameError(false);
|
||||
}}
|
||||
error={nameError}
|
||||
fullWidth
|
||||
/>
|
||||
<PhoneNumberField
|
||||
label={t('emergency_phone')}
|
||||
value={emergencyPhone}
|
||||
onChange={(value) => {
|
||||
setEmergencyPhone(value);
|
||||
if (phoneError) setPhoneError(false);
|
||||
}}
|
||||
error={phoneError}
|
||||
helperText={phoneError ? t('emergency_phone_invalid') : undefined}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<AppButton
|
||||
color="primary"
|
||||
variant="contained"
|
||||
onClick={handleSave}
|
||||
disabled={upsert.isPending}
|
||||
sx={{ alignSelf: 'flex-start' }}
|
||||
{/* زبان — the row owns the server-stored preference; the actual UI locale switch is phase 2's LocaleSwitcher, reused verbatim. */}
|
||||
<FormDialogShell
|
||||
open={languageSheetOpen}
|
||||
title={t('row_language')}
|
||||
dirty={languageDirty}
|
||||
onClose={() => setLanguageSheetOpen(false)}
|
||||
closeLabel={closeLabel}
|
||||
discardTitle={discardTitle}
|
||||
discardBody={discardBody}
|
||||
discardConfirmLabel={discardConfirmLabel}
|
||||
discardCancelLabel={cancelLabel}
|
||||
>
|
||||
{upsert.isPending ? tc('saving') : t('save')}
|
||||
</AppButton>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 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. */}
|
||||
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2, display: 'flex', gap: 2 }}>
|
||||
<AppIcon icon="location" size={28} color="var(--bal-primary)" />
|
||||
<Stack sx={{ gap: 1, flexGrow: 1 }}>
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{ta('manage_title')}
|
||||
<Stack sx={{ gap: 2.5 }}>
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{t('app_language')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{ta('manage_body')}
|
||||
{t('app_language_hint')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<AppButton
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
startIcon="location"
|
||||
to={`/${locale}${ROUTES.ADDRESSES}`}
|
||||
sx={{ alignSelf: 'flex-start' }}
|
||||
<LocaleSwitcher />
|
||||
</Stack>
|
||||
<Divider />
|
||||
<TextField
|
||||
select
|
||||
label={t('language')}
|
||||
value={language}
|
||||
onChange={(e) => setLanguage(e.target.value)}
|
||||
helperText={t('language_hint')}
|
||||
>
|
||||
{ta('manage_cta')}
|
||||
</AppButton>
|
||||
<MenuItem value="fa">{t('language_fa')}</MenuItem>
|
||||
<MenuItem value="en">{t('language_en')}</MenuItem>
|
||||
</TextField>
|
||||
<SheetActions onCancel={() => setLanguageSheetOpen(false)} onSave={saveLanguage} saving={upsert.isPending} saveLabel={tc('save')} cancelLabel={cancelLabel} />
|
||||
</Stack>
|
||||
</Paper>
|
||||
</FormDialogShell>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 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. */}
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<ActorSwitcher target="nurse" />
|
||||
<Stack direction="row" sx={{ alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
{/* مخاطب اضطراری */}
|
||||
<FormDialogShell
|
||||
open={emergencySheetOpen}
|
||||
title={t('emergency_section')}
|
||||
dirty={emergencyDirty}
|
||||
onClose={() => setEmergencySheetOpen(false)}
|
||||
closeLabel={closeLabel}
|
||||
discardTitle={discardTitle}
|
||||
discardBody={discardBody}
|
||||
discardConfirmLabel={discardConfirmLabel}
|
||||
discardCancelLabel={cancelLabel}
|
||||
>
|
||||
<Stack sx={{ gap: 2.5 }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('app_language')}
|
||||
{t('emergency_hint')}
|
||||
</Typography>
|
||||
<LocaleSwitcher />
|
||||
<TextField
|
||||
label={t('emergency_name')}
|
||||
value={emergencyName}
|
||||
onChange={(e) => {
|
||||
setEmergencyName(e.target.value);
|
||||
if (nameError) setNameError(false);
|
||||
}}
|
||||
error={nameError}
|
||||
fullWidth
|
||||
/>
|
||||
<PhoneNumberField
|
||||
label={t('emergency_phone')}
|
||||
value={emergencyPhone}
|
||||
onChange={(v) => {
|
||||
setEmergencyPhone(v);
|
||||
if (phoneError) setPhoneError(false);
|
||||
}}
|
||||
error={phoneError}
|
||||
helperText={phoneError ? t('emergency_phone_invalid') : undefined}
|
||||
fullWidth
|
||||
/>
|
||||
<SheetActions onCancel={() => setEmergencySheetOpen(false)} onSave={saveEmergency} saving={upsert.isPending} saveLabel={tc('save')} cancelLabel={cancelLabel} />
|
||||
</Stack>
|
||||
<SignOutRow />
|
||||
</FormDialogShell>
|
||||
|
||||
<ConfirmDialog
|
||||
open={signOutOpen}
|
||||
title={t('sign_out_confirm_title')}
|
||||
body={t('sign_out_confirm_body')}
|
||||
confirmLabel={t('sign_out')}
|
||||
cancelLabel={cancelLabel}
|
||||
confirmColor="error"
|
||||
onClose={() => setSignOutOpen(false)}
|
||||
onConfirm={() => {
|
||||
setSignOutOpen(false);
|
||||
logout.mutate();
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const AccountRow: FunctionComponent<{
|
||||
icon: string;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
tone?: 'default' | 'error';
|
||||
}> = ({ icon, label, onClick, tone = 'default' }) => (
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
sx={{ background: 'none', border: 'none', p: 0, width: '100%', textAlign: 'start', font: 'inherit', cursor: 'pointer' }}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
p: 1.5,
|
||||
borderRadius: 2,
|
||||
color: tone === 'error' ? 'var(--bal-error)' : 'text.primary',
|
||||
'&:hover': { bgcolor: 'action.hover' },
|
||||
}}
|
||||
>
|
||||
<AppIcon icon={icon} size={22} color={tone === 'error' ? 'var(--bal-error)' : 'var(--bal-primary)'} />
|
||||
<Typography variant="body1" sx={{ flexGrow: 1, fontWeight: 500 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
{tone !== 'error' ? <AppIcon icon="forward" size={18} color="var(--bal-text-secondary)" /> : null}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const EmergencyContactCard: FunctionComponent<{
|
||||
complete: boolean;
|
||||
name: string;
|
||||
phone: string;
|
||||
onEdit: () => void;
|
||||
}> = ({ complete, name, phone, onEdit }) => {
|
||||
const t = useTranslations('profile');
|
||||
return (
|
||||
<Box sx={{ p: 1.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
|
||||
<Stack direction="row" sx={{ alignItems: 'center', gap: 1.5 }}>
|
||||
<AppIcon icon={complete ? 'verified' : 'emergency'} size={22} color={complete ? 'var(--bal-success)' : 'var(--bal-warning)'} />
|
||||
<Stack sx={{ flexGrow: 1, gap: 0.25, minWidth: 0 }}>
|
||||
<Typography variant="body1" sx={{ fontWeight: 500 }}>
|
||||
{t('emergency_section')}
|
||||
</Typography>
|
||||
{complete ? (
|
||||
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{name}
|
||||
</Typography>
|
||||
<Typography
|
||||
component="a"
|
||||
href={`tel:${phone}`}
|
||||
dir="ltr"
|
||||
variant="body2"
|
||||
sx={{ color: 'var(--bal-primary)', textDecoration: 'none' }}
|
||||
>
|
||||
{phone}
|
||||
</Typography>
|
||||
</Stack>
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('emergency_hint')}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
<AppButton variant="text" color="primary" onClick={onEdit}>
|
||||
{t('edit')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<AppButton variant="text" color="error" startIcon="logout" onClick={() => logout()} disabled={isPending} sx={{ alignSelf: 'flex-start' }}>
|
||||
{t('sign_out')}
|
||||
</AppButton>
|
||||
<Stack direction="row" sx={{ gap: 1, justifyContent: 'flex-end' }}>
|
||||
<AppButton variant="text" onClick={onCancel} disabled={saving}>
|
||||
{cancelLabel}
|
||||
</AppButton>
|
||||
<AppButton color="primary" variant="contained" onClick={onSave} disabled={saving}>
|
||||
{saving ? tc('saving') : saveLabel}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
function ProfileSkeleton() {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 520 }}>
|
||||
<Stack sx={{ alignItems: 'center', gap: 1 }}>
|
||||
<Skeleton variant="circular" width={56} height={56} />
|
||||
<Skeleton variant="text" width={140} />
|
||||
<Skeleton variant="text" width={100} />
|
||||
</Stack>
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
{[0, 1, 2, 3, 4, 5].map((key) => (
|
||||
<Skeleton key={key} variant="rounded" height={56} />
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,6 +39,11 @@ describe('<GenderToggle/> 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('<GenderToggle/> allowAny mode', () => {
|
||||
|
||||
@@ -61,6 +61,7 @@ const GenderToggle: FunctionComponent<GenderToggleProps> = (props) => {
|
||||
aria-label={ariaLabel}
|
||||
onChange={(_event, next: Gender | 'any' | null) => handleChange(next)}
|
||||
sx={{
|
||||
width: '100%',
|
||||
'& .MuiToggleButton-root': {
|
||||
flex: 1,
|
||||
py: 1.25,
|
||||
|
||||
@@ -68,4 +68,43 @@ describe('<PatientCard/> component', () => {
|
||||
expect(onEdit).not.toHaveBeenCalled();
|
||||
expect(onArchive).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders the last-visit teaser only when provided', () => {
|
||||
const { rerender } = render(
|
||||
<ThemeProvider>
|
||||
<PatientCard
|
||||
patient={PATIENT}
|
||||
relationLabel="Parent"
|
||||
genderLabel="Female"
|
||||
ageLabel="70 yrs"
|
||||
conditionLabels={['Elderly']}
|
||||
noConditionsLabel="No conditions"
|
||||
onEdit={jest.fn()}
|
||||
onArchive={jest.fn()}
|
||||
editLabel="Edit"
|
||||
archiveLabel="Archive"
|
||||
/>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
expect(screen.queryByText(/Last visit/)).not.toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<ThemeProvider>
|
||||
<PatientCard
|
||||
patient={PATIENT}
|
||||
relationLabel="Parent"
|
||||
genderLabel="Female"
|
||||
ageLabel="70 yrs"
|
||||
conditionLabels={['Elderly']}
|
||||
noConditionsLabel="No conditions"
|
||||
onEdit={jest.fn()}
|
||||
onArchive={jest.fn()}
|
||||
editLabel="Edit"
|
||||
archiveLabel="Archive"
|
||||
lastVisitLabel="Last visit: Jul 3"
|
||||
/>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
expect(screen.getByText('Last visit: Jul 3')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<PatientCardProps> = ({
|
||||
archiveLabel,
|
||||
onOpen,
|
||||
openLabel,
|
||||
lastVisitLabel,
|
||||
}) => {
|
||||
const header = (
|
||||
<PatientHeader
|
||||
displayName={patient.displayName}
|
||||
relationLabel={relationLabel}
|
||||
genderLabel={genderLabel}
|
||||
ageLabel={ageLabel}
|
||||
conditionLabels={conditionLabels}
|
||||
noConditionsLabel={noConditionsLabel}
|
||||
/>
|
||||
<>
|
||||
<PatientHeader
|
||||
displayName={patient.displayName}
|
||||
relationLabel={relationLabel}
|
||||
genderLabel={genderLabel}
|
||||
ageLabel={ageLabel}
|
||||
conditionLabels={conditionLabels}
|
||||
noConditionsLabel={noConditionsLabel}
|
||||
/>
|
||||
{lastVisitLabel ? (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block', mt: 0.75 }}>
|
||||
{lastVisitLabel}
|
||||
</Typography>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<SurfaceCard padding="sm">
|
||||
<Stack direction="row" sx={{ alignItems: 'flex-start', gap: 1 }}>
|
||||
{onOpen ? (
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
<ButtonBase
|
||||
onClick={onOpen}
|
||||
aria-label={openLabel}
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
minWidth: 0,
|
||||
justifyContent: 'flex-start',
|
||||
textAlign: 'start',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
p: 0,
|
||||
cursor: 'pointer',
|
||||
font: 'inherit',
|
||||
color: 'inherit',
|
||||
borderRadius: 2,
|
||||
p: 1,
|
||||
m: -1,
|
||||
gap: 1,
|
||||
'&:hover': { bgcolor: 'action.hover' },
|
||||
}}
|
||||
>
|
||||
{header}
|
||||
</Box>
|
||||
<Box sx={{ flexGrow: 1, minWidth: 0 }}>{header}</Box>
|
||||
<AppIcon icon="forward" size={20} color="var(--bal-text-secondary)" />
|
||||
</ButtonBase>
|
||||
) : (
|
||||
<Box sx={{ flexGrow: 1, minWidth: 0 }}>{header}</Box>
|
||||
)}
|
||||
|
||||
@@ -6,11 +6,11 @@ jest.mock('next-intl', () => ({ useTranslations: () => (key: string) => key }));
|
||||
|
||||
import PatientForm from './PatientForm';
|
||||
|
||||
function renderForm() {
|
||||
function renderForm(extra: Partial<React.ComponentProps<typeof PatientForm>> = {}) {
|
||||
const onSubmit = jest.fn();
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<PatientForm submitLabel="Save" onSubmit={onSubmit} />
|
||||
<PatientForm submitLabel="Save" onSubmit={onSubmit} {...extra} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
return { onSubmit };
|
||||
@@ -20,17 +20,19 @@ describe('<PatientForm/> component', () => {
|
||||
it('blocks submit and flags gender when it is missing', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSubmit } = renderForm();
|
||||
await user.type(screen.getByLabelText('name_label'), 'Ali Rezaei');
|
||||
await user.type(screen.getByLabelText('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('<PatientForm/> 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<Pick<Patient, 'displayName' | 'birthDate' | 'gender' | 'conditions' | 'relation'>>;
|
||||
initial?: Partial<Pick<Patient, 'displayName' | 'firstName' | 'lastName' | 'birthDate' | 'gender' | 'conditions' | 'relation'>>;
|
||||
/** 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<CreatePatientInput, 'displayName' | 'firstName' | 'lastName'> {
|
||||
const displayName = fullName.trim();
|
||||
const parts = displayName.split(/\s+/);
|
||||
const firstName = parts[0] ?? '';
|
||||
const lastName = parts.slice(1).join(' ') || firstName;
|
||||
return { displayName, firstName, lastName };
|
||||
/** 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<PatientFormProps> = ({
|
||||
@@ -51,17 +46,26 @@ const PatientForm: FunctionComponent<PatientFormProps> = ({
|
||||
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<Gender | null>(initial?.gender ?? null);
|
||||
const [conditions, setConditions] = useState<string[]>(initial?.conditions ?? []);
|
||||
const [relation, setRelation] = useState<Relation | null>(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<Gender | null>(initialGender);
|
||||
const [conditions, setConditions] = useState<string[]>(initialConditions);
|
||||
const [relation, setRelation] = useState<Relation | null>(initialRelation);
|
||||
|
||||
const [nameError, setNameError] = useState(false);
|
||||
const [ageError, setAgeError] = useState(false);
|
||||
@@ -70,10 +74,28 @@ const PatientForm: FunctionComponent<PatientFormProps> = ({
|
||||
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<PatientFormProps> = ({
|
||||
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<PatientFormProps> = ({
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 2.5 }}>
|
||||
<TextField
|
||||
label={t('name_label')}
|
||||
value={fullName}
|
||||
onChange={(event) => {
|
||||
setFullName(event.target.value);
|
||||
if (nameError) setNameError(false);
|
||||
}}
|
||||
error={nameError}
|
||||
helperText={nameError ? t('name_required') : undefined}
|
||||
fullWidth
|
||||
/>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} sx={{ gap: 2 }}>
|
||||
<TextField
|
||||
label={t('first_name_label')}
|
||||
value={firstName}
|
||||
onChange={(event) => {
|
||||
setFirstName(event.target.value);
|
||||
if (nameError) setNameError(false);
|
||||
}}
|
||||
error={nameError}
|
||||
helperText={nameError ? t('name_required') : undefined}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label={t('last_name_label')}
|
||||
value={lastName}
|
||||
onChange={(event) => setLastName(event.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<TextField
|
||||
label={t('age_label')}
|
||||
|
||||
@@ -38,4 +38,9 @@ describe('<PatientHeader/> 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<PatientHeaderProps> = ({
|
||||
const meta = [ageLabel, genderLabel].filter(Boolean).join(' · ');
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 0.75, minWidth: 0 }}>
|
||||
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{displayName}
|
||||
</Typography>
|
||||
{relationLabel ? (
|
||||
<Chip size="small" label={relationLabel} sx={{ bgcolor: 'var(--bal-primary-soft)', fontWeight: 500 }} />
|
||||
<Stack direction="row" sx={{ gap: 1.25, minWidth: 0 }}>
|
||||
<InitialsAvatar name={displayName} />
|
||||
<Stack sx={{ gap: 0.75, minWidth: 0, flexGrow: 1 }}>
|
||||
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{displayName}
|
||||
</Typography>
|
||||
{relationLabel ? (
|
||||
<Chip size="small" label={relationLabel} sx={{ bgcolor: 'var(--bal-primary-soft)', fontWeight: 500 }} />
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
{meta ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{meta}
|
||||
</Typography>
|
||||
) : null}
|
||||
|
||||
{conditionLabels.length > 0 ? (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mt: 0.25 }}>
|
||||
{conditionLabels.map((label) => (
|
||||
<Chip key={label} size="small" variant="outlined" label={label} />
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{noConditionsLabel}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{meta ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{meta}
|
||||
</Typography>
|
||||
) : null}
|
||||
|
||||
{conditionLabels.length > 0 ? (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mt: 0.25 }}>
|
||||
{conditionLabels.map((label) => (
|
||||
<Chip key={label} size="small" variant="outlined" label={label} />
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{noConditionsLabel}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -46,4 +46,15 @@ describe('<ProfileSummary/> 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<ProfileSummaryProps> = ({
|
||||
phone,
|
||||
roleLabel,
|
||||
avatarUrl,
|
||||
initialsFallback,
|
||||
trustState,
|
||||
loading = false,
|
||||
compact = false,
|
||||
@@ -49,7 +57,12 @@ const ProfileSummary: FunctionComponent<ProfileSummaryProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
const avatar = <Avatar src={avatarUrl ?? undefined} alt={displayName} sx={{ width: avatarSize, height: avatarSize }} />;
|
||||
const avatar =
|
||||
!avatarUrl && initialsFallback ? (
|
||||
<InitialsAvatar name={initialsFallback} size={avatarSize} />
|
||||
) : (
|
||||
<Avatar src={avatarUrl ?? undefined} alt={displayName} sx={{ width: avatarSize, height: avatarSize }} />
|
||||
);
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
|
||||
@@ -37,4 +37,12 @@ describe('<RelationSelect/> 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,12 +53,16 @@ const RelationSelect: FunctionComponent<RelationSelectProps> = ({ 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 ? <AppIcon icon={option.icon} size={26} color="var(--bal-primary)" /> : null}
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, flexGrow: 1 }}>
|
||||
{option.label}
|
||||
</Typography>
|
||||
{selected ? <AppIcon icon="verified" size={22} color="var(--bal-primary)" /> : null}
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -42,4 +42,38 @@ describe('<VisitNoteCard/> component', () => {
|
||||
renderCard({ ...NOTE, nurseDisplayName: null });
|
||||
expect(screen.getByText('پرستار')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the task summary line and service label when provided', () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<VisitNoteCard
|
||||
note={NOTE}
|
||||
dateLabel="۱ خرداد ۱۴۰۵"
|
||||
authorFallback="پرستار"
|
||||
taskSummaryLabel="۱ از ۲ انجام شد"
|
||||
serviceLabel="مراقبت پایه"
|
||||
/>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
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(
|
||||
<ThemeProvider>
|
||||
<VisitNoteCard note={NOTE} dateLabel="۱ خرداد ۱۴۰۵" authorFallback="پرستار" bookingLinkLabel="مشاهده رزرو" onOpenBooking={onOpenBooking} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
screen.getByText('مشاهده رزرو').click();
|
||||
expect(onOpenBooking).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender(
|
||||
<ThemeProvider>
|
||||
<VisitNoteCard note={{ ...NOTE, bookingId: null }} dateLabel="۱ خرداد ۱۴۰۵" authorFallback="پرستار" bookingLinkLabel="مشاهده رزرو" />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
expect(screen.queryByText('مشاهده رزرو')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<VisitNoteCardProps> = ({ note, dateLabel, authorFallback }) => {
|
||||
const VisitNoteCard: FunctionComponent<VisitNoteCardProps> = ({
|
||||
note,
|
||||
dateLabel,
|
||||
authorFallback,
|
||||
taskSummaryLabel,
|
||||
serviceLabel,
|
||||
bookingLinkLabel,
|
||||
onOpenBooking,
|
||||
}) => {
|
||||
return (
|
||||
<SurfaceCard padding="sm">
|
||||
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, mb: 1, flexWrap: 'wrap' }}>
|
||||
@@ -32,6 +47,11 @@ const VisitNoteCard: FunctionComponent<VisitNoteCardProps> = ({ note, dateLabel,
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{note.nurseDisplayName?.trim() || authorFallback}
|
||||
</Typography>
|
||||
{serviceLabel ? (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
· {serviceLabel}
|
||||
</Typography>
|
||||
) : null}
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', marginInlineStart: 'auto' }}>
|
||||
{dateLabel}
|
||||
</Typography>
|
||||
@@ -42,24 +62,37 @@ const VisitNoteCard: FunctionComponent<VisitNoteCardProps> = ({ note, dateLabel,
|
||||
</Typography>
|
||||
|
||||
{note.taskResults.length > 0 ? (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mt: 1 }}>
|
||||
{note.taskResults.map((task, index) => (
|
||||
<Chip
|
||||
key={`${task.label}-${index}`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
icon={
|
||||
<AppIcon
|
||||
icon={task.done ? 'verified' : 'pending'}
|
||||
size={14}
|
||||
color={task.done ? 'var(--bal-success)' : 'var(--bal-text-secondary)'}
|
||||
/>
|
||||
}
|
||||
label={task.label}
|
||||
sx={{ color: task.done ? undefined : 'text.secondary' }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
<>
|
||||
{taskSummaryLabel ? (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block', mt: 1 }}>
|
||||
{taskSummaryLabel}
|
||||
</Typography>
|
||||
) : null}
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mt: 0.5 }}>
|
||||
{note.taskResults.map((task, index) => (
|
||||
<Chip
|
||||
key={`${task.label}-${index}`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
icon={
|
||||
<AppIcon
|
||||
icon={task.done ? 'verified' : 'pending'}
|
||||
size={14}
|
||||
color={task.done ? 'var(--bal-success)' : 'var(--bal-text-secondary)'}
|
||||
/>
|
||||
}
|
||||
label={task.label}
|
||||
sx={{ color: task.done ? undefined : 'text.secondary' }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{note.bookingId != null && bookingLinkLabel ? (
|
||||
<AppButton variant="text" color="primary" size="small" onClick={onOpenBooking} sx={{ mt: 1, px: 0 }}>
|
||||
{bookingLinkLabel}
|
||||
</AppButton>
|
||||
) : null}
|
||||
</SurfaceCard>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../../theme';
|
||||
import FormDialogShell from './FormDialogShell';
|
||||
|
||||
describe('<FormDialogShell/>', () => {
|
||||
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
|
||||
|
||||
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(
|
||||
<FormDialogShell {...baseProps} dirty={false} onClose={jest.fn()}>
|
||||
<div>form body</div>
|
||||
</FormDialogShell>,
|
||||
);
|
||||
expect(screen.getByText('Add patient')).toBeInTheDocument();
|
||||
expect(screen.getByText('form body')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('closes immediately when not dirty', () => {
|
||||
const onClose = jest.fn();
|
||||
wrap(
|
||||
<FormDialogShell {...baseProps} dirty={false} onClose={onClose}>
|
||||
<div />
|
||||
</FormDialogShell>,
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText('Close'));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows a discard confirm instead of closing when dirty', () => {
|
||||
const onClose = jest.fn();
|
||||
wrap(
|
||||
<FormDialogShell {...baseProps} dirty onClose={onClose}>
|
||||
<div />
|
||||
</FormDialogShell>,
|
||||
);
|
||||
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(
|
||||
<FormDialogShell {...baseProps} dirty onClose={onClose}>
|
||||
<div />
|
||||
</FormDialogShell>,
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText('Close'));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Keep editing' }));
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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<FormDialogShellProps> = ({
|
||||
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 (
|
||||
<>
|
||||
<Dialog open={open} onClose={requestClose} fullScreen={fullScreen} fullWidth maxWidth="sm">
|
||||
<AppBar
|
||||
position="relative"
|
||||
color="default"
|
||||
elevation={0}
|
||||
sx={{ bgcolor: 'background.paper', borderBottom: 1, borderColor: 'divider' }}
|
||||
>
|
||||
<Toolbar>
|
||||
<IconButton edge="start" onClick={requestClose} aria-label={closeLabel} sx={{ marginInlineEnd: 1 }}>
|
||||
<AppIcon icon="close" />
|
||||
</IconButton>
|
||||
<Typography variant="h6" sx={{ flexGrow: 1, fontWeight: 700 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<DialogContent sx={{ pt: 2 }}>{children}</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={discardOpen}
|
||||
title={discardTitle}
|
||||
body={discardBody}
|
||||
confirmLabel={discardConfirmLabel}
|
||||
cancelLabel={discardCancelLabel}
|
||||
confirmColor="error"
|
||||
onConfirm={() => {
|
||||
setDiscardOpen(false);
|
||||
onClose();
|
||||
}}
|
||||
onClose={() => setDiscardOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FormDialogShell;
|
||||
@@ -0,0 +1,4 @@
|
||||
import FormDialogShell from './FormDialogShell';
|
||||
|
||||
export default FormDialogShell;
|
||||
export type { FormDialogShellProps } from './FormDialogShell';
|
||||
@@ -0,0 +1,37 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../../theme';
|
||||
import InitialsAvatar from './InitialsAvatar';
|
||||
|
||||
describe('<InitialsAvatar/>', () => {
|
||||
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
|
||||
|
||||
it('renders initials from the first two words of the name', () => {
|
||||
wrap(<InitialsAvatar name="مریم رضایی" />);
|
||||
expect(screen.getByText('مر')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a single-word name as one initial', () => {
|
||||
wrap(<InitialsAvatar name="مریم" />);
|
||||
expect(screen.getByText('م')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is hidden from assistive tech (decorative next to a visible name)', () => {
|
||||
wrap(<InitialsAvatar name="مریم رضایی" />);
|
||||
expect(screen.getByText('مر')).toHaveAttribute('aria-hidden', 'true');
|
||||
});
|
||||
|
||||
it('picks the same color index for the same name every render', () => {
|
||||
const { container: first } = render(
|
||||
<ThemeProvider>
|
||||
<InitialsAvatar name="سارا محمدی" />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
const { container: second } = render(
|
||||
<ThemeProvider>
|
||||
<InitialsAvatar name="سارا محمدی" />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
const bg = (el: HTMLElement) => el.querySelector('.MuiAvatar-root')?.getAttribute('style');
|
||||
expect(bg(first.firstChild as HTMLElement)).toEqual(bg(second.firstChild as HTMLElement));
|
||||
});
|
||||
});
|
||||
@@ -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<InitialsAvatarProps> = ({ name, size = 40 }) => {
|
||||
const index = useMemo(() => colorIndexOf(name), [name]);
|
||||
const initials = useMemo(() => initialsOf(name), [name]);
|
||||
|
||||
return (
|
||||
<Avatar
|
||||
aria-hidden
|
||||
sx={{
|
||||
width: size,
|
||||
height: size,
|
||||
bgcolor: `var(--bal-avatar-${index})`,
|
||||
color: `var(--bal-avatar-${index}-contrast)`,
|
||||
fontWeight: 700,
|
||||
fontSize: size * 0.4,
|
||||
}}
|
||||
>
|
||||
{initials}
|
||||
</Avatar>
|
||||
);
|
||||
};
|
||||
|
||||
export default InitialsAvatar;
|
||||
@@ -0,0 +1,4 @@
|
||||
import InitialsAvatar from './InitialsAvatar';
|
||||
|
||||
export default InitialsAvatar;
|
||||
export type { InitialsAvatarProps } from './InitialsAvatar';
|
||||
@@ -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';
|
||||
|
||||
@@ -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<AddressCardProps> = {}) {
|
||||
@@ -56,4 +59,19 @@ describe('<AddressCard/> component', () => {
|
||||
expect(props.onEdit).toHaveBeenCalledTimes(1);
|
||||
expect(props.onDelete).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows the pin-quality cue for both states', () => {
|
||||
const { rerender } = render(
|
||||
<ThemeProvider>
|
||||
<AddressCard {...BASE} hasPin={false} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
expect(screen.getByText('No pin')).toBeInTheDocument();
|
||||
rerender(
|
||||
<ThemeProvider>
|
||||
<AddressCard {...BASE} hasPin />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
expect(screen.getByText('Pin set')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<AddressCardProps> = ({
|
||||
deleteLabel,
|
||||
setPrimaryLabel,
|
||||
settingPrimary = false,
|
||||
hasPin,
|
||||
pinSetLabel,
|
||||
pinMissingLabel,
|
||||
}) => (
|
||||
<Paper elevation={0} sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Stack direction="row" sx={{ alignItems: 'flex-start', gap: 1 }}>
|
||||
@@ -64,6 +72,17 @@ const AddressCard: FunctionComponent<AddressCardProps> = ({
|
||||
</Typography>
|
||||
) : null}
|
||||
|
||||
<Stack direction="row" sx={{ alignItems: 'center', gap: 0.5 }}>
|
||||
<AppIcon
|
||||
icon={hasPin ? 'verified' : 'warning'}
|
||||
size={16}
|
||||
color={hasPin ? 'var(--bal-success)' : 'var(--bal-warning)'}
|
||||
/>
|
||||
<Typography variant="caption" sx={{ color: hasPin ? 'text.secondary' : 'var(--bal-warning)' }}>
|
||||
{hasPin ? pinSetLabel : pinMissingLabel}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
{!isPrimary ? (
|
||||
<AppButton
|
||||
variant="text"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
'use client';
|
||||
import { FunctionComponent, useState } from 'react';
|
||||
import { FunctionComponent, useEffect, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import Stack from '@mui/material/Stack';
|
||||
@@ -28,6 +28,8 @@ export interface AddressFormProps {
|
||||
submitting?: boolean;
|
||||
onSubmit: (input: CreateAddressInput) => 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<AddressFormProps> = ({ initial, submitting = false, onSubmit, onCancel }) => {
|
||||
const AddressForm: FunctionComponent<AddressFormProps> = ({ 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<CascadingRegionValue>(() => initialRegion(initial));
|
||||
const [addressLine, setAddressLine] = useState(initial?.addressLine ?? '');
|
||||
const [addressLine, setAddressLine] = useState(initialAddressLine);
|
||||
const [pin, setPin] = useState<LatLng | null>(() => 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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: () => <Skeleton variant="rounded" height={260} />,
|
||||
});
|
||||
|
||||
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<AddressMapPickerProps> = (props) => {
|
||||
const t = useTranslations('address');
|
||||
|
||||
if (NESHAN_WEB_KEY) {
|
||||
return (
|
||||
<NeshanMap
|
||||
value={props.value}
|
||||
onChange={props.onChange}
|
||||
center={props.center ?? IRAN_CENTROID}
|
||||
neshanKey={NESHAN_WEB_KEY}
|
||||
searchPlaceholder={t('map_search_placeholder')}
|
||||
locateMeLabel={t('map_locate_me')}
|
||||
pinPreviewPrefix={t('map_pin_prefix')}
|
||||
resolvingLabel={t('map_resolving')}
|
||||
error={props.error}
|
||||
errorText={props.errorText}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <GridFallbackMap {...props} />;
|
||||
};
|
||||
|
||||
// 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<AddressMapPickerProps> = ({
|
||||
const GridFallbackMap: FunctionComponent<AddressMapPickerProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
center = IRAN_CENTROID,
|
||||
@@ -113,7 +154,7 @@ const AddressMapPicker: FunctionComponent<AddressMapPickerProps> = ({
|
||||
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',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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 =
|
||||
'<svg width="32" height="32" viewBox="0 0 24 24" fill="var(--bal-primary)" style="filter: drop-shadow(0 1px 2px var(--bal-pin-shadow))"><path d="M12 2C7.58 2 4 5.58 4 10c0 5.25 7 12 8 12s8-6.75 8-12c0-4.42-3.58-8-8-8zm0 10.5A2.5 2.5 0 1 1 12 7a2.5 2.5 0 0 1 0 5.5z"/></svg>';
|
||||
|
||||
/**
|
||||
* 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<NeshanMapProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
center,
|
||||
neshanKey,
|
||||
searchPlaceholder,
|
||||
locateMeLabel,
|
||||
pinPreviewPrefix,
|
||||
resolvingLabel,
|
||||
error = false,
|
||||
errorText,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<L.Map | null>(null);
|
||||
const markerRef = useRef<L.Marker | null>(null);
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [results, setResults] = useState<NeshanSearchResult[]>([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [locating, setLocating] = useState(false);
|
||||
const [pinAddress, setPinAddress] = useState<string | null>(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 (
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<Stack direction="row" sx={{ gap: 1 }}>
|
||||
<TextField
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
runSearch();
|
||||
}
|
||||
}}
|
||||
placeholder={searchPlaceholder}
|
||||
fullWidth
|
||||
size="small"
|
||||
slotProps={{
|
||||
input: {
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
{searching ? (
|
||||
<CircularProgress size={18} />
|
||||
) : (
|
||||
<IconButton size="small" onClick={runSearch} aria-label={searchPlaceholder}>
|
||||
<AppIcon icon="search" size={18} />
|
||||
</IconButton>
|
||||
)}
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<IconButton onClick={locateMe} aria-label={locateMeLabel} title={locateMeLabel} disabled={locating}>
|
||||
{locating ? <CircularProgress size={20} /> : <AppIcon icon="gps" size={22} color="var(--bal-primary)" />}
|
||||
</IconButton>
|
||||
</Stack>
|
||||
|
||||
{results.length > 0 ? (
|
||||
<Paper elevation={0} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, maxHeight: 200, overflowY: 'auto' }}>
|
||||
<List dense disablePadding>
|
||||
{results.map((result, index) => (
|
||||
<ListItemButton key={`${result.title}-${index}`} onClick={() => pickResult(result)}>
|
||||
<ListItemText primary={result.title} secondary={result.address} />
|
||||
</ListItemButton>
|
||||
))}
|
||||
</List>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
<Box
|
||||
ref={containerRef}
|
||||
dir="ltr"
|
||||
sx={{
|
||||
height: 260,
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: error ? 'var(--bal-error)' : 'divider',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
/>
|
||||
|
||||
{error && errorText ? (
|
||||
<Typography variant="caption" sx={{ color: 'var(--bal-error)' }}>
|
||||
{errorText}
|
||||
</Typography>
|
||||
) : value ? (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{resolvingAddress ? resolvingLabel : `${pinPreviewPrefix} ${pinAddress ?? ''}`.trim()}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default NeshanMap;
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<unknown> {
|
||||
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<NeshanSearchResult[]> {
|
||||
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<string | null> {
|
||||
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;
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user