Compare commits

...

5 Commits

Author SHA1 Message Date
hamid 12ce7fa7de ui phase 13 2026-07-20 01:35:15 +03:30
hamid d33568bf31 ui phase 12 2026-07-19 21:31:59 +03:30
hamid 87fa4cd497 ui phase 11 2026-07-19 19:19:44 +03:30
hamid b4b8c9ea79 ui phase 10 2026-07-19 17:13:32 +03:30
hamid b638e25a0e ui phase 9 2026-07-19 15:14:44 +03:30
197 changed files with 9296 additions and 1782 deletions
+7 -1
View File
@@ -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
+88 -47
View File
@@ -105,13 +105,15 @@ client/
├── messages/ # Translation files (add keys to BOTH files)
│ ├── en.json
│ └── fa.json
├── middleware.ts # next-intl routing middleware (locale detection + redirect)
├── middleware.ts # next-intl routing middleware (locale detection + redirect) + (ui-phase-13) the guest front-door: after the i18n 307/308 early-return, an UNAUTHENTICATED exact-match on '/' is `NextResponse.rewrite()`d to `/{locale}/welcome` (never a redirect, so the URL/SEO canonical stays '/'); an AUTHENTICATED hit on `/welcome` redirects to '/'. The bare-root matcher entry (`'/'`, alongside the existing catch-all regex) is load-bearing — this Next 16/Turbopack build did not reliably invoke middleware for the literal root through the negative-lookahead pattern alone (verified in dev; a production `next build && next start` confirmed the intended behavior end-to-end, so this is a dev-server-only quirk, not a logic bug)
├── next.config.mjs # createNextIntlPlugin wires i18n into Next.js
└── src/
├── app/
│ ├── globals.css
│ ├── fonts/ # Local font files (woff2) — Mikhak for fa
│ ├── global-error.tsx # Special file above [locale] — replaces the root layout on a root-level crash; renders its own <html>, so it CANNOT use next-intl. The one sanctioned static-string exception (minimal, bilingual fa+en).
│ ├── robots.ts # (ui-phase-13) replaces the old static `public/robots.txt` (deleted) — allows the public surface, disallows every private route root for every locale, points at sitemap.xml
│ ├── sitemap.ts # (ui-phase-13) public routes only ('/', /login, /terms, /privacy) × both locales, with hreflang alternates
│ └── [locale]/
│ ├── layout.tsx # ROOT RSC: renders <html lang/dir> + fonts + setRequestLocale + NextIntlClientProvider + ThemeProvider + AuthProvider (seeded via getServerAuthState) + generateMetadata (the '%s | برند' title template)
│ ├── error.tsx # Branded, localized error boundary for the whole [locale] segment — reset() retries, a "go home" link escapes
@@ -154,11 +156,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
@@ -185,31 +187,31 @@ client/
│ │ │ ├── earnings/ # /nurse/earnings — f12 nurse earnings (read-only), ui-phase-7 pass: page.tsx = EarningsBalanceHeader (net payable balance + 4 buckets, negative "owed back") + a «برداشت بعدی» ForecastLine (server-served only) + an accessible ButtonBase ExplainerCard (aria-expanded, registered `expand` chevron) + state-segmented EarningsRow list (deep-links to /nurse/visits/[id], shared Pager) ↔ payouts/page.tsx (PayoutHistoryRow list) → payouts/[id]/page.tsx (payout/batch reconciliation detail: money decomposition + masked IBAN + booking links); failed-payout reasons now map through `services/payouts/failureReasons.ts` (mapped label headline, raw code demoted to a secondary LTR caption)
│ │ │ ├── support/tickets/ # /nurse/support/tickets — f14 nurse "My Tickets" (same TicketInboxScreen/TicketThreadScreen, role="nurse") ↔ support/tickets/[id]/page.tsx
│ │ │ └── notifications/page.tsx # /nurse/notifications — f14 notification center (role="nurse"); the nurse-shell bell deep-links here
│ │ ├── admin/ # Admin/backoffice (/admin/…) — desktop sidebar shell (f15). Every screen is role-gated via useAdminCapabilities(); the sidebar hides a console the current admin role can't act on (server still enforces).
│ │ ├── admin/ # Admin/backoffice (/admin/…) — desktop sidebar shell (f15). Every screen is role-gated via useAdminCapabilities(); the sidebar hides a console the current admin role can't act on (server still enforces). ui-phase-11: every queue page adopts `useAdminListState` (URL-synced filters+page, `@/hooks`) behind a `<Suspense>` wrapper.
│ │ │ ├── layout.tsx # 'use client' — RoleGuard(expected=admin) → AdminLayout (capability-gated nav)
│ │ │ ├── loading.tsx # → ../_chrome/SidebarShellSkeleton
│ │ │ ├── page.tsx # Thin RSC — generateMetadata (admin.overview_title) + renders AdminOverviewScreen
│ │ │ ├── AdminOverviewScreen.tsx # 'use client' — f15 overview landing: a capability-gated grid of console cards
│ │ │ ├── verification/ # /admin/verification — f15 review queue (page.tsx: status-filtered nurse worklist) ↔ [nurseId]/page.tsx per-nurse case (DocumentViewer signed-URL docs, pass/reject+reason per step, structured credential entry, Approve enabled only when all steps pass — client never writes is_verified)
│ │ │ ├── tickets/ # /admin/tickets — f15 global ticket queue (page.tsx: filter status/category/referenceCode) ↔ [id]/page.tsx admin thread (AdminMessageBubble renders isInternal notes distinctly; internal-note composer; RefundPanel opens from a refund ticket)
│ │ │ ├── payouts/ # /admin/payouts — f15 batch dashboard (page.tsx: batches + preview-next-batch dialog → run, idempotency-keyed) ↔ [batchId]/page.tsx per-nurse rows + failed-payout retry + transfer-reference reconcile
│ │ │ ├── reviews/page.tsx # /admin/reviews — f15 moderation queue: publish/hide/reject (reason on hide/reject); low-rating flag; client never computes the aggregate
│ │ │ ├── config/page.tsx # /admin/config — f15 config editor: typed input by data_type + 01 rate validation + audited-save dialog + change-history drawer
│ │ │ ├── holidays/page.tsx # /admin/holidays — f15 Iranian-holiday manager (is_bank_closed toggle; client never computes the payout shift)
│ │ │ ├── alerts/page.tsx # /admin/alerts — f15 internal support-alert worklist (assign/resolve); NEVER surfaced to a non-admin
│ │ │ ├── audit/page.tsx # /admin/audit — f15 append-only audit viewer (filtered, paginated, expandable changedFields diff; no edit/delete)
│ │ │ ├── partners/ # /admin/partners — f15 partner-center management (page.tsx: list + create) ↔ [id]/page.tsx detail (verify/activate/suspend + edit + sponsored-nurse roster + assign-nurse; IBAN write-then-masked)
│ │ │ ├── roles/page.tsx # /admin/roles — f15 RBAC grant/revoke grid (DEFERRED-IF-MISSING — mock-backed until the b15 role endpoints land)
│ │ │ ├── users/page.tsx # /admin/users
│ │ │ └── notifications/page.tsx # /admin/notifications
│ │ │ ├── verification/ # /admin/verification — ui-phase-11 rebuild: page.tsx = status **Tabs** with server counts (when served, REQ-062) + a name/phone search behind the draft-vs-applied Apply/Clear pattern + a client-computed SLA-colored waiting-time column (`WAITING_TIME_WARNING_HOURS`/`_ALARM_HOURS`) ↔ [nurseId]/page.tsx per-nurse case (unchanged DocumentViewer signed-URL docs, pass/reject+reason per step, structured credential entry with `JalaliDateField` issued/expires, Approve enabled only when all steps pass) + «پرونده بعدی/قبلی» next/prev case nav (re-derives the queue's cached page via `queueFilters.ts`) + arrow-key bindings — client never writes is_verified
│ │ │ ├── tickets/ # /admin/tickets — ui-phase-11: page.tsx adds an activity column + a results footer ↔ [id]/page.tsx admin thread gains close/reopen/assign-to-me (`useCloseTicket`/`useReopenTicket`/`useAssignTicket`, gated behind `TICKET_LIFECYCLE_ENABLED` + `canManageTickets` — REQ-063, no live route yet), opens scrolled to the newest message (`useThreadScroll`), and the composer turns amber (`--bal-warning-soft`) + relabels its send button in internal-note mode so a note can't be posted publicly by mistake; AdminMessageBubble still renders isInternal notes distinctly; RefundPanel opens from a refund ticket
│ │ │ ├── payouts/ # /admin/payouts — ui-phase-11: page.tsx fixes the local-midnight UTC off-by-one on the window default, adopts `JalaliDateField` for the period inputs, and the run-confirm shows the batch total/count/date (from the already-fetched preview) behind `ConfirmDialog`'s new typed-confirmation gate (type «تایید» or the amount) ↔ [batchId]/page.tsx per-nurse rows + failed-payout retry + transfer-reference reconcile, unified onto `PageHeader`
│ │ │ ├── reviews/page.tsx # /admin/reviews — f15 moderation queue: publish/hide/reject (reason on hide/reject); low-rating flag; client never computes the aggregate; ui-phase-11 adopts `useAdminListState`
│ │ │ ├── config/page.tsx # /admin/config — f15 config editor: typed input by data_type + 01 rate validation + audited-save dialog + change-history drawer; ui-phase-11 gives both the list and the history drawer real page state (was hard-wired to page 1) and drops the dead `dataType==='int'||'decimal'?'text':'text'` ternary
│ │ │ ├── holidays/page.tsx # /admin/holidays — f15 Iranian-holiday manager (is_bank_closed toggle; client never computes the payout shift); ui-phase-11 adopts `JalaliDateField` for the holiday date, seeds new-holiday date from a real local-date helper (was a lying `TODAY_ISO = ''`), and real page state
│ │ │ ├── alerts/page.tsx # /admin/alerts — f15 internal support-alert worklist (assign/resolve); NEVER surfaced to a non-admin; ui-phase-11 fixes assign-to-self's `?? 1` fallback — the button disables with a "loading your account" tooltip until the real id hydrates — and adopts `useAdminListState`
│ │ │ ├── audit/page.tsx # /admin/audit — f15 append-only audit viewer (filtered, paginated, expandable changedFields diff; no edit/delete); ui-phase-11 adopts `useAdminListState` + `JalaliDateField` for from/to + batch-resolves actor names (`useUserLookup`) for `AuditLogRow`'s new `actorLabel` prop (falls back to `#id`)
│ │ │ ├── partners/ # /admin/partners — f15 partner-center management (page.tsx: list + create) ↔ [id]/page.tsx detail (verify/activate/suspend + edit + sponsored-nurse roster + assign-nurse; IBAN write-then-masked); ui-phase-11 replaces both the admin-user and sponsored-nurse raw-id `TextField`s with `UserPicker`/`NursePicker` (name+masked-phone+id search, §3.2) and unifies the detail header onto `PageHeader`
│ │ │ ├── roles/page.tsx # /admin/roles — f15 RBAC grant/revoke grid (DEFERRED-IF-MISSING — mock-backed until the b15 role endpoints land); ui-phase-11 replaces the raw numeric-id grant `TextField` with `UserPicker` — the confirm/revoke copy now names the resolved person, never `#42`
│ │ │ ├── users/page.tsx # /admin/users — ui-phase-11: a real read-first directory (was a `PlaceholderScreen`) — search by name/phone over the same admin user-directory seam `UserPicker` uses (REQ-061, mock-backed), role chips, a per-row link into the audit log filtered to that user
│ │ │ └── notifications/page.tsx # /admin/notifications — still a placeholder; NOT in `AdminLayout`'s nav (no real feed yet, ui-phase-10/11) so it satisfies "no placeholder reachable from admin nav"
│ │ └── partner/ # Partner-center portal (/partner/…) — a SEPARATE authz scope (f15). A center admin is not a Balinyaar admin; each page resolves the caller's OWN center (useMyPartnerCenter → access-denied on 403/404).
│ │ ├── layout.tsx # 'use client' — RoleGuard (no expected role — hydration-only) → PartnerLayout (own partner nav; self-gates via useMyPartnerCenter)
│ │ ├── loading.tsx # → ../_chrome/SidebarShellSkeleton
│ │ ├── page.tsx # Thin RSC — generateMetadata (partner.home_title) + renders PartnerHomeScreen
│ │ ├── PartnerHomeScreen.tsx # 'use client' — center home: onboarding/verification state banner + license fields + is_merchant_of_record indicator
│ │ ├── nurses/page.tsx # /partner/nurses — the center's sponsored nurses (verification badge)
│ │ ├── bookings/page.tsx # /partner/bookings — the bookings the center legally covers (read-only summaries)
│ │ └── settlement/page.tsx # /partner/settlement — rendered ONLY when is_merchant_of_record: per-booking commission invoices (commission/VAT decomposition via PartnerSettlementRow, signed-URL PDF, masked IBAN); non-MoR shows the "settlement via Balinyaar" state
│ │ ├── bookings/ # /partner/bookings — ui-phase-11: page.tsx localizes the 7 booking-status codes onto `StatusChip` (was raw English wire codes, e.g. `pending_payment`) in both the table and filter, adopts `useAdminListState`, and rows link to ↔ [id]/page.tsx (new) — a scoped read-only detail (dates, status timeline via the shared `StatusTimeline`, patient display name only — no clinical data; REQ-064, mock-backed)
│ │ └── settlement/page.tsx # /partner/settlement — rendered ONLY when is_merchant_of_record: per-booking commission invoices (commission/VAT decomposition via PartnerSettlementRow, signed-URL PDF, masked IBAN); non-MoR shows the "settlement via Balinyaar" state; ui-phase-11 adds a client-side «خروجی CSV» export (`utils/toCsv.ts`, UTF-8 BOM + CRLF for Excel) of the current result set
│ ├── (customer-focused)/ # ui-phase-3 — chrome-free counterpart to (customer) for can't-tab-away flows; same URL space (route groups add no segment)
│ │ ├── layout.tsx # 'use client' — RoleGuard(expected=customer) → FocusedLayout (no BottomBar/bell/sidebar)
│ │ └── onboarding/ # /onboarding — moved here from (customer) so the A3→A4 wizard can't be tabbed away from mid-setup
@@ -222,7 +224,11 @@ client/
│ │ ├── page.tsx # Thin RSC — generateMetadata (auth.customer_title) + renders LoginScreen
│ │ └── LoginScreen.tsx # 'use client' — the actual LoginFlow body
│ ├── terms/page.tsx # /terms — draft Terms of Service (ui-phase-3; DRAFT COPY, needs human/legal review before launch)
── privacy/page.tsx # /privacy — draft Privacy Policy (ui-phase-3; DRAFT COPY, needs human/legal review before launch)
── privacy/page.tsx # /privacy — draft Privacy Policy (ui-phase-3; DRAFT COPY, needs human/legal review before launch)
│ └── welcome/ # /welcome — ui-phase-13 public front door; middleware REWRITES an unauthenticated '/' here (URL stays '/'), so this is what a guest, and every crawler, actually sees at the root
│ ├── page.tsx # Thin RSC — generateMetadata (description/OG/`alternates.canonical` pointing at `/{locale}`; no `title` override, so it inherits the root layout's default "بالین‌یار"/"Balinyaar" — that's how it's told apart from the customer home's own `shell.customer_app` title in view-source) + renders WelcomeScreen
│ ├── WelcomeScreen.tsx # Async Server Component (no 'use client') — hero (BrandMark) + static category grid (CategoryTile via its new `href` prop, §3.1 tier a+b — NOT the live catalog) + how-it-works (step 2 reuses EscrowNotice verbatim) + a static trust/verification explainer + nurse-recruitment CTA + footer; zero query hooks anywhere in the tree — first paint is the whole page
│ └── opengraph-image.tsx # `next/og` ImageResponse — a static brand-mark composition (Latin-only; Persian would need an embedded Mikhak font buffer, left for a follow-up) on the brand teal/cream, served as the page's absolute `og:image`
├── components/ # Shared UI components (each with .test.tsx if imported >1 place)
│ ├── common/ # Foundational primitives (import from @/components or @/components/common)
│ │ ├── AppButton/, AppIconButton/, AppIcon/, AppLink/, AppAlert/, AppLoading/ # house-default MUI wrappers (see frontend-designer skill §4)
@@ -230,8 +236,8 @@ client/
│ │ ├── EmptyState/ # icon+title+body+action — the one "nothing here" pattern (replaces hand-rolled dashed-border Paper blocks)
│ │ ├── ErrorState/ # message+retryLabel(required)+onRetry — the one "this query failed" pattern; PRESENTATIONAL, no next-intl import (same reason as ErrorBoundary)
│ │ ├── QueryStateGate/ # wraps a query's skeleton/error/empty/children branching in the fixed skeleton→error→empty→children order; requires retryLabel
│ │ ├── PageHeader/ # title+subtitle+actions+optional back button (backTo/backLabel)
│ │ ├── ConfirmDialog/ # promoted from admin/ — required-reason gating + busy-disable, now usable by any actor
│ │ ├── PageHeader/ # title+subtitle+actions+optional back button (backTo/backLabel); ui-phase-11 added `meta` (a chip-row slot below the title, distinct from the button-oriented `actions`) and `onBack` (a callback alternative to `backTo` — pairs with `useAdminBackToList` for `router.back()`-with-fallback semantics; takes precedence over `backTo` when both are given)
│ │ ├── ConfirmDialog/ # promoted from admin/ — required-reason gating + busy-disable, now usable by any actor; ui-phase-11 added `requireTypedConfirmation`/`typedConfirmationLabel`/`typedConfirmationPlaceholder` — confirm stays disabled until the typed value matches one of the given strings, the guard for an irreversible money-moving action (the admin payout run confirm)
│ │ ├── SurfaceCard/ # flat Paper wrapper, padding: 'sm'|'md'|'lg'
│ │ ├── AccentCard/ # SurfaceCard + tone → 4px borderInlineStart accent (primary/secondary/success/error/warning/info/trust/neutral)
│ │ ├── Money/ # <Money amountIrr size tone deduction hideUnit strikethrough> — the one money-rendering primitive (wraps utils/money.ts); size gained `xl` (h4) in ui-phase-6 for the checkout/confirmation prominent-total hero; imports next-intl (see jest.config.ts transformIgnorePatterns note below)
@@ -242,7 +248,11 @@ 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)
│ │ ├── RouteFadeIn/ # ui-phase-12 — the one route-content fade/slide primitive (motion pass): wraps `{children}`, keyed on the locale-stripped pathname so it remounts (replays the CSS `bal-fade-in` keyframe, globals.css) on navigation but never on an in-place re-render; mounted inside the `ErrorBoundary` in all five shells (tested)
│ │ └── index.tsx # barrel — keep next-intl-importing primitives (Money) below the presentational ones so the poisoning risk stays visible in review
│ ├── admin/ # f15 backoffice + partner composites (import from `@/components/admin`): AdminDataTable (v2, ui-phase-11 — optional per-column server-param `sort`/`sortable` + `TableSortLabel`, `stickyHeader` scroll viewport, `minWidth`, a `footer` line), AdminPager (page/pageCount; the admin `page_indicator` i18n key regained its `{total}`), AdminPageHeader/AdminEmptyState/AdminErrorState, ConfirmDialog (thin alias), ConfigRow/AuditLogRow (ui-phase-11 — expand chevron rotates + `aria-expanded` + button semantics, new `actorLabel` prop resolved via a batch id→name lookup)/SupportAlertCard (ui-phase-11 — `assignSelfDisabled`/`assignSelfDisabledTitle` so "assign to me" never falls back to a guessed user)/PartnerSettlementRow, DocumentViewer, RefundPanel, AdminMessageBubble, and (ui-phase-11) `UserPicker`/`NursePicker` — async name/phone `Autocomplete` over the admin user directory (REQ-061, mock-backed), replacing every raw numeric-id `TextField` on an audited action; each option renders name+masked-phone+id, never a bare id
│ ├── PlaceholderScreen/ # Empty-state scaffold for not-yet-built screens
│ ├── OtpInput/ # OTP code input (auto-advance, paste, RTL-safe)
│ ├── PhoneNumberField/ # Iranian mobile field (digit-normalizing, LTR-in-RTL, maskIranMobile)
@@ -251,10 +261,10 @@ 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)
│ ├── CategoryTile/ # f4 tappable service-category tile (icon+label; `selected` state for the builder) — Home grid + builder step 1; ui-phase-13 added an `href` mode (renders as the anchor itself via `ButtonBase`'s `component` swap to `AppLink` — never a button nested inside a link) for the public landing's static category grid (tested)
│ ├── PriceDisplay/ # f4 price renderer: money-util Toman + i18n unit label + unit-aware estimated total (never a total from price alone) (tested)
│ ├── VariantCard/ # f4 nurse offering card: display_name, PriceDisplay, active/deactivated distinction, edit/deactivate (no delete); ui-phase-8 added `interactive={false}` for a read-only preview use (the builder's live listing preview + the profile preview) (tested)
│ ├── ActivationChecklist/ # ui-phase-8 — the unified go-live tracker (`useActivationChecklist` hook, self-fetching): five already-cached queries folded into rows — two-tier honesty (identity/profile/services/coverage drive search visibility; bank drives "getting paid", labelled separately and never gates search) — collapses to a compact «فعال در جستجو» state once everything passes AND accepting-bookings is on; mounted on `/nurse/services` and the dashboard's `DashboardActivationSlot`, one shared component; `useActivationChecklist` is also consumed directly by `PublishGate` so the go-live gate and the checklist never compute the conditions twice (tested)
@@ -282,12 +292,12 @@ client/
│ ├── RatingInput/ # f13 15 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)
│ ├── 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
│ ├── 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), ui-phase-10 messaging-app rebuild. Screens shared by the customer+nurse pages (role decides chrome): TicketInboxScreen (status filter chips + load-more, EmergencyPlaybookRow instead of a permanent banner), TicketThreadScreen (+ TicketConversationPanel, key={ticketId} — owns the one usePostMessage/draft both TicketMessageList and MessageComposer share, so retry/discard and the composer are one pipeline), TicketMessageList (date separators/author-grouped bubbles/centered system events via useThreadScroll — opens at the newest message, "new message" pill), ContactSupportDialog (new-ticket → shows referenceCode), MessageComposer (controlled; pointer-aware Enter semantics, attachment affordance gated behind `TICKETS_ATTACHMENTS_ENABLED`), BookingSupportEntry (page-local glue on f8 booking detail — reuses the cached booking + care query, no refetch; still mounts the full alarm-red EmergencyBanner, untouched). Pure/tested: MessageBubble (mine/theirs, RTL-mirrored, hh:mm-only, failed-send retry-in-place + discard, `role="alert"` on failure), TicketListCard (prominent referenceCode + unread pill + last-message preview + relative time, mock-tolerant when the enrichment fields are absent), EmergencyBanner (nurse post-confirmation tel: playbook only), EmergencyPlaybookRow (the inbox's compact neutral emergency row). Helpers: statusKind.ts, authorLabel.ts, clientMessageId.ts, useThreadScroll (reusable scroll-orchestration hook, exported for phase 11's admin thread)
│ ├── notifications/ # f14 notification composites (import from @/components/notifications), ui-phase-10 pass. NotificationBell (chrome container — subscribes to the polling count so only it re-renders; opens NotificationBellPopover on the nurse desktop shell instead of navigating, everywhere else still navigates) → NotificationBellView (pure, tested, ref-forwarding so the container can anchor the popover), NotificationBellPopover (5-recent preview, fetches on open, exported for phase 11's admin shell once it has a feed), NotificationRow (pure, tested: per-kind tinted icon container, navigable rows get a trailing chevron, non-navigable rows render as a plain non-rippling surface), NotificationCenter (shared page body: unread-first, day-grouped امروز/دیروز/این‌هفته with relative timestamps, mark-read-on-open + mark-all, deep-links via notificationDeepLink). Helper: notificationIcon.ts (+ notificationTint). Admin's bell entry is hidden (no real feed yet, `AdminLayout.tsx`) until phase 11 ships one.
│ └── 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)
├── i18n/
│ ├── routing.ts # defineRouting — locales: ['en', 'fa'], defaultLocale: 'fa'
@@ -295,10 +305,10 @@ client/
│ └── navigation.ts # ui-2 createNavigation(routing) — Link/usePathname/useRouter/redirect/getPathname. ALL chrome navigation goes through this: usePathname is locale-stripped (so unprefixed ROUTES.* compare directly) and Link/router add the locale automatically — no manual `/${locale}` prefixing, no middleware redirect hop
├── layout/ # ui-2 rewrite — per-actor branded chrome + correct locale-aware navigation
│ ├── PrivateLayout.tsx # authenticated wrapper (passthrough today); actor chrome lives in the shells below
│ ├── CustomerLayout.tsx # 'use client' — customer shell: contextual TopBar (brand lockup on the 5 root tabs, title+back on pushed routes) + mobile BottomBar, replaced by an inline desktop top-nav (CustomerDesktopNav) at ≥md
│ ├── NurseLayout.tsx # 'use client' — nurse workspace via TopBarAndSideBarLayout: grouped sidebar (امروز/حرفهٔ من/مالی/پشتیبانی) + ProfileSummary identity card + ActorSwitcher, 5-tab mobile BottomBar («بیشتر» opens the same sidebar drawer)
│ ├── AdminLayout.tsx # 'use client' — admin shell via TopBarAndSideBarLayout: sectioned sidebar (اعتماد/مالی/پشتیبانی/سیستم, useAdminCapabilities-gated, unchanged gating), TopBar identity chip (fine-grained role) + bell
│ ├── PartnerLayout.tsx # 'use client' — partner portal via TopBarAndSideBarLayout; TopBar identity chip shows the center's own name (useMyPartnerCenter, skeleton while resolving)
│ ├── CustomerLayout.tsx # 'use client' — customer shell: contextual TopBar (brand lockup on the 5 root tabs, title+back on pushed routes) + mobile BottomBar, replaced by an inline desktop top-nav (CustomerDesktopNav) at ≥md; ui-phase-10 added a `useSupportUnreadTotal`-driven Badge on the root-tab support icon (renders only when a signal exists — mock-only until REQ-059)
│ ├── NurseLayout.tsx # 'use client' — nurse workspace via TopBarAndSideBarLayout: grouped sidebar (امروز/حرفهٔ من/مالی/پشتیبانی) + ProfileSummary identity card + ActorSwitcher, 5-tab mobile BottomBar («بیشتر» opens the same sidebar drawer); ui-phase-10 added `badgeCount` (useSupportUnreadTotal) on the support sidebar item
│ ├── AdminLayout.tsx # 'use client' — admin shell via TopBarAndSideBarLayout: sectioned sidebar (اعتماد/مالی/پشتیبانی/سیستم, useAdminCapabilities-gated, unchanged gating), TopBar identity chip (fine-grained role); no notification bell (ui-phase-10 — admin has no real feed yet, re-add via `NotificationBellPopover` once phase 11 ships one)
│ ├── PartnerLayout.tsx # 'use client' — partner portal via TopBarAndSideBarLayout; TopBar identity chip shows the center's own name (useMyPartnerCenter, skeleton while resolving); ui-phase-11 adds a compact merchant-of-record `StatusChip` beside it, persistent across every portal page
│ ├── PublicLayout.tsx # unauthenticated shell — minimal corner strip (logo + LocaleSwitcher + dark toggle), no sidebar/bottom bar; AuthCard renders its own larger BrandMark
│ ├── FocusedLayout.tsx # ui-phase-3 — chrome-free shell for can't-tab-away flows (today: onboarding): a slim logo strip + content, no BottomBar/bell/sidebar; the route group above it still applies RoleGuard
│ ├── TopBarAndSideBarLayout.tsx # 'use client' — the nurse/admin/partner engine: a fixed TopBar (useRouteTitle) + SideBar rendered as flex-row siblings (mobile temporary Drawer + desktop `variant="permanent"` Drawer switched by CSS `sx` breakpoints only — no `useIsMobile` structural branching, so desktop first paint already has the sidebar); optional `identity`/`sidebarIdentity`/`mobileBottomBar` slots
@@ -310,7 +320,7 @@ client/
│ ├── TopBar.tsx # title | titleNode override, align ('start' breadcrumb-style | 'center'), optional secondaryRow (the customer desktop top-nav)
│ ├── SideBar.tsx # renders both Drawers (mobile temporary + desktop permanent) off one content tree; close handler wired to the nav list only (dark-mode/locale toggles never close it); brand header + optional identity slot
│ ├── SideBarNavList.tsx # renders `ListSubheader` sections when items share a `group`; selection computed once via matchActivePath and passed down
│ ├── SideBarNavItem.tsx # navigates via `@/i18n/navigation`'s Link — one navigation, no redirect hop
│ ├── SideBarNavItem.tsx # navigates via `@/i18n/navigation`'s Link — one navigation, no redirect hop; renders `LinkToPage.badgeCount` as a small Badge on the icon when > 0 (ui-phase-10, the nurse support entry)
│ ├── BrandLockup.tsx # ui-2 compact horizontal logo+wordmark — customer header (root tabs) + every sidebar shell's drawer header
│ ├── ActorSwitcher.tsx # ui-2 dual customer+nurse session switcher (renders nothing for a single-role session); nurse sidebar + customer profile hub (tested)
│ ├── DarkModeButton.tsx # 'use client' — only subscriber to useColorScheme()
@@ -353,9 +363,9 @@ client/
│ ├── payouts/ # F12 nurse earnings & payout history (b13) — read-only, no mutations. useNurseEarningsBalance/useNurseEarnings(state,page)/useNursePayoutHistory(page)/useNursePayoutDetail(id); the state-filter + page are part of the query key (tabs/pages cache separately, keepPreviousData); seam+mock(PRIMARY)+client. b13 serves only GET nurse_payouts/history; the four-bucket earnings summary, per-booking earnings list + money-state, and nurse-readable payout detail (batch context + booking links + failureReason) are REQ-025 gaps mocked behind the seam. EarningsState (pending|eligible|paid|clawback_applied) is a client display model derived server-side; PayoutStatus is the contract's pending|submitted|paid|failed. Money = IRR digit-strings (gross=commission+payout; net=grossclawback; Σ booking-links=grossEarnings); the net payable balance is SIGNED (may be negative "owed back", never clamped); eligibility/dates/amounts are server truth (never computed client-side); the BNPL provider commission never appears (payment-method-invariant). MOCK_SCENARIO toggles the negative-balance demo
│ ├── reviews/ # F13 moderated reviews (b14). useNurseReviews(infinite, published-only aggregate+list)/useReviewEligibility(bookingId)/useMyReviewForBooking(bookingId)/useCreateReview(invalidates eligibility+myReview, NEVER the public list); seam+mock(PRIMARY)+client. b14 serves submit + GET nurses/{id}/reviews (both mapped 1:1); review-eligibility + my-review-for-booking are REQ-026 gaps and moderation is admin-only (f15), so the mock reads a booking from the shared f8 bookings store (mockGetBookingForReview) to gate on a completed booking, tracks the submission for the persistent "under review" state, seeds a per-nurse published list, and recomputes the aggregate from published (never a stored sum). A pending_moderation review is NEVER injected into a public list/aggregate. Dev-only __mockPublishSubmittedReview stands in for the f15 admin queue. Tag chip labels are i18n keys off REVIEW_TAG_CODES, never off the wire
│ ├── patientRecords/ # F13 continuity-of-care (b14) — patient-scoped, NOT booking-scoped. usePatientCareRecord(family record)/useRecordAccess(gates before any clinical fetch)/usePatientHistory(paged visit-note history)/useUpdateCareRecord(CUSTOMER-only edit → setQueryData)/useCreateVisitNote(NURSE-only append → invalidates history). seam+mock(PRIMARY)+client. The nurse-authored visit-note history/append (getPatientHistory/createVisitNote) are REAL b14 (GET/POST patients/{id}/care_records, mapped 1:1; the append folds the ticked task checklist into the note body); the family-owned editable record (medications/routine/tasks) + the access check have NO backend (REQ-027) and are mocked. Nurse is APPEND-ONLY (never wires useUpdateCareRecord). Access-denied (canView=false / 403) is a first-class non-leaking state; MOCK_FOREIGN_PATIENT_ID=8888 exercises it. Clinical text is never logged/localStorage/query-string
│ ├── tickets/ # F14 tickets — the ONLY sanctioned post-booking channel (b15). useMyTickets/useTicket(one detail(id) = the whole thread; no message pagination)/useTicketThread(select over detail)/useOpenTicket(invalidates lists)/usePostMessage(OPTIMISTIC: onMutate append pending, onError rollback+keep composer draft, onSuccess replace by clientMessageId, onSettled invalidate). seam+mock(PRIMARY)+client(maps b15 1:1). **is_internal NEVER modelled in the user-app types** — both mappers DROP any internal message (server-strip mimic); no internal affordance anywhere. Mock stores an internal note it never returns (no-leak demo), seeds a booking-linked coordination ticket (idempotent for coordination+bookingId → "jump to existing"), tracks the last viewer so an optimistic message reconciles as mine, MOCK_SEND_FAIL_SENTINEL='/fail' drives the failure→retry path. Wire summary lacks unreadCount/lastMessageAt (REQ-028) → mock-only
│ ├── notifications/ # F14 in-app notification center (b1) — polled, no push. useNotifications(unread-first, growing limit)/useUnreadCount(the POLLING bell: refetchInterval 60s + staleTime 45s + refetchOnFocus, auth-gated — count only, list never polled)/useMarkNotificationRead+useMarkAllRead(OPTIMISTIC setQueryData flips isRead + decrements/zeros the cached count, rollback on error, invalidate on settle). seam+mock(PRIMARY)+client(maps b1 1:1). data_json is a TYPED contract: parseNotificationData(type,dataJson)→discriminated NotificationData union (snake/camel tolerant, degrades to {kind:'none'} on malformed/unknown/missing id — never trusts a blob); notificationDeepLink(n,role) centralises the role-aware route (null when nothing to open). Mock seeds every deep-link class + __mockPushNotification for the bell-increment demo
│ ├── admin/ # F15 backoffice-owned data (b1 + b15): config, holidays, audit, support-alerts, RBAC. usePlatformConfigs/useUpdatePlatformConfig/useConfigChangeHistory/useHolidays/useUpsertHoliday/useAuditLogs/useSupportAlerts/useAssignSupportAlert/useResolveSupportAlert/useAdminRoles/useGrantRole/useRevokeRole; seam+mock(PRIMARY)+client. Filters+page in each key (worklist filters cache separately). Mock-primary: config updatedAt/updatedBy + rich audit filters + the whole RBAC surface are gaps (REQ-029/030/031). support_alerts are internal-only — never rendered outside an admin route
│ ├── tickets/ # F14 tickets — the ONLY sanctioned post-booking channel (b15). useMyTickets/useTicket+useTicketThread(select over detail; both poll every TICKET_THREAD_REFETCH_INTERVAL while a thread is mounted, ui-phase-10)/useOpenTicket(invalidates lists)/usePostMessage(OPTIMISTIC + retry-in-place, ui-phase-10: onMutate is idempotent on clientMessageId — a retry flips an existing `failed` bubble back to `sending` instead of appending a duplicate; onError no longer rolls back, it flips the bubble to `sendStatus:'failed'` in place so the text + a retry/discard affordance survive; onSettled also invalidates the unread-total badge)/useDiscardFailedMessage(non-mutation cache removal for the composer's discard-and-retype)/useSupportUnreadTotal(chrome badge, §3.1 — mock sums unread, real returns null until REQ-059). seam+mock(PRIMARY)+client(maps b15 1:1). **is_internal NEVER modelled in the user-app types** — both mappers DROP any internal message (server-strip mimic); no internal affordance anywhere. Mock stores an internal note it never returns (no-leak demo), seeds a booking-linked coordination ticket (idempotent for coordination+bookingId → "jump to existing"), tracks the last viewer so an optimistic message reconciles as mine, MOCK_SEND_FAIL_SENTINEL='/fail' drives the failure→retry path. `unreadCount`/`lastMessageAt` are REAL (REQ-028, delivered); `lastMessagePreview`/`lastAuthorRole` are the newer REQ-059 gap → mock-only, card degrades gracefully without them
│ ├── notifications/ # F14 in-app notification center (b1) — polled, no push. useNotifications(unread-first, growing limit, now takes an optional `{enabled}` so the ui-phase-10 bell popover can fetch only on open)/useUnreadCount(the POLLING bell: refetchInterval 60s + staleTime 45s + refetchOnFocus, auth-gated — count only, list never polled)/useMarkNotificationRead+useMarkAllRead(OPTIMISTIC setQueryData flips isRead + decrements/zeros the cached count, rollback on error, invalidate on settle). seam+mock(PRIMARY)+client(maps b1 1:1). data_json is a TYPED contract: parseNotificationData(type,dataJson)→discriminated NotificationData union (snake/camel tolerant, degrades to {kind:'none'} on malformed/unknown/missing id — never trusts a blob); notificationDeepLink(n,role) centralises the role-aware route (null when nothing to open). Mock seeds every deep-link class + __mockPushNotification for the bell-increment demo
│ ├── admin/ # F15 backoffice-owned data (b1 + b15): config, holidays, audit, support-alerts, RBAC, and (ui-phase-11) a user directory. usePlatformConfigs/useUpdatePlatformConfig/useConfigChangeHistory/useHolidays/useUpsertHoliday/useAuditLogs/useSupportAlerts/useAssignSupportAlert/useResolveSupportAlert/useAdminRoles/useGrantRole/useRevokeRole/useUserSearch/useUserLookup (ui-phase-11 — `searchUsers`/`lookupUsers` on `AdminApi`, backing `UserPicker`/`NursePicker` + `AuditLogRow`'s actor-name resolve); seam+mock(PRIMARY)+client. Filters+page in each key (worklist filters cache separately). Mock-primary: config updatedAt/updatedBy + rich audit filters + the whole RBAC surface + the user directory are gaps (REQ-029/030/031/061). support_alerts are internal-only — never rendered outside an admin route
│ ├── partnerCenter/ # F15 partner centers (b15): admin management + the center-scoped portal. usePartnerCenters/usePartnerCenter/useCenterSponsoredNurses/useCreate/useUpdate/useVerify/useSetActive/useAssignNurse (admin) + useMyPartnerCenter/useMySponsoredNurses/useMySponsoredBookings/useMySettlement (portal); seam+mock(PRIMARY)+client. settlement_iban masked last-4 (write-then-masked); merchant-of-record gates the settlement view; VAT on the commission line only (config vat_rate); deriveCenterState(isActive,verifiedAt). Mock-primary: portal split reads + activate/suspend + invoice total are gaps (REQ-032/033)
│ │ # Admin-endpoint ADDITIONS to existing domains (the staff lens — NOT new domains):
│ │ # verification → useVerificationQueue/useVerificationCase/useVerificationDocumentUrl(on-demand signed URL)/useDecideStep/useApproveVerification/useRejectVerification (b6; REQ-034)
@@ -384,9 +394,9 @@ client/
│ ├── tokens.css # CSS custom properties — [data-mui-color-scheme] selectors + the dark @media fallback (no-flash boot, no script — see "Theme System" below)
│ ├── typography.ts # TYPOGRAPHY_LTR (Space Grotesk) / TYPOGRAPHY_RTL (Mikhak) — shared size scale, 500/700 weight system
│ └── index.ts # Public re-exports (ThemeProvider, getDirection, APP_THEME_*)
├── constants/ # App-wide constants (routes.ts w/ actor paths, roles.ts, headers.ts)
├── hooks/ # incl. auth.ts → useIsAuthenticated / useActorRole (role-aware chrome)
├── utils/ # incl. money.ts (IRR/Toman, integer-safe) + date.ts (Shamsi display) + number.ts (localeTag/formatNumber/formatRelativeTime/formatClock — the one home for locale-ternary formatting) + toEnglishDigits
├── constants/ # App-wide constants (routes.ts w/ actor paths, roles.ts, headers.ts, policy.ts — ui-phase-12's single-sourced trust-copy numbers pending REQ-065)
├── hooks/ # incl. auth.ts → useIsAuthenticated / useActorRole (role-aware chrome); ui-phase-11 added `useAdminListState.ts` — URL-synced worklist state (`useSearchParams`-based; callers need a `<Suspense>` boundary) mirroring **applied** filters+page into the URL (draft-vs-applied; `apply`/`applyFilters`/`clear`/`goToPage`) + `useAdminBackToList` (a real `router.back()` with a list-route fallback), adopted by every admin/partner queue page
├── utils/ # incl. money.ts (IRR/Toman, integer-safe) + date.ts (Shamsi display) + number.ts (localeTag/formatNumber/formatRelativeTime/formatClock — the one home for locale-ternary formatting) + toEnglishDigits + toCsv.ts (ui-phase-11 — dependency-free CSV serializer, CRLF + comma/quote escaping; backs the partner settlement CSV export, UTF-8-BOM-prefixed for Excel)
└── config.ts
```
@@ -426,6 +436,12 @@ root template; `page.tsx` itself never renders `<title>` or touches `document.ti
pages (customer home, `/login`, `/search`, `/bookings`, `/nurse`, `/admin`, `/partner`) have adopted this
so far — the rest is deferred to the area phases (311).
**`metadataBase` (ui-phase-13):** the root layout's `generateMetadata` sets `metadataBase: new URL(SITE_URL)`
(`SITE_URL``src/config.ts`, sourced from `NEXT_PUBLIC_SITE_URL`, never hard-coded) so every child
page's relative OG/canonical URLs resolve to an absolute one. The public `/welcome` landing is the first
consumer: its `generateMetadata` sets `alternates.canonical` + `openGraph`, and a co-located
`opengraph-image.tsx` (`next/og`'s `ImageResponse`) supplies `og:image`.
```tsx
import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
@@ -495,19 +511,27 @@ async function MyServerComponent() {
- `'payouts'` — the f12 nurse earnings & payout-history surface: the balance header (`balance_net_*`/`balance_owed_*` — the negative "owed back" state + hint) + four buckets (`bucket_*`), the cadence/dispute-window explainer (`explainer_*` — weekly batches, EVV+72h gate, method-invariant), the state tabs + earnings-state chip labels (`tab_*`/`estate_*` for pending/eligible/paid/clawback_applied), the nurse-framed three-amount breakdown (`amount_gross`/`amount_commission`/`amount_your_payout`) + clawback net explanation (`clawback_*`), the per-state affordances (`pending_affordance`/`dispute_window_*`/`eligible_affordance`/`paid_on`), the payout-status labels (`pstatus_*` for pending/submitted/paid/failed) + batch-status labels (`bstatus_*`), the read-only failure banner (`failure_*`), and the detail money decomposition + booking-links copy (`detail_*`/`gross_earnings_label`/`net_amount_label`); consumed by the `/nurse/earnings` pages and `EarningsBalanceHeader`/`EarningsRow`/`PayoutHistoryRow`
- `'reviews'` — the f13 leave-a-review flow + the C3 reviews tab: the form labels (`title`/`rating_label`/`body_label`/`tags_label`/`submit`), the review-tag labels keyed off the code (`tag_{punctual,professional,clean,kind,communicative}` — never off the wire), the not-eligible reasons (`reason_*`), the moderation-status labels (`status_pending_moderation`/`status_published`/`status_hidden`/`status_rejected`), the persistent "under review" + my-review copy, the booking-detail CTA (`cta_leave`/`cta_under_review`/`cta_view_review`), the aggregate count (`count` ICU plural), the masked author fallback (`author_masked`), and the list empty/error/load-more; consumed by the review page, the C3 `ReviewsPanel`, and the `LeaveReviewCta`
- `'records'` — the f13 E2 care-record viewer + the nurse visit-note panel: the ownership banner, the four tab labels (`tab_{medications,routine,history,tasks}`), the access-denied + not-found cards, the editable-record field labels (`med_*`/`routine_*`/`task_*`) + empty states, the paged-history controls (`prev`/`next`/`page_of`) + visit-note author fallback, and the nurse composer copy (`notes_title`/`tasks_checklist_title`/`note_*`/`continuity_title`); shared enum labels (relation/gender/condition) are REUSED from `onboarding`/`patients`, never re-keyed; consumed by the E2 record page + `NurseVisitNotesPanel` + `VisitNoteCard`
- `'tickets'` — the f14 messaging surface (tickets are the only post-booking channel): the inbox (`title`/`contact_support`/`empty_*`/`error_body`), the category + status labels keyed off the code (`category_{support,coordination,refund,emergency}`/`status_{open,closed}`), the linked-entity hints (`linked_booking`/`linked_refund` with `{id}`), `ref_code_label`, the new-ticket dialog (`new_ticket_title`/`category_label`/`subject_label`/`message_label`/`submit`/`created_*`/`view_thread`), the thread (`back_to_tickets`/`thread_*`/`closed_notice`), the composer (`sending`/`send`/`send_failed`/`composer_placeholder`), the author-role labels (`author_{customer,nurse,support,system}``admin`→support), and the **emergency playbook** (`emergency_title`/`emergency_body`/`emergency_call {name}`/`emergency_call_generic`/`emergency_open_ticket`) + `open_from_booking`; consumed by the ticket screens, `MessageBubble`/`TicketListCard`/`EmergencyBanner`/`ContactSupportDialog`/`MessageComposer`/`BookingSupportEntry`
- `'notifications'` — the f14 notification center + bell: `title`, `empty_*`, `error_body`, `retry`, `mark_all_read`, `load_more`, and the polled-bell aria (`bell_aria` with `{count, number}`); the row `title`/`body` are **server-rendered** copy, not keys. Consumed by `NotificationCenter` + `NotificationBell`
- `'tickets'` — the f14 messaging surface (tickets are the only post-booking channel): the inbox (`title`/`contact_support`/`empty_*`/`error_body`/`filter_all`/`load_more`), the category + status labels keyed off the code (`category_{support,coordination,refund,emergency}`/`status_{open,closed}`), the linked-entity hints (`linked_booking`/`linked_refund` with `{id}`), `ref_code_label`, the new-ticket dialog (`new_ticket_title`/`category_label`/`subject_label`/`message_label`/`submit`/`created_*`/`view_thread`), the thread (`back_to_tickets`/`thread_*`/`closed_notice`/`day_today`/`day_yesterday`/`new_message_pill`), the composer (`sending`/`send`/`send_failed`/`composer_placeholder`/`discard_failed`/`attach_photo` — the last gated off by `TICKETS_ATTACHMENTS_ENABLED`), the author-role labels (`author_{customer,nurse,support,system}``admin`→support), and both emergency surfaces: the nurse post-confirmation **playbook** (`emergency_title`/`emergency_body`/`emergency_call {name}`/`emergency_call_generic`/`emergency_open_ticket`) + `open_from_booking`, and the ui-phase-10 inbox **compact row** (`emergency_row_title`/`emergency_row_body` — rewritten to never instruct calling a number the inbox can't show); consumed by the ticket screens, `MessageBubble`/`TicketListCard`/`EmergencyBanner`/`EmergencyPlaybookRow`/`ContactSupportDialog`/`MessageComposer`/`BookingSupportEntry`
- `'notifications'` — the f14 notification center + bell: `title`, `empty_*`, `error_body`, `retry`, `mark_all_read`, `load_more`, the day-group headers (`group_today`/`group_yesterday`/`group_this_week`, ui-phase-10), `view_all` (the bell popover's link to the full center), and the polled-bell aria (`bell_aria` with `{count, number}`); the row `title`/`body` are **server-rendered** copy, not keys. Consumed by `NotificationCenter` + `NotificationBell` + `NotificationBellPopover`
- `'auth'` — the phone-OTP login flow, role router, RoleGuard (loading/`account_error_*`/`guard_denied`), and SelectRole screen (`common.brand`/`brand_tagline` for the wordmark); ui-phase-3 added the login-hero `trust_*` bullets, the consent line (`consent_line`, `t.rich` with `<terms>`/`<privacy>` tags), and select-role's `role_add_later_note`
- `'legal'` — ui-phase-3's `/terms`/`/privacy` static pages: `terms_title`/`privacy_title`, `draft_banner` (the human/legal-review flag shown on-page), `terms_intro`/`privacy_intro`, and `terms_sections`/`privacy_sections` (arrays of `{title, body}` read via `t.raw`, not flat keys — the one namespace with structured JSON values). Consumed only by the two legal pages
- `'admin'` — the f15 backoffice consoles: verification queue/case, refund panel, payout dashboard/detail, review moderation, config editor + change-history, holiday manager, support-alert board, audit viewer, admin ticket queue/thread, RBAC grid, and admin-side partner management. Includes the **Persian legal terms** (پروانه تأسیس / مسئول فنی / نماد اعتماد الکترونیکی) and the enum-label prefixes keyed off the stable code (`step_*`/`agg_*`/`atype_*`/`astatus_*`/`sev_*`/`htype_*`/`dtype_*`/`batch_status_*`/`pstatus_*`/`channel_*`/`rstatus_*`/`mstatus_*`/`center_state_*`/`role_*`/`tcat_*`/`tstatus_*`). Consumed by the `/admin/*` screens + the `@/components/admin` composites
- `'partner'` — the f15 partner-center portal (a separate authz scope): center home/onboarding-state, sponsored nurses/bookings, and the merchant-of-record settlement/invoice view (سامانه مودیان, commission/VAT decomposition). Consumed by the `/partner/*` screens + `PartnerSettlementRow`
- `'welcome'` — the ui-phase-13 public landing: hero subtitle/CTA, the five static category-grid labels (`category_*`, distinct from the live `catalog`-namespace category names — this grid is marketing copy, not the catalog API), the how-it-works steps, the trust/verification explainer (`trust_*`, a static counterpart to `VerificationPanel`'s live-badge version), the nurse-recruitment CTA, and the footer (`footer_*`, incl. a plain `{year, number}`-formatted copyright line). Consumed only by `WelcomeScreen.tsx`
**Namespace conventions for the phases to come** (seed each when its feature lands, in both locale
files): none — **MVP namespaces complete** (f15 seeded `admin` + `partner`). Keep top-level keys as
namespaces and both files in sync.
files): none — **MVP namespaces complete** (f15 seeded `admin` + `partner`; ui-phase-13 seeded `welcome`
post-MVP). Keep top-level keys as namespaces and both files in sync.
**Never hard-code UI strings in English.** Any user-visible text must have a translation key in both locale files.
**Persian orthography is a checked-in style guide, not tribal knowledge.** `client/messages/STYLE.md`
(ui-phase-12) is the binding decision record for brand spelling (ZWNJ: «بالین‌یار»), تأیید's hamza form,
the one `جستجو` spelling, ZWNJ rules, the domain glossary, the shell-naming system, the
verification-pipeline-vs-KYC-step naming split, and the digits/policy-number-interpolation rules.
`npm run lint:copy` (`client/scripts/check-copy.mjs`, part of `npm run check`) greps `fa.json` for the
banned variants on every run — a regression fails the gate immediately, it doesn't need re-discovering.
---
## Cookie Manager
@@ -539,7 +563,7 @@ A value is "magic" if its meaning isn't obvious from the literal alone: cookie n
Where to define:
- **Cookie names / options**: `src/lib/cookies/constants.ts`
- **Feature-scope constants**: co-locate in a `constants.ts` next to that feature's files
- **App-wide constants** (used across multiple features): `src/constants/` — one file per concern (`routes.ts`, `events.ts`, etc.)
- **App-wide constants** (used across multiple features): `src/constants/` — one file per concern (`routes.ts`, `events.ts`, etc.). `constants/policy.ts` (ui-phase-12) is this pattern applied to legally-sensitive numbers that trust-critical copy states in plain language (the payout dispute-window hours, the cancellation lead-time hours, the refund ETA day range) — real server config with no public/authenticated read yet (REQ-065), single-sourced here and fed into message keys as ICU params (`{hours}`, `{minDays}`/`{maxDays}`) instead of being baked into the string.
Rules:
1. Import the constant; never copy-paste the string value.
@@ -624,6 +648,20 @@ This includes feedback colors: `--bal-success`, `--bal-error`, `--bal-warning`,
`APP_THEME_LTR` and `APP_THEME_RTL` are created once at module load. Never call `createTheme()` inside a component or hook — pass the appropriate pre-built theme to `MuiThemeProvider`.
### Motion & the reduced-motion gate
Durations/easing live as tokens in `tokens.css` (`--bal-motion-fast/-base/-slow`, `--bal-easing-standard`);
`theme.ts` points `MuiDialog`/`MuiDrawer`/`MuiPopover`/`MuiMenu`'s `defaultProps.transitionDuration` at the
same numbers (in one place) instead of MUI's per-variant defaults. `RouteFadeIn`
(`components/common/RouteFadeIn/`) is the one route-content fade/slide primitive, mounted inside the
`ErrorBoundary` in all five shells — new pages get the motion for free, no per-page wiring.
**`prefers-reduced-motion: reduce` has exactly one gate**, in `src/app/globals.css`: a universal
`*, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; … }`
media-query block. This is deliberately a global CSS reset, not a token-only zeroing (`tokens.css` also
zeroes the duration tokens, but that alone wouldn't reach MUI's own JS-driven Dialog/Drawer/Menu/Collapse
transitions, which don't read the CSS custom properties) — never add a second, component-local
reduced-motion branch; extend this one rule if a new motion primitive needs the same treatment.
### Toggle components
`DarkModeToggleButton` and `DarkModeFormSwitch` in `src/layout/components/DarkModeButton.tsx` are the **only** components that subscribe to `useColorScheme()`. When the user toggles:
@@ -959,6 +997,9 @@ PUBLIC_PATHS = [ROUTES.LOGIN, ...] // paths that bypass middleware auth check
Import from the barrel: `import { ROUTES, PUBLIC_PATHS } from '@/constants'`.
To add a new public route, append it to `PUBLIC_PATHS` — the middleware picks it up automatically.
**Never append `ROUTES.HOME` ('/') itself** — `PUBLIC_PATHS` is matched with `startsWith`, so `'/'`
would silently make every route public. The guest-facing root (ui-phase-13) is handled by an
exact-match `NextResponse.rewrite()` in `middleware.ts` instead, to `ROUTES.WELCOME` ('/welcome').
---
+116
View File
@@ -0,0 +1,116 @@
# Persian (fa) copy style guide
One page, binding for `messages/fa.json`. `scripts/check-copy.mjs` (`npm run lint:copy`, part of
`npm run check`) enforces the banned-variant rules below so these decisions cannot silently regress.
This file does not repeat `en.json` conventions beyond what's noted in §7 — the English catalog is
hand-written and reviewed for idiom, not linted.
## 1. Brand name
**«بالین‌یار» — ZWNJ (``) between بالین and یار, always.** Never a plain space («بالین یار»).
The brand name appears in money/trust copy (login, escrow, refunds) as often as anywhere else — an
unstable brand mark there is the worst place to be inconsistent.
## 2. تأیید — hamza, always
Write **تأیید** (with hamza) and its derived forms — **تأییدشده**, **تأییدیه**, **تأیید کردن** — every
time, never تایید/تاییدشده/تاییدیه (hamza-less). This is the single most frequent word in a
verification product; one spelling, no exceptions, in every namespace (booking, payment, auth,
verification, admin, payouts, bnpl, refunds, legal — all of them).
## 3. جستجو — one form
Standard form: **جستجو** (no ZWNJ, one word). Not «جست‌وجو» / «جست و جو». Applies to the noun and any
compound (`در جستجو`, `نتایج جستجو`).
## 4. ZWNJ (نیم‌فاصله) rules
Use ZWNJ (``) — never a plain space or no separator — in:
- **می‌ + verb stem**: می‌شود، می‌کند، می‌پردازید، می‌ماند (never میشود/می شود).
- **Plural ها**: مراقب‌ها-style compounds keep the ZWNJ before ها when the base ends in a consonant that
would otherwise misread (`شب‌ها` not `شبها`); a plain plural on a word already ending in a vowel/silent-h
takes the ZWNJ too (`بچه‌ها`).
- **Compound past-participle adjectives**: تأییدشده، لغوشده، ردشده، منتشرشده، پرداخت‌شده — the doer/state
compound is one ZWNJ-joined word, not two spaced words («تایید شده») and not fused with no separator.
- Brand name itself (§1) is the other load-bearing ZWNJ case.
## 5. Punctuation & quotes
- Persian text uses «...» guillemets for quoted terms/labels in prose (as this document does), and
Persian «،» / «؛» for commas/semicolons *inside translated sentences* where the surrounding punctuation
is itself Persian prose (most UI strings use plain Latin `,`/`;` today for simplicity in short labels —
don't retrofit existing short strings, but prefer «،»/«؛» in new multi-clause sentences).
- English (`en.json`) uses **straight** apostrophes (`don't`, `couldn't`) throughout — never curly
(``, ``). One admin-namespace holdout (curly `don't`/`couldn't`) is fixed by this phase; don't
reintroduce curly quotes when editing English copy.
## 6. Domain glossary
- **بیمار** — the care recipient, used consistently everywhere except one parenthetical. Do not adopt
«مددجو» — it appeared exactly once (`booking.patient_label`) and has been dropped in favor of the
99%-majority «بیمار».
- **پرستار** — the caregiver, always (never «مراقب» as a noun for the person — «مراقب» only survives as
an adjective/role qualifier, e.g. `booking.gender_label` "جنسیت مراقب" meaning "the caregiver's gender").
- **رزرو** — a confirmed, paid booking. **درخواست رزرو** — a pre-payment request. Never conflate the two;
a `booking_request` is never called «رزرو» before it converts.
- **ویزیت** — one scheduled visit/session within a booking.
- **شبا** — IBAN, always («شماره شبا» for the field label, «شبا» alone elsewhere).
## 7. Shell naming system
One metaphor per audience class, not four:
- **End-user shells** (family, nurse — the apps people book/work through day to day) → **«اپلیکیشن»**:
«اپلیکیشن خانواده», «اپلیکیشن پرستار».
- **Back-office shells** (staff consoles — admin, partner-center) → **«کنسول»**: «کنسول مدیریت»,
«کنسول همکار».
- Never «نما» (view) or «پرتال» (portal) for a whole shell name — those read as one-off inconsistent
metaphors. (`booking.evv_nurse_view` "نمای پرستار" is a different thing — a chip labeling *whose
perspective* a shared booking-detail screen is rendered from, not a shell name; it correctly keeps
«نما» in that narrower sense.)
## 8. Verification pipeline vs. the identity step
**«تأیید صلاحیت»** names the whole 7-step nurse trust pipeline (nav entry, the verification hub's title,
its start/progress/approved states, the admin queue). **«احراز هویت»** stays the name of the one KYC
step inside it (national-ID + civil-registry + liveness selfie) — both on the nurse side
(`verification.step_identity_kyc`) and the admin side (`admin.step_identity_kyc`). A nurse who passed the
KYC step but still sees a pipeline titled «احراز هویت» incomplete in the nav used to read as a
contradiction; they no longer share a name.
## 9. Status vocabulary — one nurse-facing form, one admin-facing form
For "this step/item was rejected/failed" style states that appear on both a nurse-facing screen and an
admin-facing screen for the *same underlying concept* (a verification step's outcome):
- **Nurse-facing**: «رد شد» (`verification.status_failed`) — a short declarative sentence-style status,
matching the register of its sibling `status_passed` ("تأییدشده")/`status_in_review` ("در حال بررسی").
- **Admin-facing**: «ردشده» (`admin.step_failed`, `admin.agg_rejected`, `admin.rstatus_rejected`,
`admin.mstatus_rejected`) — the compound-adjective state form, matching the admin namespace's own
`step_passed`/`agg_approved`/`center_state_verified` ("تأییدشده") pattern.
This does **not** extend to unrelated money-failure vocabulary (`payouts.pstatus_failed`,
`refunds.rstatus_failed`, `admin.batch_status_failed` all legitimately use «ناموفق» — a transfer/payment
*failing* is a different concept from a document being *rejected*, and conflating them would blur a real
distinction).
## 10. Digits policy
Persian digits (۰۱۲۳۴۵۶۷۸۹) everywhere on `/fa` — both hard-coded literals (`"۲۴ ساعت"`) and
interpolated numbers. For an interpolated `{count}`/`{hours}`/… inside an ICU message, use the ICU
`number` sub-format (`{count, number}`) or a plain `#` inside a `plural` block — next-intl formats both
through the active locale (`fa` → Persian digits) automatically. When a raw number is interpolated at a
call site instead of through ICU (e.g. built into a larger string in code, not a message placeholder),
route it through `formatNumber` (`@/utils`) — never template a raw JS number directly into Persian text.
## 11. Register
Formal شما throughout, with polite imperatives (کنید) for actions and instructions. Already consistent
across the whole catalog — this codifies it so a future addition can't drift into informal تو/imperative
stems (نکن, برو).
## 12. Policy numbers
Legally/financially sensitive numbers that the admin config panel can change (the dispute-window hours,
cancellation lead-time hours, refund ETA days) are **never hard-coded into a message string**. The
message key takes a parameter (`{hours}`, `{minDays}`/`{maxDays}`) and the call site interpolates from
`client/src/constants/policy.ts` (single-sourced, REQ-065 tracks the eventual public config-read that
replaces the constants file). A config edit must never again silently make the UI copy lie.
+199 -52
View File
@@ -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"
@@ -430,11 +446,11 @@
"categories_error": "Couldn't load categories.",
"cta_choose_category_city": "Choose a category and city",
"cta_loading": "Counting nurses…",
"cta_view_results": "View {count, plural, =0 {no nurses} one {# nurse} other {# nurses}}",
"cta_view_results": "{count, plural, =0 {No nurses found} one {View # nurse} other {View # nurses}}",
"cta_zero_title": "No nurses match these filters",
"cta_zero_hint": "Loosen a filter to see more nurses.",
"results_loading_title": "Searching…",
"results_count": "{count, plural, =0 {No nurses} one {# nurse} other {# nurses}}",
"results_count": "{count, plural, =0 {No nurses found} one {# nurse} other {# nurses}}",
"sort_static": "Sorted by rating",
"results_error": "Something went wrong loading results.",
"retry": "Try again",
@@ -552,7 +568,7 @@
"accepted_body": "Pay within the window below to confirm the booking.",
"payment_countdown_label": "Payment window",
"payment_elapsed": "The payment window has closed",
"continue_payment": "Continue to payment",
"continue_payment": "Continue to payment",
"cancel_request": "Cancel request",
"cancelling": "Cancelling…",
"cancel_confirm_title": "Cancel this request?",
@@ -815,12 +831,12 @@
"customer_title": "Sign in to Balinyaar",
"customer_subtitle": "Sign in with your mobile number",
"nurse_title": "Nurse sign in",
"nurse_subtitle": "For nurses with a Nursing Council licence",
"nurse_subtitle": "For nurses with a Nursing Council license",
"phone_label": "Mobile number",
"phone_invalid": "Enter a valid Iranian mobile number",
"request_code": "Get verification code",
"nurse_switch": "Are you a nurse? Nurse sign in",
"customer_switch": "Are you a family? Family sign in",
"nurse_switch": "Are you a nurse? Nurse sign in",
"customer_switch": "Are you a family? Family sign in",
"rate_limited": "Too many attempts — please try again shortly",
"otp_title": "Enter the verification code",
"otp_sent_to": "Code sent to {phone}",
@@ -1008,8 +1024,8 @@
"policy_free_24h": "Free cancellation",
"policy_partial_under_24h": "Partial refund",
"policy_customer_no_show": "No refund",
"lead_gt_24h": "More than 24 hours before the visit",
"lead_lt_24h": "Less than 24 hours before the visit",
"lead_gt_24h": "More than {hours} hours before the visit",
"lead_lt_24h": "Less than {hours} hours before the visit",
"lead_started": "The visit has already started",
"refund_percent": "{percent}% refunded",
"fee_percent": "Cancellation fee: {percent}%",
@@ -1070,7 +1086,7 @@
"eta_bnpl_body": "Unpaid installments are cancelled and credited back to your installment wallet; any installment you already paid is returned to your bank account.",
"eta_manual_title": "Manual bank transfer",
"eta_manual_body": "The amount is transferred to your bank account by our team.",
"eta_business_days": "About 710 business days",
"eta_business_days": "About {minDays}{maxDays} business days",
"eta_expected_label": "estimated by {date}",
"cancel_booking_cta": "Cancel booking",
"refund_section_title": "Refund",
@@ -1194,7 +1210,7 @@
"bucket_clawback": "Clawback outstanding",
"explainer_title": "How payouts work",
"explainer_point_1": "You're paid in weekly batches to your verified primary account.",
"explainer_point_2": "An amount becomes eligible only after the visit is verified (check-out) and the 72-hour dispute window closes.",
"explainer_point_2": "An amount becomes eligible only after the visit is verified (check-out) and the {hours}-hour dispute window closes.",
"explainer_point_3": "You receive the same amount whether the family paid by card or in installments — the installment provider's fee is never deducted from you.",
"tab_all": "All",
"tab_pending": "Pending",
@@ -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",
@@ -1360,6 +1401,15 @@
"empty_body": "Open a ticket to coordinate or get support.",
"error_body": "Couldn't load your tickets.",
"retry": "Retry",
"filter_all": "All",
"load_more": "Load more",
"day_today": "Today",
"day_yesterday": "Yesterday",
"new_message_pill": "New message ↓",
"discard_failed": "Discard and edit",
"attach_photo": "Attach a photo",
"emergency_row_title": "Emergencies",
"emergency_row_body": "If this is urgent, open a ticket and our support team will follow up as soon as possible.",
"category_support": "Support",
"category_coordination": "Visit coordination",
"category_refund": "Refund",
@@ -1409,7 +1459,11 @@
"retry": "Retry",
"mark_all_read": "Mark all read",
"load_more": "Load more",
"bell_aria": "{count, number} unread notifications"
"bell_aria": "{count, number} unread notifications",
"group_today": "Today",
"group_yesterday": "Yesterday",
"group_this_week": "This week",
"view_all": "View all"
},
"admin": {
"overview_title": "Backoffice",
@@ -1437,12 +1491,12 @@
"actor": "Actor",
"timestamp": "Time",
"showing_range": "Showing {from}{to} of {total}",
"page_indicator": "Page {page}",
"page_indicator": "Page {page} of {total}",
"prev_page": "Previous",
"next_page": "Next",
"search_placeholder": "Search…",
"refresh": "Refresh",
"no_permission": "You dont have access to this console.",
"no_permission": "You don't have access to this console.",
"role_gate_hint": "This action is limited to certain admin roles.",
"masked_iban_label": "IBAN",
"ver_title": "Verification queue",
@@ -1452,10 +1506,15 @@
"ver_col_step": "Step",
"ver_col_status": "Status",
"ver_col_submitted": "Submitted",
"ver_col_waiting": "Waiting",
"ver_search_label": "Search nurse",
"ver_search_ph": "Name or phone",
"ver_progress": "{done} of {total}",
"ver_next_step": "Next: {step}",
"ver_open_case": "Review",
"ver_case_title": "Verification case",
"ver_next_case": "Next case",
"ver_prev_case": "Previous case",
"ver_identity_name": "Identity on file",
"ver_steps_title": "Steps",
"ver_documents_title": "Documents",
@@ -1485,7 +1544,7 @@
"doc_loading": "Loading document…",
"doc_expired": "This secure link expired.",
"doc_reload": "Re-request link",
"doc_error": "Couldnt load the document.",
"doc_error": "Couldn't load the document.",
"doc_open_new": "Open in a new tab",
"doc_file_meta": "{name} · {size}",
"payout_title": "Payout batches",
@@ -1509,7 +1568,10 @@
"payout_eligibility_note": "Only completed bookings past their dispute window appear here — computed server-side.",
"payout_run": "Run batch",
"payout_run_confirm_title": "Run this payout batch?",
"payout_run_confirm_body": "Money moves to nurses. This is protected by an idempotency key — a double-click cant pay a booking twice.",
"payout_run_confirm_body": "Money moves to nurses. This is protected by an idempotency key — a double-click can't pay a booking twice.",
"payout_run_summary_intro": "This batch will run and the amount below will be transferred:",
"payout_run_count_label": "Nurse count",
"payout_run_type_to_confirm": "Type “CONFIRM” or the amount to proceed",
"payout_running": "Submitting to the bank rail…",
"payout_skipped": "Skipped nurses",
"payout_batch_title": "Batch #{id}",
@@ -1557,7 +1619,7 @@
"mod_publish": "Publish",
"mod_hide": "Hide",
"mod_reject": "Reject",
"mod_confirm_publish": "Publish this review? It becomes public and re-computes the nurses rating.",
"mod_confirm_publish": "Publish this review? It becomes public and re-computes the nurse's rating.",
"mod_confirm_hide": "Hide this review with the reason above?",
"mod_confirm_reject": "Reject this review with the reason above?",
"mod_done": "Review updated.",
@@ -1580,7 +1642,8 @@
"cfg_saved": "Configuration saved.",
"cfg_history": "Change history",
"cfg_history_title": "History — {key}",
"cfg_history_change": "{old} → {new}",
"cfg_history_change_old": "{old}",
"cfg_history_change_new": "{new}",
"cfg_history_empty": "No changes recorded yet.",
"cfg_group_fees": "Fees & VAT",
"cfg_group_deadlines": "Deadlines & windows",
@@ -1656,6 +1719,16 @@
"ticket_sent": "Message sent.",
"ticket_participants": "Participants",
"ticket_linked_refund": "Refund #{id}",
"ticket_activity_col": "Last activity",
"ticket_send_internal": "Save internal note",
"ticket_close": "Close ticket",
"ticket_close_confirm": "This ticket will close and leave the open queue. Continue?",
"ticket_reopen": "Reopen ticket",
"ticket_reopen_confirm": "This ticket will reopen. Continue?",
"ticket_assign_me": "Assign to me",
"ticket_assigned_me": "Ticket assigned to you.",
"ticket_closed_ok": "Ticket closed.",
"ticket_reopened_ok": "Ticket reopened.",
"partner_title": "Partner centers",
"partner_subtitle": "Licensed centers that sponsor nurses and may be merchant-of-record.",
"partner_empty": "No partner centers yet.",
@@ -1690,6 +1763,20 @@
"partner_assign_nurse_ph": "Nurse profile id",
"partner_unlink_nurse": "Remove",
"partner_nurse_assigned": "Nurse assignment updated.",
"user_title": "Users",
"user_subtitle": "Search users by name or phone number.",
"user_col_name": "Name",
"user_col_phone": "Phone",
"user_col_id": "ID",
"user_col_roles": "Roles",
"user_view_audit": "View audit trail",
"user_search_label": "Search",
"user_search_hint": "Type at least 2 characters of a name or phone number to search.",
"user_empty": "No users found",
"user_role_customer": "Family",
"user_role_nurse": "Nurse",
"user_role_admin": "Admin",
"user_role_partner": "Partner",
"role_title": "Roles & access",
"role_subtitle": "Grant or revoke admin roles. (Awaiting backend role endpoints.)",
"role_deferred": "This console is served by a client-side placeholder until the RBAC endpoints ship.",
@@ -1698,9 +1785,13 @@
"role_col_granted": "Granted",
"role_grant": "Grant role",
"role_revoke": "Revoke",
"role_grant_confirm": "Grant {role} to user #{id}?",
"role_revoke_confirm": "Revoke {role} from user #{id}?",
"role_grant_confirm": "Grant {role} to {name}?",
"role_revoke_confirm": "Revoke {role} from {name}?",
"role_updated": "Role updated.",
"user_picker_search_ph": "Search by name or phone…",
"user_picker_no_options": "No users found",
"user_picker_loading": "Searching…",
"assign_me_loading": "Loading your account…",
"agg_not_started": "Not started",
"agg_pending": "Pending",
"agg_in_review": "In review",
@@ -1795,7 +1886,7 @@
"legal_type": "Legal entity type",
"is_mor_yes": "Merchant of record",
"is_mor_no": "Settlement runs through Balinyaar",
"access_denied": "You dont have access to a partner center.",
"access_denied": "You don't have access to a partner center.",
"nurses_title": "Sponsored nurses",
"nurses_empty": "No nurses sponsored yet.",
"nurses_col_name": "Nurse",
@@ -1806,6 +1897,19 @@
"bookings_col_patient": "Patient",
"bookings_col_date": "Date",
"bookings_col_status": "Status",
"bstatus_pending_payment": "Pending payment",
"bstatus_confirmed": "Confirmed",
"bstatus_in_progress": "In progress",
"bstatus_completed": "Completed",
"bstatus_disputed": "Disputed",
"bstatus_closed": "Closed",
"bstatus_cancelled": "Cancelled",
"booking_detail_title": "Booking detail",
"booking_detail_back": "Back to bookings",
"booking_detail_not_found": "This booking could not be found.",
"booking_detail_patient": "Patient",
"booking_detail_scheduled_date": "Scheduled date",
"booking_detail_timeline_title": "Status timeline",
"settlement_title": "Settlement & invoices",
"settlement_not_mor": "This center is not merchant-of-record — settlement runs through Balinyaar, and no commission invoices are issued here.",
"settlement_empty": "No invoices yet.",
@@ -1821,9 +1925,17 @@
"invoice_row_vat": "VAT (on commission)",
"invoice_row_total": "Invoice total",
"invoice_download": "Download PDF",
"invoice_pdf_error": "Couldnt open the invoice PDF.",
"invoice_pdf_error": "Couldn't open the invoice PDF.",
"invoice_number": "Invoice #{number}",
"settlement_iban": "Settlement IBAN"
"settlement_iban": "Settlement IBAN",
"settlement_export_csv": "Export CSV",
"csv_col_invoice_number": "Invoice number",
"csv_col_booking_id": "Booking",
"csv_col_gross": "Gross (Toman)",
"csv_col_commission": "Commission (Toman)",
"csv_col_vat": "VAT (Toman)",
"csv_col_total": "Total (Toman)",
"csv_col_issued_at": "Issued at"
},
"routeChrome": {
"error_title": "Something went wrong",
@@ -1858,5 +1970,40 @@
{ "title": "Changes to this policy", "body": "We may update this policy as the service evolves. Material changes will be announced in the app before they take effect." },
{ "title": "Contact", "body": "Questions about this policy can be sent through the support ticket system in the app." }
]
},
"welcome": {
"meta_description": "Balinyaar connects families with verified home-care nurses — secure escrow payment and peace-of-mind care.",
"hero_subtitle": "Verified nurses for the people you love, with secure, transparent payment.",
"hero_cta": "Get started",
"categories_title": "Services you can book",
"category_elderly": "Elderly care",
"category_post_surgery": "Post-surgery care",
"category_infant": "Infant care",
"category_chronic": "Chronic conditions",
"category_companionship": "Companionship & daily care",
"how_it_works_title": "How Balinyaar works",
"step1_title": "Search a verified nurse",
"step1_body": "Filter by city, district, and service to choose from verified nurses.",
"step2_title": "Secure escrow payment",
"step3_title": "Peace-of-mind care",
"step3_body": "The nurse logs the completed visit, and is paid only once it is confirmed.",
"trust_title": "What Balinyaar verifies before a booking",
"trust_intro": "Before a nurse can be booked on Balinyaar, they pass the following steps.",
"trust_identity_title": "Identity verification",
"trust_identity_body": "An automated match of national ID, mobile number, and a live selfie against civil-registry records.",
"trust_license_title": "Professional competency license",
"trust_license_body": "The Ministry of Health's official license to practice home nursing.",
"trust_ino_title": "Nursing Organization membership",
"trust_ino_body": "Cross-checked against the Iranian Nursing Organization as a second source.",
"trust_bank_title": "Bank account ownership check",
"trust_bank_body": "Each nurse is paid only into their own verified bank account.",
"nurse_cta_title": "Are you a nurse? Join Balinyaar",
"nurse_cta_body": "List your own services at your own price, and start receiving bookings once you're verified.",
"nurse_cta_button": "Get started as a nurse",
"footer_terms": "Terms of Service",
"footer_privacy": "Privacy Policy",
"footer_contact_title": "Support",
"footer_contact_body": "For any questions, reach us through the in-app support ticket system after you sign in.",
"footer_copyright": "© {year, number} Balinyaar"
}
}
+280 -133
View File
@@ -2,7 +2,7 @@
"nav": {
"home": "خانه",
"bookings": "رزروها",
"patients": "بیماران",
"patients": "حلقهٔ مراقبت",
"wallet": "کیف‌پول",
"profile": "پروفایل",
"bank": "حساب بانکی",
@@ -10,7 +10,7 @@
"services": "خدمات",
"dashboard": "داشبورد",
"requests": "درخواست‌ها",
"verification": "احراز هویت",
"verification": "تأیید صلاحیت",
"visits": "ویزیت‌ها",
"earnings": "درآمدها",
"admin": "مدیریت",
@@ -60,21 +60,24 @@
"close": "بستن",
"optional": "اختیاری",
"currency_toman": "تومان",
"brand": "بالین یار",
"brand": "بالینیار",
"brand_tagline": "مراقبت مطمئن در خانه",
"open_sidebar": "باز کردن منو",
"switch_locale": "تغییر به {locale}",
"page_prev": "قبلی",
"page_next": "بعدی",
"page_indicator": "صفحه {page} از {total}"
"page_indicator": "صفحه {page} از {total}",
"discard_title": "تغییرات نادیده گرفته شود؟",
"discard_body": "تغییراتی که ذخیره نشده از دست می‌رود.",
"discard_confirm": "بله، نادیده بگیر"
},
"shell": {
"customer_app": "اپلیکیشن خانواده",
"nurse_app": مای پرستار",
"nurse_app": "اپلیکیشن پرستار",
"admin_console": "کنسول مدیریت",
"placeholder_body": "این بخش در فازهای بعدی تکمیل می‌شود.",
"partner_console": "پرتال همکار",
"switch_to_nurse": مای پرستار",
"partner_console": "کنسول همکار",
"switch_to_nurse": "اپلیکیشن پرستار",
"switch_to_customer": "اپلیکیشن خانواده"
},
"home": {
@@ -86,15 +89,15 @@
"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_verified_nurses": "پرستاران تأییدشده",
"trust_support": "پشتیبانی",
"rebook_with": "رزرو دوباره با {name}"
},
@@ -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": "پروفایل پرستار",
@@ -186,8 +196,8 @@
"save": "ذخیره پروفایل",
"saved": "پروفایل ذخیره شد",
"unverified_title": "پروفایل شما هنوز فعال نیست",
"unverified_body": "برای نمایش به خانواده‌ها و دریافت رزرو، احراز هویت را تکمیل کنید.",
"unverified_cta": "تکمیل احراز هویت",
"unverified_body": "برای نمایش به خانواده‌ها و دریافت رزرو، تأیید صلاحیت را تکمیل کنید.",
"unverified_cta": "تکمیل تأیید صلاحیت",
"deferred_services": "خدمات و قیمت‌های خود را در بخش «خدمات و قیمت‌ها» مدیریت کنید؛ روزهای کاری در مرحله بعد اضافه می‌شود.",
"save_error": "ذخیرهٔ پروفایل انجام نشد. دوباره تلاش کنید.",
"avatar_upload_error": "بارگذاری عکس انجام نشد. دوباره تلاش کنید.",
@@ -214,7 +224,7 @@
"preview_reviews_count": "({count, plural, =0 {بدون نظر} one {# نظر} other {# نظر}})",
"preview_completed_visits": "{count, number} ویزیت موفق",
"preview_years_experience": "{years} سال سابقه",
"preview_verified_title": "بالین‌یار چه چیزی را تایید کرده است",
"preview_verified_title": "بالین‌یار چه چیزی را تأیید کرده است",
"preview_services_title": "خدمات",
"preview_services_empty": "هنوز خدمت فعالی ندارید — یک خدمت در بخش «خدمات و قیمت‌ها» اضافه کنید.",
"preview_coverage_title": "مناطق تحت پوشش",
@@ -238,8 +248,8 @@
"status_pending_chip": "در حال استعلام",
"status_pending_title": "در حال استعلام مالکیت حساب",
"status_pending_body": "در حال استعلام صحت شبا؛ معمولاً چند دقیقه طول می‌کشد…",
"status_verified_chip": اییدشد",
"status_verified_title": "حساب تایید شد",
"status_verified_chip": أییدشده",
"status_verified_title": "حساب تأیید شد",
"status_verified_body": "این حساب برای واریز درآمد شما آماده است.",
"status_mismatch_chip": "مغایرت مالکیت",
"status_mismatch_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": "مدیریت آدرس‌ها"
@@ -308,7 +324,7 @@
"adding": "در حال افزودن…",
"whole_city_chip": "کل شهر",
"empty_title": "هنوز منطقه‌ای ثبت نشده",
"empty_warning": "تا زمانی که حداقل یک منطقهٔ تحت پوشش اضافه نکنید، در جست‌وجو نمایش داده نمی‌شوید.",
"empty_warning": "تا زمانی که حداقل یک منطقهٔ تحت پوشش اضافه نکنید، در جستجو نمایش داده نمی‌شوید.",
"duplicate": "این منطقه از قبل تحت پوشش شماست.",
"city_required": "شهر را انتخاب کنید",
"added": "منطقهٔ تحت پوشش اضافه شد",
@@ -398,7 +414,7 @@
"row_profile": "تکمیل نمایه",
"row_services": "حداقل یک خدمت فعال",
"row_coverage": "محدودهٔ پوشش",
"row_bank": "شبای تاییدشده",
"row_bank": "شبای تأییدشده",
"row_bank_hint": "برای دریافت درآمد — برای نمایش در جستجو لازم نیست.",
"row_fix": "تکمیل",
"live_title": "فعال در جستجو",
@@ -408,7 +424,7 @@
},
"search": {
"title": "یافتن پرستار",
"subtitle": "فقط پرستاران تاییدشده و دارای صلاحیت اینجا نمایش داده می‌شوند.",
"subtitle": "فقط پرستاران تأییدشده و دارای صلاحیت اینجا نمایش داده می‌شوند.",
"section_category": "دستهٔ مراقبت",
"section_location": "شهر",
"section_gender": "جنسیت پرستار",
@@ -430,11 +446,11 @@
"categories_error": "بارگذاری دسته‌ها ممکن نشد.",
"cta_choose_category_city": "یک دسته و شهر انتخاب کنید",
"cta_loading": "در حال شمارش پرستاران…",
"cta_view_results": "مشاهده {count} پرستار",
"cta_view_results": "{count, plural, =0 {پرستاری یافت نشد} one {مشاهدهٔ # پرستار} other {مشاهدهٔ # پرستار}}",
"cta_zero_title": "پرستاری با این فیلترها یافت نشد",
"cta_zero_hint": "فیلترها را کمی باز کنید تا پرستاران بیشتری ببینید.",
"results_loading_title": "در حال جستجو…",
"results_count": "{count} پرستار",
"results_count": "{count, plural, =0 {پرستاری یافت نشد} one {# پرستار} other {# پرستار}}",
"sort_static": "مرتب‌شده بر اساس امتیاز",
"results_error": "در بارگذاری نتایج مشکلی پیش آمد.",
"retry": "تلاش دوباره",
@@ -447,7 +463,7 @@
"unnamed_nurse": "پرستار",
"unnamed_service": "خدمت",
"completed_visits": "{count, number} ویزیت موفق",
"reviews_count": "({count} نظر)",
"reviews_count": "({count, plural, =0 {بدون نظر} one {# نظر} other {# نظر}})",
"distance_km": "{km} کیلومتر",
"price_from": "از",
"price_chip_range": "از {min} تا {max} تومان",
@@ -484,7 +500,7 @@
"form_subtitle": "برای این پرستار درخواست بفرستید. هنوز پرداختی انجام نمی‌شود؛ ابتدا پرستار درخواست را بررسی می‌کند.",
"whathappens_title": "چه اتفاقی می‌افتد؟",
"missing_nurse_title": "پرستاری انتخاب نشده است",
"missing_nurse_body": "ابتدا از جست‌وجو یک پرستار انتخاب کنید و سپس درخواست دهید.",
"missing_nurse_body": "ابتدا از جستجو یک پرستار انتخاب کنید و سپس درخواست دهید.",
"missing_nurse_cta": "یافتن پرستار",
"patient_label": "بیمار (مددجو)",
"patient_placeholder": "انتخاب بیمار",
@@ -514,7 +530,7 @@
"gender_hint": "برای مراقبت‌های بدنی، هم‌جنس بودن مراقب اهمیت دارد. انتخاب شما همراه درخواست ارسال می‌شود.",
"notes_label": "توضیحات برای پرستار",
"notes_placeholder": "آنچه پرستار پیش از پذیرش می‌بیند…",
"notes_hint": "این تنها چیزی است که پرستار پیش از پذیرش می‌بیند؛ شرح کامل مراقبت پس از تایید ثبت می‌شود.",
"notes_hint": "این تنها چیزی است که پرستار پیش از پذیرش می‌بیند؛ شرح کامل مراقبت پس از تأیید ثبت می‌شود.",
"notes_counter": "{count}/{max}",
"submit": "ارسال درخواست",
"submitting": "در حال ارسال…",
@@ -541,18 +557,18 @@
"awaiting_title": "درخواست برای پرستار ارسال شد",
"awaiting_subtitle": "در انتظار پاسخ پرستار",
"step_submitted": "درخواست ثبت شد",
"step_awaiting": "در انتظار تایید پرستار",
"step_payment": "پرداخت و تایید نهایی",
"step_awaiting": "در انتظار تأیید پرستار",
"step_payment": "پرداخت و تأیید نهایی",
"response_countdown_label": "مهلت پاسخ پرستار",
"response_elapsed": "در انتظار تایید سرور…",
"response_elapsed": "در انتظار تأیید سرور…",
"response_notify_note": "نتیجه را به شما اطلاع می‌دهیم",
"countdown_about_hours": "حدود {hours} ساعت",
"countdown_about_minutes": "حدود {minutes} دقیقه",
"accepted_badge": "پرستار تایید کرد",
"accepted_badge": "پرستار تأیید کرد",
"accepted_body": "برای نهایی‌شدن رزرو، در مهلت زیر پرداخت را انجام دهید.",
"payment_countdown_label": "مهلت پرداخت",
"payment_elapsed": "مهلت پرداخت به پایان رسید",
"continue_payment": "ادامه پرداخت",
"continue_payment": "ادامه پرداخت",
"cancel_request": "انصراف از درخواست",
"cancelling": "در حال لغو…",
"cancel_confirm_title": "این درخواست لغو شود؟",
@@ -613,13 +629,13 @@
"bd_my_bookings": "رزروهای من",
"timeline_title": "روند رزرو",
"bstatus_pending_payment": "در انتظار پرداخت",
"bstatus_confirmed": اییدشده",
"bstatus_confirmed": أییدشده",
"bstatus_in_progress": "در حال انجام",
"bstatus_completed": "تکمیل‌شده",
"bstatus_disputed": "در حال بررسی اختلاف",
"bstatus_closed": "بسته‌شده",
"bstatus_cancelled": "لغوشده",
"confirmed_note": "رزرو شما تایید شد. ویزیت‌های زیر برنامه‌ریزی شده‌اند.",
"confirmed_note": "رزرو شما تأیید شد. ویزیت‌های زیر برنامه‌ریزی شده‌اند.",
"in_progress_note": "یک ویزیت هم‌اکنون در حال انجام است.",
"completed_note": "همهٔ ویزیت‌ها به پایان رسید.",
"dispute_window_note": "مبلغ پس از پایان مهلت بررسی در {date} برای پرستار آزاد می‌شود.",
@@ -627,7 +643,7 @@
"disputed_note": "این رزرو در حال بررسی توسط تیم پشتیبانی است.",
"sessions_title": "برنامهٔ ویزیت‌ها",
"session_index": "ویزیت {n}",
"session_count": "{count} ویزیت",
"session_count": "{count, plural, =0 {ویزیتی نیست} one {# ویزیت} other {# ویزیت}}",
"sstatus_scheduled": "برنامه‌ریزی‌شده",
"sstatus_in_progress": "در حال انجام",
"sstatus_completed": "تکمیل‌شده",
@@ -642,20 +658,20 @@
"money_nurse_earning": "درآمد شما",
"evv_today_title": "ویزیت امروز",
"evv_visits_title": "ویزیت‌های امروز",
"evv_visits_subtitle": "برای هر ویزیت، ورود و خروج را با EVV ثبت کنید.",
"evv_visits_subtitle": "ثبت حضور الکترونیکی (EVV) — ورود و خروج شما ثبت می‌شود تا ویزیت بدون اختلاف تأیید شود.",
"evv_no_visits": "برای امروز ویزیتی برنامه‌ریزی نشده است.",
"evv_check_in": "ثبت ورود (EVV)",
"evv_check_out": "ثبت خروج (EVV)",
"evv_checking_in": "در حال ثبت ورود…",
"evv_checking_out": "در حال ثبت خروج…",
"evv_acquiring_location": "در حال دریافت موقعیت…",
"evv_banner_in_range": "ورود ثبت شد {time} · موقعیت تایید شد (EVV)",
"evv_banner_in_range": "ورود ثبت شد {time} · موقعیت تأیید شد (EVV)",
"evv_banner_out_of_range": "ورود ثبت شد {time} · موقعیت خارج از محدوده (در حال بررسی)",
"evv_banner_no_gps": "ورود ثبت شد {time} · موقعیت ثبت نشد",
"evv_presence_headline": "پرستار در محل است · ورود {time}",
"evv_checked_out_at": "خروج ثبت شد {time}",
"evv_gps_denied_note": "دسترسی به موقعیت داده نشد — همچنان می‌توانید ویزیت را ثبت کنید؛ برای بررسی علامت‌گذاری می‌شود.",
"evv_no_open_check_in": "ورود بازی برای ثبت خروج وجود ندارد.",
"evv_no_open_check_in": "ورودِ ثبت‌شده‌ای برای این ویزیت وجود ندارد؛ ابتدا ورود را ثبت کنید.",
"evv_not_startable": "این ویزیت هنوز قابل شروع نیست.",
"evv_checked_in_toast": "ورود ثبت شد.",
"evv_checked_out_toast": "خروج ثبت شد.",
@@ -664,7 +680,7 @@
"evv_nurse_view": "نمای پرستار",
"view_booking": "مشاهدهٔ رزرو",
"care_title": "دستورالعمل مراقبت",
"care_subtitle": "پس از تایید، به‌عنوان پرستار مسئول با شما به اشتراک گذاشته شده است.",
"care_subtitle": "پس از تأیید، به‌عنوان پرستار مسئول با شما به اشتراک گذاشته شده است.",
"care_conditions": "شرایط پزشکی",
"care_medications": "داروها",
"care_allergies": "حساسیت‌ها",
@@ -676,7 +692,7 @@
"care_locked_title": "شرح مراقبت",
"care_locked_body": "شرح کامل مراقبت تنها برای پرستار مسئول شما و پشتیبانی قابل مشاهده است.",
"list_title": "رزروهای من",
"list_subtitle": "مراقبت‌های تاییدشدهٔ شما.",
"list_subtitle": "مراقبت‌های تأییدشدهٔ شما.",
"list_error": "بارگذاری رزروها ممکن نشد.",
"list_total": "مبلغ کل",
"inbox_error": "درخواست‌های شما بارگذاری نشد.",
@@ -693,7 +709,7 @@
"address_title": "آدرس",
"address_open_map": "مسیریابی",
"address_open_web_map": "نمایش روی نقشه",
"address_pending_note": "آدرس پس از تایید رزرو در دسترس قرار می‌گیرد.",
"address_pending_note": "آدرس پس از تأیید رزرو در دسترس قرار می‌گیرد.",
"in_visit_title": "در حال ویزیت",
"in_visit_elapsed": "{duration} در محل",
"evv_checkout_confirm_title": "اتمام این ویزیت؟",
@@ -731,7 +747,7 @@
"notifications_entry_empty": "اعلان جدیدی ندارید"
},
"payment": {
"title_checkout": ایید و پرداخت",
"title_checkout": أیید و پرداخت",
"row_service_cost": "هزینه خدمت",
"row_service_cost_with_count": "هزینه خدمت ({count, plural, one {# ویزیت} other {# ویزیت}})",
"row_commission": "کارمزد بالین‌یار",
@@ -741,7 +757,7 @@
"escrow_explainer_toggle": "چطور کار می‌کند؟",
"escrow_step_pay": "پرداخت",
"escrow_step_hold": "امانت نزد بالین‌یار",
"escrow_step_release": "آزادسازی پس از تایید پایان ویزیت",
"escrow_step_release": "آزادسازی پس از تأیید پایان ویزیت",
"escrow_cancellation_note": "در صورت لغو رزرو پیش از انجام ویزیت، مبلغ طبق سیاست لغو بازپرداخت می‌شود.",
"total_payable_label": "مبلغ قابل پرداخت",
"secure_gateway_notice": "پرداخت امن از طریق درگاه بانکی",
@@ -749,10 +765,10 @@
"bnpl_option": "یا پرداخت اقساطی",
"state_initiating": "در حال آغاز پرداخت…",
"state_redirecting": "در حال انتقال به درگاه پرداخت…",
"state_pending_title": "در حال تایید پرداخت…",
"state_pending_hint": "پرداخت شما نزد درگاه در حال تایید است؛ این مرحله معمولاً چند لحظه طول می‌کشد.",
"state_pending_title": "در حال تأیید پرداخت…",
"state_pending_hint": "پرداخت شما نزد درگاه در حال تأیید است؛ این مرحله معمولاً چند لحظه طول می‌کشد.",
"stage_returned": "بازگشت از درگاه",
"stage_confirming": "در انتظار تایید بانک",
"stage_confirming": "در انتظار تأیید بانک",
"state_pending_duration_hint": "معمولاً کمتر از یک دقیقه طول می‌کشد.",
"check_again": "بررسی دوباره",
"state_failed_title": "پرداخت ناموفق بود",
@@ -803,7 +819,7 @@
"moadian_submitted": "ارسال‌شده",
"moadian_registered": "ثبت‌شده",
"moadian_failed": "ناموفق",
"pstatus_pending": "در انتظار تایید",
"pstatus_pending": "در انتظار تأیید",
"pstatus_succeeded": "موفق",
"pstatus_failed": "ناموفق",
"error_title": "مشکلی پیش آمد",
@@ -812,27 +828,27 @@
"issuer_platform": "بالین‌یار"
},
"auth": {
"customer_title": "ورود به بالین یار",
"customer_title": "ورود به بالینیار",
"customer_subtitle": "با شماره موبایل خود وارد شوید",
"nurse_title": "ورود پرستاران",
"nurse_subtitle": "ویژه پرستاران دارای پروانه نظام پرستاری",
"phone_label": "شماره موبایل",
"phone_invalid": "شماره موبایل معتبر وارد کنید",
"request_code": "دریافت کد تایید",
"nurse_switch": "پرستار هستید؟ ورود پرستاران",
"customer_switch": "خانواده هستید؟ ورود خانواده‌ها",
"request_code": "دریافت کد تأیید",
"nurse_switch": "پرستار هستید؟ ورود پرستاران",
"customer_switch": "خانواده هستید؟ ورود خانواده‌ها",
"rate_limited": "به‌دلیل تلاش زیاد، کمی بعد دوباره تلاش کنید",
"otp_title": "کد تایید را وارد کنید",
"otp_title": "کد تأیید را وارد کنید",
"otp_sent_to": "کد به شماره {phone} ارسال شد",
"otp_verify_customer": ایید و ادامه",
"otp_verify_nurse": ایید و ورود",
"otp_verify_customer": أیید و ادامه",
"otp_verify_nurse": أیید و ورود",
"otp_invalid": "کد وارد شده نادرست یا منقضی شده است",
"otp_locked": "به‌دلیل تلاش‌های زیاد، ورود موقتاً قفل شد. کد جدید دریافت کنید.",
"resend_in": "ارسال مجدد کد تا {time}",
"resend": "ارسال مجدد کد",
"change_number": "تغییر شماره",
"routing_title": "در حال ورود…",
"select_role_title": "به بالین یار خوش آمدید",
"select_role_title": "به بالینیار خوش آمدید",
"select_role_subtitle": "برای شروع، نقش خود را انتخاب کنید",
"role_customer": "خانواده",
"role_customer_desc": "برای رزرو پرستار و مراقبت در منزل",
@@ -842,28 +858,28 @@
"continue": "ادامه",
"guard_denied": "شما به این بخش دسترسی ندارید.",
"account_error_title": "حساب شما بارگذاری نشد",
"account_error_body": "در ارتباط با بالین یار برای بارگذاری حساب شما مشکلی پیش آمد. اتصال خود را بررسی کنید و دوباره تلاش کنید.",
"account_error_body": "در ارتباط با بالینیار برای بارگذاری حساب شما مشکلی پیش آمد. اتصال خود را بررسی کنید و دوباره تلاش کنید.",
"account_error_retry": "تلاش مجدد",
"trust_verified_nurses": "پرستاران دارای پروانه صلاحیت حرفه‌ای و احراز هویت‌شده",
"trust_escrow_payment": "پرداخت شما تا تایید انجام خدمت، امانی نزد بالین‌یار می‌ماند",
"trust_escrow_payment": "پرداخت شما تا تأیید انجام خدمت، امانی نزد بالین‌یار می‌ماند",
"trust_support": "پشتیبانی در تمام مراحل مراقبت",
"consent_line": "با ورود، <terms>شرایط استفاده</terms> و <privacy>حریم خصوصی</privacy> را می‌پذیرید."
},
"verification": {
"title": "احراز هویت",
"title": "تأیید صلاحیت",
"subtitle": "برای فعال‌شدن پروفایل و دریافت رزرو، این مراحل را کامل کنید.",
"retry": "تلاش مجدد",
"load_error": "بارگذاری وضعیت احراز هویت ممکن نشد.",
"start_title": "احراز هویت را آغاز کنید",
"start_body": "اعتماد، بنیان بالین یار است. با تکمیل این مراحل، خانواده‌ها با اطمینان شما را انتخاب می‌کنند.",
"start_cta": "شروع احراز هویت",
"load_error": "بارگذاری وضعیت تأیید صلاحیت ممکن نشد.",
"start_title": "تأیید صلاحیت را آغاز کنید",
"start_body": "اعتماد، بنیان بالینیار است. با تکمیل این مراحل، خانواده‌ها با اطمینان شما را انتخاب می‌کنند.",
"start_cta": "شروع تأیید صلاحیت",
"starting": "در حال آماده‌سازی…",
"progress_title": "پیشرفت احراز هویت",
"progress_title": "پیشرفت تأیید صلاحیت",
"progress_count": "{passed} از {total}",
"blocking_summary": "تا تکمیل همهٔ مراحل، پروفایل شما قابل نمایش و رزرو نیست.",
"continue": "ادامه",
"continue_step": "ادامه مرحلهٔ {n}",
"approved_title": "احراز هویت تکمیل شد",
"approved_title": "تأیید صلاحیت تکمیل شد",
"approved_body": "همه‌چیز تأیید شد. اکنون می‌توانید پروفایل و خدمات خود را منتشر کنید.",
"approved_cta": "انتشار خدمات",
"step_mobile_verified": "شماره موبایل",
@@ -880,7 +896,7 @@
"step_criminal_record_desc": "آپلود گواهی؛ بررسی دستی. دارای تاریخ انقضا.",
"step_bank_account_verification": "تأیید حساب بانکی",
"step_bank_account_verification_desc": "استعلام خودکار مالکیت شبا با کد ملی.",
"status_passed": اییدشده",
"status_passed": أییدشده",
"status_in_review": "در حال بررسی",
"status_pending": "در انتظار",
"status_failed": "رد شد",
@@ -922,7 +938,7 @@
"back_to_checklist": "بازگشت به فهرست",
"credentials_title": "مدارک حرفه‌ای",
"credentials_subtitle": "مدارک شما پس از بارگذاری، توسط کارشناس بررسی می‌شود.",
"credentials_needs_start": "ابتدا احراز هویت را از فهرست وضعیت آغاز کنید.",
"credentials_needs_start": "ابتدا تأیید صلاحیت را از فهرست وضعیت آغاز کنید.",
"ino_number_label": "شماره نظام پرستاری",
"ino_number_hint": "شماره عضویت نظام پرستاری خود را وارد کنید.",
"ino_number_required": "شماره نظام پرستاری را وارد کنید.",
@@ -956,20 +972,20 @@
"review_timeline_submitted": "مدارک ثبت شد",
"review_timeline_review": "بررسی توسط کارشناس",
"review_timeline_activation": "فعال‌سازی نشان",
"review_approved_title": "احراز هویت تکمیل شد",
"review_approved_title": "تأیید صلاحیت تکمیل شد",
"review_approved_body": "همهٔ مراحل تأیید شد. اکنون می‌توانید پروفایل خود را منتشر کنید.",
"review_summary_title": "خلاصهٔ وضعیت",
"review_view_status": "مشاهده وضعیت",
"badge_verified": اییدشده",
"badge_verified": أییدشده",
"badge_unverified": "احراز نشده",
"badge_expired": "منقضی شده",
"explainer_title": "بالین‌یار چه چیزی را تایید کرده است",
"explainer_open_label": "مشاهدهٔ جزئیات تاییدیه",
"explainer_title": "بالین‌یار چه چیزی را تأیید کرده است",
"explainer_open_label": "مشاهدهٔ جزئیات تأییدیه",
"explainer_close": "بستن",
"explainer_intro": "این پرستار این مراحل را با موفقیت گذرانده است:",
"explainer_approved_at": "تاریخ تایید: {date}",
"explainer_not_verified": "این پرستار هنوز احراز هویت نشده است.",
"explainer_error": "بارگذاری جزئیات تاییدیه ممکن نشد.",
"explainer_approved_at": "تاریخ تأیید: {date}",
"explainer_not_verified": "صلاحیت این پرستار هنوز تأیید نشده است.",
"explainer_error": "بارگذاری جزئیات تأییدیه ممکن نشد.",
"publish_ready_title": "آمادهٔ فعال‌سازی",
"publish_ready_body": "راه‌اندازی کامل است. پذیرش رزرو را روشن کنید تا در جستجو نمایش داده شوید و درخواست دریافت کنید.",
"publish_blocked_title": "برای فعال‌سازی، راه‌اندازی را کامل کنید",
@@ -1001,15 +1017,15 @@
"refunds": {
"cancel_title": "لغو رزرو",
"step_review": "بازبینی",
"step_confirm": ایید",
"step_confirm": أیید",
"offramp_note": "پیش از لغو، این گزینه‌ها را هم در نظر بگیرید — لغو ممکن است زمان پرستار شما را از دست بدهد.",
"reschedule_cta": "تغییر زمان",
"contact_support_cta": "گفتگو با پشتیبانی",
"policy_free_24h": "لغو رایگان",
"policy_partial_under_24h": "بازپرداخت جزئی",
"policy_customer_no_show": "بدون بازپرداخت",
"lead_gt_24h": "بیش از ۲۴ ساعت مانده به ویزیت",
"lead_lt_24h": "کمتر از ۲۴ ساعت مانده به ویزیت",
"lead_gt_24h": "بیش از {hours} ساعت مانده به ویزیت",
"lead_lt_24h": "کمتر از {hours} ساعت مانده به ویزیت",
"lead_started": "ویزیت آغاز شده است",
"refund_percent": "{percent}٪ بازپرداخت می‌شود",
"fee_percent": "کارمزد لغو: {percent}٪",
@@ -1033,9 +1049,9 @@
"reason_notes_label": "توضیحات (اختیاری)",
"acknowledge_label": "بازپرداخت و کارمزد نمایش‌داده‌شده را می‌پذیرم.",
"continue_cta": "ادامه",
"confirm_title": ایید لغو",
"confirm_restate": "مبلغ <refund></refund> به شما بازپرداخت می‌شود و کارمزد لغو <fee></fee> کسر می‌گردد.",
"confirm_cta": ایید لغو",
"confirm_title": أیید لغو",
"confirm_restate": "مبلغ <refund></refund> به شما بازپرداخت می‌شود و کارمزد لغو <fee></fee> کسر می‌شود.",
"confirm_cta": أیید لغو",
"submitting": "در حال ثبت…",
"not_cancellable_title": "این رزرو قابل لغو نیست",
"not_cancellable_body": "جلسهٔ باقی‌مانده‌ای برای لغو وجود ندارد. اگر پیش‌تر لغو شده است، وضعیت بازپرداخت را دنبال کنید.",
@@ -1070,7 +1086,7 @@
"eta_bnpl_body": "اقساط پرداخت‌نشده لغو و معادل آن به کیف اعتباری اقساطی شما بازگردانده می‌شود؛ هر قسطی که پرداخت کرده‌اید به حساب بانکی شما برمی‌گردد.",
"eta_manual_title": "واریز بانکی دستی",
"eta_manual_body": "مبلغ توسط تیم بالین‌یار به حساب بانکی شما واریز می‌شود.",
"eta_business_days": "حدود ۷ تا ۱۰ روز کاری",
"eta_business_days": "حدود {minDays} تا {maxDays} روز کاری",
"eta_expected_label": "تا حدود {date}",
"cancel_booking_cta": "لغو رزرو",
"refund_section_title": "بازپرداخت",
@@ -1080,7 +1096,7 @@
},
"bnpl": {
"title": "پرداخت اقساطی",
"ownership_note": "ارائه‌دهنده کل مبلغ را یک‌جا به بالین‌یار می‌پردازد و ریسک نکول مشتری کاملاً با اوست؛ بازپرداخت اقساط میان شما و ارائه‌دهنده است.",
"ownership_note": "قسط‌ها را مستقیماً به ارائه‌دهنده می‌پردازید؛ بالین‌یار مبلغ کامل رزرو را همان ابتدا دریافت می‌کند و اگر قسطی پرداخت نشود، مسئولیت آن با ارائه‌دهنده است — نه شما و نه پرستار.",
"payable_amount": "مبلغ قابل پرداخت",
"total_amount_named": "مبلغ کل با طرح {plan}",
"total_repayment": "مجموع بازپرداخت",
@@ -1101,7 +1117,7 @@
"provider_tagline_torobpay": "طرح اقساطی",
"method_title": "روش پرداخت",
"method_card": "پرداخت کامل (کارت بانکی)",
"method_card_hint": "پرداخت یک‌جا و تایید فوری رزرو",
"method_card_hint": "پرداخت یک‌جا و تأیید فوری رزرو",
"installments_heading": "پرداخت اقساطی",
"continue_with": "ادامه با {provider}",
"no_providers_title": "اقساط در دسترس نیست",
@@ -1124,11 +1140,11 @@
"consent_label": "با استعلام اعتبارسنجی و سابقه اعتباری من توسط {provider} موافقم.",
"check_eligibility": "استعلام اعتبار",
"checking_eligibility": "در حال استعلام اعتبار…",
"approved_title": "اعتبار شما تایید شد",
"approved_title": "اعتبار شما تأیید شد",
"credit_ceiling_label": "سقف اعتبار قابل استفاده",
"approve_continue": ایید و ادامه",
"declined_not_eligible_title": "اعتبارسنجی تایید نشد",
"declined_not_eligible_body": "متأسفانه ارائه‌دهنده اعتبار لازم را تایید نکرد.",
"approve_continue": أیید و ادامه",
"declined_not_eligible_title": "اعتبارسنجی تأیید نشد",
"declined_not_eligible_body": "متأسفانه ارائه‌دهنده اعتبار لازم را تأیید نکرد.",
"declined_ceiling_title": "سقف اعتبار کافی نیست",
"declined_ceiling_body": "مبلغ این رزرو از سقف اعتبار قابل استفاده شما بیشتر است.",
"pay_with_card": "پرداخت با کارت",
@@ -1138,13 +1154,13 @@
"installment_n": "قسط {n, number}",
"contract_note": "قرارداد اقساط میان شما و {provider} است؛ بالین‌یار مبلغ کامل رزرو را یک‌جا دریافت می‌کند.",
"contract_consent": "شرایط و قرارداد اقساط را خوانده‌ام و می‌پذیرم.",
"pay_down_payment": ایید نهایی و پرداخت پیش‌پرداخت",
"pay_down_payment": أیید نهایی و پرداخت پیش‌پرداخت",
"redirecting": "در حال انتقال به {provider}…",
"handoff_title": "در حال انتقال به ارائه‌دهنده",
"handoff_body": "برای تایید رزرو، قرارداد اقساط را با {provider} تکمیل کنید.",
"handoff_body": "برای تأیید رزرو، قرارداد اقساط را با {provider} تکمیل کنید.",
"handoff_pay_success": "پرداخت پیش‌پرداخت (نمایشی)",
"handoff_pay_fail": "انصراف",
"settling_title": "در حال تایید طرح اقساط شما",
"settling_title": "در حال تأیید طرح اقساط شما",
"settling_body": "ارائه‌دهنده در حال تسویه سفارش است. طولی نمی‌کشد.",
"settle_failed_title": "پرداخت اقساطی کامل نشد",
"settle_failed_body": "ارائه‌دهنده تراکنش را کامل نکرد. می‌توانید دوباره تلاش کنید یا با کارت پرداخت کنید.",
@@ -1193,8 +1209,8 @@
"bucket_paid": "پرداخت‌شده (کل)",
"bucket_clawback": "بازپس‌گیری معوق",
"explainer_title": "پرداخت‌ها چگونه کار می‌کنند",
"explainer_point_1": "پرداخت‌ها به‌صورت هفتگی و به حساب تاییدشده اصلی شما واریز می‌شود.",
"explainer_point_2": "هر مبلغ تنها پس از تایید ویزیت (خروج) و بسته‌شدن پنجره ۷۲ ساعته اعتراض، آماده پرداخت می‌شود.",
"explainer_point_1": "پرداخت‌ها به‌صورت هفتگی و به حساب تأییدشده اصلی شما واریز می‌شود.",
"explainer_point_2": "هر مبلغ تنها پس از تأیید ویزیت (خروج) و بسته‌شدن پنجرهٔ {hours} ساعتهٔ اعتراض، آماده پرداخت می‌شود.",
"explainer_point_3": "چه خانواده با کارت پرداخت کند چه اقساطی، مبلغ شما یکسان است — کارمزد ارائه‌دهنده اقساط هرگز از شما کسر نمی‌شود.",
"tab_all": "همه",
"tab_pending": "در انتظار",
@@ -1224,7 +1240,7 @@
"transfer_reference_label": "کد پیگیری واریز",
"list_error": "بارگذاری درآمدها ممکن نشد.",
"list_empty_title": "هنوز درآمدی ندارید",
"list_empty_body": "ویزیت‌های تکمیل و تاییدشده اینجا نمایش داده می‌شوند.",
"list_empty_body": "ویزیت‌های تکمیل و تأییدشده اینجا نمایش داده می‌شوند.",
"retry": "تلاش دوباره",
"page_prev": "قبلی",
"page_next": "بعدی",
@@ -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": "بعدی",
@@ -1360,6 +1401,15 @@
"empty_body": "برای هماهنگی یا پشتیبانی، یک تیکت جدید باز کنید.",
"error_body": "بارگذاری تیکت‌ها ممکن نشد.",
"retry": "تلاش مجدد",
"filter_all": "همه",
"load_more": "نمایش بیشتر",
"day_today": "امروز",
"day_yesterday": "دیروز",
"new_message_pill": "پیام جدید ↓",
"discard_failed": "حذف و ویرایش پیام",
"attach_photo": "افزودن عکس",
"emergency_row_title": "موارد اضطراری",
"emergency_row_body": "در شرایط اضطراری، یک تیکت فوری ثبت کنید تا پشتیبانی در اسرع وقت با شما تماس بگیرد.",
"category_support": "پشتیبانی",
"category_coordination": "هماهنگی ویزیت",
"category_refund": "بازپرداخت",
@@ -1384,7 +1434,7 @@
"back_to_tickets": "بازگشت به تیکت‌ها",
"thread_error": "بارگذاری گفتگو ممکن نشد.",
"thread_empty_title": "هنوز پیامی نیست",
"thread_empty_body": "هنوز پیامی نیست، هماهنگی را شروع کنید.",
"thread_empty_body": "هنوز پیامی نیست. هماهنگی را شروع کنید.",
"closed_notice": "این گفتگو بسته شده است.",
"sending": "در حال ارسال…",
"send": "ارسال پیام",
@@ -1409,7 +1459,11 @@
"retry": "تلاش مجدد",
"mark_all_read": "علامت‌گذاری همه به‌عنوان خوانده‌شده",
"load_more": "نمایش بیشتر",
"bell_aria": "{count, number} اعلان خوانده‌نشده"
"bell_aria": "{count, number} اعلان خوانده‌نشده",
"group_today": "امروز",
"group_yesterday": "دیروز",
"group_this_week": "این هفته",
"view_all": "مشاهده همه"
},
"admin": {
"overview_title": "پیشخان مدیریت",
@@ -1437,7 +1491,7 @@
"actor": "عامل",
"timestamp": "زمان",
"showing_range": "نمایش {from}{to} از {total}",
"page_indicator": "صفحه {page}",
"page_indicator": "صفحه {page} از {total}",
"prev_page": "قبلی",
"next_page": "بعدی",
"search_placeholder": "جستجو…",
@@ -1445,17 +1499,22 @@
"no_permission": "شما به این بخش دسترسی ندارید.",
"role_gate_hint": "این اقدام تنها برای نقش‌های مشخصی از مدیران در دسترس است.",
"masked_iban_label": "شبا",
"ver_title": "صف احراز هویت",
"ver_title": "صف تأیید صلاحیت",
"ver_subtitle": "پرستاران در انتظار بررسی مدارک.",
"ver_empty": "صف خالی است — موردی برای بررسی نیست.",
"ver_col_nurse": "پرستار",
"ver_col_step": "مرحله",
"ver_col_status": "وضعیت",
"ver_col_submitted": "زمان ارسال",
"ver_col_waiting": "زمان انتظار",
"ver_search_label": "جستجوی پرستار",
"ver_search_ph": "نام یا شماره تماس",
"ver_progress": "{done} از {total}",
"ver_next_step": "بعدی: {step}",
"ver_open_case": "بررسی",
"ver_case_title": "پرونده احراز هویت",
"ver_case_title": "پرونده تأیید صلاحیت",
"ver_next_case": "پروندهٔ بعدی",
"ver_prev_case": "پروندهٔ قبلی",
"ver_identity_name": "نام ثبت‌شده",
"ver_steps_title": "مراحل",
"ver_documents_title": "مدارک",
@@ -1510,6 +1569,9 @@
"payout_run": "اجرای دسته",
"payout_run_confirm_title": "این دستهٔ تسویه اجرا شود؟",
"payout_run_confirm_body": "مبلغ به پرستاران منتقل می‌شود. این اقدام با کلید یکتا محافظت می‌شود — کلیک دوباره باعث پرداخت مضاعف نمی‌شود.",
"payout_run_summary_intro": "این دسته اجرا و مبلغ زیر واریز می‌شود:",
"payout_run_count_label": "تعداد پرستاران",
"payout_run_type_to_confirm": "برای تأیید، «تأیید» یا مبلغ را تایپ کنید",
"payout_running": "در حال ارسال به سامانهٔ بانکی…",
"payout_skipped": "پرستاران نادیده‌گرفته‌شده",
"payout_batch_title": "دسته #{id}",
@@ -1557,7 +1619,7 @@
"mod_publish": "انتشار",
"mod_hide": "پنهان‌سازی",
"mod_reject": "رد",
"mod_confirm_publish": "این نظر منتشر شود؟ عمومی می‌شود و امتیاز پرستار بازمحاسبه می‌گردد.",
"mod_confirm_publish": "این نظر منتشر شود؟ عمومی می‌شود و امتیاز پرستار بازمحاسبه می‌شود.",
"mod_confirm_hide": "این نظر با دلیل واردشده پنهان شود؟",
"mod_confirm_reject": "این نظر با دلیل واردشده رد شود؟",
"mod_done": "نظر به‌روزرسانی شد.",
@@ -1576,11 +1638,12 @@
"cfg_json_error": "JSON نامعتبر است.",
"cfg_empty_error": "مقدار الزامی است.",
"cfg_save_confirm_title": "این تغییر پیکربندی ذخیره شود؟",
"cfg_save_confirm_body": "این تغییر ممیزی می‌شود و بلافاصله اعمال می‌گردد. رزروها و ثبت‌های مالی قبلی را بازنمی‌گرداند.",
"cfg_save_confirm_body": "این تغییر ممیزی می‌شود و بلافاصله اعمال می‌شود. رزروها و ثبت‌های مالی قبلی را بازنمی‌گرداند.",
"cfg_saved": "پیکربندی ذخیره شد.",
"cfg_history": "تاریخچهٔ تغییرات",
"cfg_history_title": "تاریخچه — {key}",
"cfg_history_change": "{old} ← {new}",
"cfg_history_change_old": "{old}",
"cfg_history_change_new": "{new}",
"cfg_history_empty": "هنوز تغییری ثبت نشده.",
"cfg_group_fees": "کارمزد و مالیات",
"cfg_group_deadlines": "مهلت‌ها و پنجره‌ها",
@@ -1603,7 +1666,7 @@
"hol_year": "سال",
"alert_title": "هشدارهای پشتیبانی",
"alert_subtitle": "صف داخلی — هرگز به مشتری یا پرستار نمایش داده نمی‌شود.",
"alert_empty": "هشدار بازی وجود ندارد.",
"alert_empty": "هشداری برای رسیدگی نیست.",
"alert_col_type": "نوع",
"alert_col_entity": "مرتبط با",
"alert_col_owner": "مسئول",
@@ -1656,6 +1719,16 @@
"ticket_sent": "پیام ارسال شد.",
"ticket_participants": "شرکت‌کنندگان",
"ticket_linked_refund": "بازپرداخت #{id}",
"ticket_activity_col": "آخرین فعالیت",
"ticket_send_internal": "ثبت یادداشت داخلی",
"ticket_close": "بستن گفتگو",
"ticket_close_confirm": "این گفتگو بسته و از صف باز خارج می‌شود. ادامه می‌دهید؟",
"ticket_reopen": "بازگشایی گفتگو",
"ticket_reopen_confirm": "این گفتگو دوباره باز می‌شود. ادامه می‌دهید؟",
"ticket_assign_me": "واگذاری به من",
"ticket_assigned_me": "گفتگو به شما واگذار شد.",
"ticket_closed_ok": "گفتگو بسته شد.",
"ticket_reopened_ok": "گفتگو دوباره باز شد.",
"partner_title": "مراکز همکار",
"partner_subtitle": "مراکز دارای مجوز که پرستاران را پشتیبانی می‌کنند و می‌توانند فروشندهٔ رسمی باشند.",
"partner_empty": "هنوز مرکزی ثبت نشده.",
@@ -1690,6 +1763,20 @@
"partner_assign_nurse_ph": "شناسهٔ پروفایل پرستار",
"partner_unlink_nurse": "حذف",
"partner_nurse_assigned": "وابستگی پرستار به‌روزرسانی شد.",
"user_title": "کاربران",
"user_subtitle": "جستجوی کاربران با نام یا شماره موبایل.",
"user_col_name": "نام",
"user_col_phone": "موبایل",
"user_col_id": "شناسه",
"user_col_roles": "نقش‌ها",
"user_view_audit": "مشاهده گزارش تغییرات",
"user_search_label": "جستجو",
"user_search_hint": "برای جستجو، حداقل ۲ نویسه از نام یا شماره موبایل را وارد کنید.",
"user_empty": "کاربری یافت نشد",
"user_role_customer": "خانواده",
"user_role_nurse": "پرستار",
"user_role_admin": "مدیر",
"user_role_partner": "شریک تجاری",
"role_title": "نقش‌ها و دسترسی",
"role_subtitle": "اعطا یا لغو نقش‌های مدیریتی. (در انتظار سرویس‌های نقش سمت سرور.)",
"role_deferred": "این بخش تا آماده‌شدن سرویس‌های نقش، با داده‌های موقتِ سمت‌کاربر نمایش داده می‌شود.",
@@ -1698,9 +1785,13 @@
"role_col_granted": "اعطا شده",
"role_grant": "اعطای نقش",
"role_revoke": "لغو",
"role_grant_confirm": "نقش {role} به کاربر #{id} اعطا شود؟",
"role_revoke_confirm": "نقش {role} از کاربر #{id} لغو شود؟",
"role_grant_confirm": "نقش {role} به {name} اعطا شود؟",
"role_revoke_confirm": "نقش {role} از {name} لغو شود؟",
"role_updated": "نقش به‌روزرسانی شد.",
"user_picker_search_ph": "جستجو با نام یا شماره موبایل…",
"user_picker_no_options": "کاربری یافت نشد",
"user_picker_loading": "در حال جستجو…",
"assign_me_loading": "در حال بارگذاری حساب شما…",
"agg_not_started": "شروع‌نشده",
"agg_pending": "در انتظار",
"agg_in_review": "در حال بررسی",
@@ -1711,7 +1802,7 @@
"step_pending": "در انتظار",
"step_in_review": "در حال بررسی",
"step_passed": "تأییدشده",
"step_failed": "ناموفق",
"step_failed": "ردشده",
"step_expired": "منقضی",
"step_identity_kyc": "احراز هویت",
"step_shahkar_match": "تطبیق شاهکار",
@@ -1806,6 +1897,19 @@
"bookings_col_patient": "بیمار",
"bookings_col_date": "تاریخ",
"bookings_col_status": "وضعیت",
"bstatus_pending_payment": "در انتظار پرداخت",
"bstatus_confirmed": "تأییدشده",
"bstatus_in_progress": "در حال انجام",
"bstatus_completed": "تکمیل‌شده",
"bstatus_disputed": "مورد اختلاف",
"bstatus_closed": "بسته‌شده",
"bstatus_cancelled": "لغوشده",
"booking_detail_title": "جزئیات رزرو",
"booking_detail_back": "بازگشت به رزروها",
"booking_detail_not_found": "این رزرو یافت نشد.",
"booking_detail_patient": "بیمار",
"booking_detail_scheduled_date": "تاریخ ویزیت",
"booking_detail_timeline_title": "روند وضعیت",
"settlement_title": "تسویه و فاکتورها",
"settlement_not_mor": "این مرکز فروشندهٔ رسمی نیست — تسویه از طریق بالین‌یار انجام می‌شود و فاکتور کارمزدی اینجا صادر نمی‌شود.",
"settlement_empty": "هنوز فاکتوری نیست.",
@@ -1823,6 +1927,14 @@
"invoice_download": "دانلود PDF",
"invoice_pdf_error": "باز کردن فایل فاکتور ناموفق بود.",
"invoice_number": "فاکتور #{number}",
"settlement_export_csv": "خروجی CSV",
"csv_col_invoice_number": "شماره فاکتور",
"csv_col_booking_id": "رزرو",
"csv_col_gross": "ناخالص (تومان)",
"csv_col_commission": "کارمزد (تومان)",
"csv_col_vat": "مالیات (تومان)",
"csv_col_total": "مبلغ کل (تومان)",
"csv_col_issued_at": "تاریخ صدور",
"settlement_iban": "شبای تسویه"
},
"routeChrome": {
@@ -1837,12 +1949,12 @@
"terms_title": "شرایط استفاده",
"privacy_title": "حریم خصوصی",
"draft_banner": "این متن پیش‌نویس است و هنوز توسط تیم حقوقی بازبینی نشده؛ پیش از انتشار نهایی قابل استناد نیست.",
"terms_intro": "این شرایط پیش‌نویس، نحوه ارتباط بالین‌یار بین خانواده‌ها و پرستاران مستقل و تاییدشده مراقبت در منزل را توضیح می‌دهد. با ساخت حساب کاربری، شرایط زیر را می‌پذیرید.",
"terms_intro": "این شرایط پیش‌نویس، نحوه ارتباط بالین‌یار بین خانواده‌ها و پرستاران مستقل و تأییدشده مراقبت در منزل را توضیح می‌دهد. با ساخت حساب کاربری، شرایط زیر را می‌پذیرید.",
"terms_sections": [
{ "title": "ماهیت خدمت", "body": "بالین‌یار یک بازارگاه است و پرستاران را استخدام نمی‌کند. پرستاران مستقل یا شاغل در مراکز پرستاری، خدمات خود را ثبت می‌کنند و خانواده‌ها از طریق پلتفرم جست‌وجو، رزرو و پرداخت انجام می‌دهند." },
{ "title": "رزرو و پرداخت", "body": "مبلغ کامل رزرو را از طریق بالین‌یار و با کارت پرداخت می‌کنید. این مبلغ به‌صورت امانی نزد بالین‌یار نگه‌داری می‌شود و تنها پس از تایید انجام خدمت و پایان مهلت اعتراض، به‌صورت هفتگی به پرستار پرداخت می‌شود." },
{ "title": "لغو و بازگشت وجه", "body": "لغو یک رزرو تاییدشده، بسته به فاصله زمانی تا زمان مراجعه، ممکن است مشمول کارمزد شود که پیش از تایید نهایی به شما نمایش داده می‌شود. مبالغ بازگشتی تاییدشده به همان روش پرداخت اصلی یا ارائه‌دهنده مربوطه بازمی‌گردد." },
{ "title": "احراز هویت پرستاران", "body": "هر پرستار پیش از قابل‌رزرو شدن، مراحل احراز هویت، بررسی پروانه صلاحیت حرفه‌ای و سایر مراحل الزامی را می‌گذراند. آنچه تایید شده در پروفایل او نمایش داده می‌شود." },
{ "title": "ماهیت خدمت", "body": "بالین‌یار یک بازارگاه است و پرستاران را استخدام نمی‌کند. پرستاران مستقل یا شاغل در مراکز پرستاری، خدمات خود را ثبت می‌کنند و خانواده‌ها از طریق پلتفرم جستجو، رزرو و پرداخت انجام می‌دهند." },
{ "title": "رزرو و پرداخت", "body": "مبلغ کامل رزرو را از طریق بالین‌یار و با کارت پرداخت می‌کنید. این مبلغ به‌صورت امانی نزد بالین‌یار نگه‌داری می‌شود و تنها پس از تأیید انجام خدمت و پایان مهلت اعتراض، به‌صورت هفتگی به پرستار پرداخت می‌شود." },
{ "title": "لغو و بازگشت وجه", "body": "لغو یک رزرو تأییدشده، بسته به فاصله زمانی تا زمان مراجعه، ممکن است مشمول کارمزد شود که پیش از تأیید نهایی به شما نمایش داده می‌شود. مبالغ بازگشتی تأییدشده به همان روش پرداخت اصلی یا ارائه‌دهنده مربوطه بازمی‌گردد." },
{ "title": "احراز هویت پرستاران", "body": "هر پرستار پیش از قابل‌رزرو شدن، مراحل احراز هویت، بررسی پروانه صلاحیت حرفه‌ای و سایر مراحل الزامی را می‌گذراند. آنچه تأیید شده در پروفایل او نمایش داده می‌شود." },
{ "title": "مسئولیت‌های شما", "body": "اطلاعات دقیق درباره فرد دریافت‌کننده مراقبت ارائه دهید، برای هر موضوع مرتبط با رزرو از طریق سامانه تیکت پشتیبانی اپلیکیشن ارتباط بگیرید و با پرستاران محترمانه رفتار کنید." },
{ "title": "مسئولیت‌پذیری", "body": "بالین‌یار واسط رزرو بین خانواده‌ها و پرستاران مستقل است و خود ارائه‌دهنده خدمات درمانی نیست. اختلافات از طریق سامانه تیکت پشتیبانی رسیدگی می‌شود." },
{ "title": "تغییر این شرایط", "body": "ممکن است این شرایط با تحول خدمت به‌روزرسانی شود. تغییرات مهم پیش از اعمال، در اپلیکیشن اطلاع‌رسانی می‌شود." },
@@ -1858,5 +1970,40 @@
{ "title": "تغییر این سیاست", "body": "ممکن است این سیاست با تحول خدمت به‌روزرسانی شود. تغییرات مهم پیش از اعمال، در اپلیکیشن اطلاع‌رسانی می‌شود." },
{ "title": "تماس با ما", "body": "سوالات درباره این سیاست را می‌توانید از طریق سامانه تیکت پشتیبانی در اپلیکیشن ارسال کنید." }
]
},
"welcome": {
"meta_description": "بالین‌یار پرستاران تأییدشده را برای مراقبت در منزل به خانواده‌ها متصل می‌کند؛ پرداخت امن امانی و مراقبت با خیال راحت.",
"hero_subtitle": "پرستاران تأییدشده برای مراقبت از عزیزان شما، با پرداختی امن و شفاف.",
"hero_cta": "شروع کنید",
"categories_title": "خدماتی که می‌توانید رزرو کنید",
"category_elderly": "مراقبت سالمندی",
"category_post_surgery": "مراقبت پس از جراحی",
"category_infant": "مراقبت نوزاد",
"category_chronic": "بیماری‌های مزمن",
"category_companionship": "همراهی و مراقبت روزانه",
"how_it_works_title": "بالین‌یار چگونه کار می‌کند؟",
"step1_title": "جستجوی پرستار تأییدشده",
"step1_body": "بر اساس شهر، منطقه و نوع خدمت، از میان پرستاران تأییدشده انتخاب کنید.",
"step2_title": "پرداخت امن امانی",
"step3_title": "مراقبت با خیال راحت",
"step3_body": "پرستار انجام ویزیت را ثبت می‌کند و دستمزد او تنها پس از تأیید انجام کار، پرداخت می‌شود.",
"trust_title": "آنچه بالین‌یار پیش از رزرو تأیید می‌کند",
"trust_intro": "پیش از آنکه پرستاری در بالین‌یار قابل‌رزرو شود، مراحل زیر را می‌گذراند.",
"trust_identity_title": "احراز هویت",
"trust_identity_body": "تطبیق خودکار کد ملی، شماره موبایل و تصویر زنده با ثبت احوال.",
"trust_license_title": "پروانه صلاحیت حرفه‌ای",
"trust_license_body": "مجوز رسمی وزارت بهداشت برای فعالیت پرستاری در منزل.",
"trust_ino_title": "عضویت نظام پرستاری",
"trust_ino_body": "بررسی عضویت در سازمان نظام پرستاری کشور، به‌عنوان منبع دوم تأیید.",
"trust_bank_title": "تأیید مالکیت حساب بانکی",
"trust_bank_body": "دستمزد هر پرستار تنها به حساب بانکی تأییدشدهٔ خود او واریز می‌شود.",
"nurse_cta_title": "پرستار هستید؟ به بالین‌یار بپیوندید",
"nurse_cta_body": "خدمات خود را با قیمت دلخواه ثبت کنید و پس از تأیید صلاحیت، رزرو دریافت کنید.",
"nurse_cta_button": "شروع به‌عنوان پرستار",
"footer_terms": "شرایط استفاده",
"footer_privacy": "حریم خصوصی",
"footer_contact_title": "پشتیبانی",
"footer_contact_body": "برای هر سوالی، پس از ورود از طریق سامانه تیکت پشتیبانی اپلیکیشن با ما در تماس باشید.",
"footer_copyright": "© {year, number} بالین‌یار"
}
}
+39 -24
View File
@@ -17,33 +17,43 @@ export default function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Strip the locale segment to get the actual path (e.g. /fa/login → /login)
const pathWithoutLocale = '/' + pathname.split('/').slice(2).join('/');
const isPublic = PUBLIC_PATHS.some((p) => pathWithoutLocale.startsWith(p));
if (!isPublic) {
const token = request.cookies.get(COOKIE_NAMES.ACCESS_TOKEN)?.value;
if (!isTokenAlive(token)) {
const locale = request.cookies.get('NEXT_LOCALE')?.value ?? routing.defaultLocale;
const loginUrl = new URL(`/${locale}${ROUTES.LOGIN}`, request.url);
// Carry the attempted (locale-stripped) destination so a deep link — an SMS booking link, a
// shared nurse profile — survives the round trip through login instead of dumping the user
// on their role home. Validated same-origin + role-permitting on the way back out
// (resolvePostLoginDestination in services/auth/routing.ts); '/' is the default anyway.
const next = pathWithoutLocale + request.nextUrl.search;
if (next && next !== '/') {
loginUrl.searchParams.set(RETURN_URL_PARAM, next);
}
return NextResponse.redirect(loginUrl);
}
}
// Detect locale from the normalized URL (next-intl always puts it at position 1)
const locale = routing.locales.find(
(l) => pathname === `/${l}` || pathname.startsWith(`/${l}/`),
) ?? routing.defaultLocale;
// Strip the locale segment to get the actual path (e.g. /fa/login → /login)
const pathWithoutLocale = '/' + pathname.split('/').slice(2).join('/');
const isPublic = PUBLIC_PATHS.some((p) => pathWithoutLocale.startsWith(p));
const isAuthenticated = isTokenAlive(request.cookies.get(COOKIE_NAMES.ACCESS_TOKEN)?.value);
// Guest front door (ui-phase-13): an unauthenticated hit on the exact root is REWRITTEN — never
// redirected — to the public landing, so the URL/SEO canonical stays '/'. Exact match only:
// never add ROUTES.HOME ('/') to PUBLIC_PATHS itself, whose `startsWith` check below would
// otherwise silently un-gate every route (§ the routes.ts comment on PUBLIC_PATHS).
if (!isAuthenticated && pathWithoutLocale === ROUTES.HOME) {
return NextResponse.rewrite(new URL(`/${locale}${ROUTES.WELCOME}`, request.url));
}
// A signed-in visitor landing on the marketing page directly gets their real home instead.
if (isAuthenticated && pathWithoutLocale === ROUTES.WELCOME) {
return NextResponse.redirect(new URL(`/${locale}${ROUTES.HOME}`, request.url));
}
if (!isPublic && !isAuthenticated) {
const loginUrl = new URL(`/${locale}${ROUTES.LOGIN}`, request.url);
// Carry the attempted (locale-stripped) destination so a deep link — an SMS booking link, a
// shared nurse profile — survives the round trip through login instead of dumping the user
// on their role home. Validated same-origin + role-permitting on the way back out
// (resolvePostLoginDestination in services/auth/routing.ts); '/' is the default anyway.
const next = pathWithoutLocale + request.nextUrl.search;
if (next && next !== '/') {
loginUrl.searchParams.set(RETURN_URL_PARAM, next);
}
return NextResponse.redirect(loginUrl);
}
const requestHeaders = new Headers(request.headers);
requestHeaders.set(HEADER_NAMES.LOCALE, locale);
@@ -58,6 +68,11 @@ export default function middleware(request: NextRequest) {
}
export const config = {
// Match all pathnames except internal Next.js paths, API routes, and static files
matcher: ['/((?!_next|_vercel|api|.*\\..*).*)'],
// Match all pathnames except internal Next.js paths, API routes, and static files. The bare
// root '/' is listed explicitly alongside the catch-all regex — Next's matcher does not
// reliably invoke middleware for the literal root path through the negative-lookahead pattern
// alone (verified empirically in this Next 16/Turbopack build: '/' skipped middleware entirely
// and 404'd, while every other path matched fine). This is load-bearing for ui-phase-13's
// guest-front-door rewrite, which only fires on an exact '/' match.
matcher: ['/', '/((?!_next|_vercel|api|.*\\..*).*)'],
};
+25
View File
@@ -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",
+4 -1
View File
@@ -9,11 +9,12 @@
"format": "prettier ./ --write",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"lint:copy": "node scripts/check-copy.mjs",
"start": "next start",
"test": "jest --watch",
"test:ci": "jest --ci",
"type": "tsc --noEmit",
"check": "npm run type && npm run lint"
"check": "npm run type && npm run lint && npm run lint:copy"
},
"dependencies": {
"@emotion/cache": "^11.14.0",
@@ -30,6 +31,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 +46,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",
-5
View File
@@ -1,5 +0,0 @@
User-agent: *
Disallow: /private/
User-agent: *
Allow: /
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env node
/**
* Lints client/messages/fa.json against the banned-orthography-variant rules in
* client/messages/STYLE.md — enforces the phase-12 sweep so it cannot silently regress.
* Exits non-zero (and prints every offending key) on any match.
*/
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const FA_MESSAGES_PATH = join(__dirname, '..', 'messages', 'fa.json');
// Each rule scans the *raw value* of every leaf string in fa.json (flattened key path → value).
// `pattern` is a plain substring (not regex) so ZWNJ/hamza characters stay literal and unambiguous.
const RULES = [
{
name: 'brand name must use ZWNJ (بالین‌یار), never a plain space',
pattern: 'بالین یار',
},
{
name: 'تأیید must use hamza — تایید (hamza-less) is banned',
pattern: 'تایید',
},
{
name: 'جستجو is the one standard form — جست‌وجو is banned',
pattern: 'جست‌وجو',
},
{
name: 'جستجو is the one standard form — جست و جو (spaced) is banned',
pattern: 'جست و جو',
},
{
name: 'the indefinite «ی» misattachment bug — «بازی» (game) instead of «ی» on the right word',
// Word-boundary trap: catches "... بازی " (space after) so it doesn't also flag words that
// legitimately contain "بازی" as a substring of something else — in this catalog there are none,
// but the trailing-space anchor keeps the check honest if one is ever added on purpose elsewhere.
pattern: 'بازی ',
},
{
// Leading space distinguishes the archaic passive auxiliary ("X می‌گردد" = "is X-ed") from the
// unrelated, entirely legitimate verb «برمی‌گردد» (returns/comes back), which fuses «بر» directly
// onto «می‌گردد» with no space and must never be flagged.
name: 'archaic passive می‌گردد is banned — use می‌شود',
pattern: ' می‌گردد',
},
];
function flattenStrings(value, path, out) {
if (typeof value === 'string') {
out.push([path, value]);
return;
}
if (Array.isArray(value)) {
value.forEach((item, index) => flattenStrings(item, `${path}[${index}]`, out));
return;
}
if (value && typeof value === 'object') {
for (const [key, child] of Object.entries(value)) {
flattenStrings(child, path ? `${path}.${key}` : key, out);
}
}
}
function main() {
const raw = readFileSync(FA_MESSAGES_PATH, 'utf8');
const messages = JSON.parse(raw);
const leaves = [];
flattenStrings(messages, '', leaves);
const failures = [];
for (const rule of RULES) {
for (const [path, value] of leaves) {
if (value.includes(rule.pattern)) {
failures.push({ rule: rule.name, path, value });
}
}
}
if (failures.length === 0) {
console.log(`check-copy: ${leaves.length} strings checked, 0 banned variants found.`);
return;
}
console.error(`check-copy: found ${failures.length} banned copy variant(s):\n`);
for (const failure of failures) {
console.error(` [${failure.rule}]`);
console.error(` fa.json → ${failure.path}: "${failure.value}"\n`);
}
process.exitCode = 1;
}
main();
@@ -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>
);
}
@@ -2,7 +2,7 @@
import { Suspense, useRef } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation';
import { Avatar, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { Avatar, Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
import {
AppButton,
AppIcon,
@@ -159,6 +159,56 @@ function CheckoutScreen() {
? t('initiate_failed')
: null;
const payActions = (
<Stack sx={{ gap: 1 }}>
{inlineError ? (
<AppAlert severity="error" variant="outlined" sx={{ marginY: 0 }}>
{inlineError}
</AppAlert>
) : null}
<Stack direction="row" sx={{ alignItems: 'center', justifyContent: 'space-between', gap: 2 }}>
<Stack sx={{ gap: 0 }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('row_total')}
</Typography>
<Money amountIrr={summary.totalIrr} tone="emphasis" size="md" sx={{ fontWeight: 800 }} />
</Stack>
<AppButton
color="secondary"
variant="contained"
size="large"
disabled={busy}
onClick={handlePay}
endIcon="forward"
sx={{ py: 1.25, flex: 'none', minWidth: 168 }}
>
{initiate.isPending ? t('state_initiating') : initiate.isSuccess ? t('state_redirecting') : t('cta_pay')}
</AppButton>
</Stack>
<Stack direction="row" sx={{ gap: 0.5, alignItems: 'center', justifyContent: 'center' }}>
<AppIcon icon="lock" size={14} color="var(--bal-text-secondary)" />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('secure_gateway_notice')}
</Typography>
</Stack>
{/* The f11 BNPL branch (D1): «پرداخت اقساطی» → the installment wizard, reached with `?request_id=`. */}
{BNPL_ENABLED ? (
<AppButton
variant="outlined"
color="secondary"
startIcon="installments"
disabled={busy}
onClick={() => router.push(`/${locale}${ROUTES.CHECKOUT_BNPL}?${CHECKOUT_QUERY_REQUEST_ID}=${requestId}`)}
>
{t('bnpl_option')}
</AppButton>
) : null}
</Stack>
);
return (
<Stack sx={{ gap: 3 }}>
<Stack sx={{ gap: 1, alignItems: 'center', textAlign: 'center' }}>
@@ -178,99 +228,74 @@ function CheckoutScreen() {
</AppButton>
</Stack>
<EngagementSummary summary={summary} locale={locale} />
{/* Above ~900px (`md`), a two-column layout: the summary/breakdown/escrow content on the reading
side, a sticky order-summary card (the desktop analogue of the mobile sticky pay bar) on the
other — the same `payActions` content either way, never duplicated logic. */}
<Stack direction={{ xs: 'column', md: 'row' }} sx={{ gap: 3, alignItems: 'flex-start' }}>
<Stack sx={{ gap: 3, width: '100%', minWidth: 0, flex: { md: '1 1 62%' } }}>
<EngagementSummary summary={summary} locale={locale} />
{/* The prominent total — the single most important figure on a payment screen, never buried in the
breakdown. Same served `totalIrr` PriceBreakdown reconciles below; never recomputed. */}
<Stack sx={{ alignItems: 'center', gap: 0.25 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('total_payable_label')}
</Typography>
<Money amountIrr={summary.totalIrr} tone="emphasis" size="xl" sx={{ fontWeight: 800 }} />
{/* The prominent total — the single most important figure on a payment screen, never buried in
the breakdown. Same served `totalIrr` PriceBreakdown reconciles below; never recomputed. */}
<Stack sx={{ alignItems: 'center', gap: 0.25 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('total_payable_label')}
</Typography>
<Money amountIrr={summary.totalIrr} tone="emphasis" size="xl" sx={{ fontWeight: 800 }} />
</Stack>
{summary.paymentDeadlineAt ? (
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
<CountdownTimer
deadlineIso={summary.paymentDeadlineAt}
label={tb('payment_countdown_label')}
elapsedText={tb('payment_elapsed')}
urgent
onElapsed={() => refetch()}
/>
</Paper>
) : null}
<PriceBreakdown
rows={[
{
key: 'service_cost',
// Quantity context per the wireframe («هزینه خدمت (۸ ساعت)») — the visit count is the only
// quantity that always matches the charged gross (variant price × session count).
label: t('row_service_cost_with_count', { count: summary.sessionCount }),
amountIrr: summary.serviceCostIrr,
},
{ key: 'commission', label: t('row_commission'), amountIrr: summary.commissionIrr },
{ key: 'vat', label: t('row_vat'), amountIrr: summary.vatIrr },
]}
totalLabel={t('row_total')}
totalAmountIrr={summary.totalIrr}
/>
<EscrowExplainer />
</Stack>
<Box
sx={{
display: { xs: 'none', md: 'block' },
position: 'sticky',
top: 96,
flex: '0 0 300px',
width: 300,
}}
>
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
{payActions}
</Paper>
</Box>
</Stack>
{summary.paymentDeadlineAt ? (
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
<CountdownTimer
deadlineIso={summary.paymentDeadlineAt}
label={tb('payment_countdown_label')}
elapsedText={tb('payment_elapsed')}
urgent
onElapsed={() => refetch()}
/>
</Paper>
) : null}
<PriceBreakdown
rows={[
{
key: 'service_cost',
// Quantity context per the wireframe («هزینه خدمت (۸ ساعت)») — the visit count is the only
// quantity that always matches the charged gross (variant price × session count).
label: t('row_service_cost_with_count', { count: summary.sessionCount }),
amountIrr: summary.serviceCostIrr,
},
{ key: 'commission', label: t('row_commission'), amountIrr: summary.commissionIrr },
{ key: 'vat', label: t('row_vat'), amountIrr: summary.vatIrr },
]}
totalLabel={t('row_total')}
totalAmountIrr={summary.totalIrr}
/>
<EscrowExplainer />
{/* Spacer so the sticky bar never overlaps the last scrollable content on a short viewport. */}
<Stack sx={{ pb: 1 }} />
<StickyActionBar>
<Stack sx={{ gap: 1 }}>
{inlineError ? (
<AppAlert severity="error" variant="outlined" sx={{ marginY: 0 }}>
{inlineError}
</AppAlert>
) : null}
<Stack direction="row" sx={{ alignItems: 'center', justifyContent: 'space-between', gap: 2 }}>
<Stack sx={{ gap: 0 }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('row_total')}
</Typography>
<Money amountIrr={summary.totalIrr} tone="emphasis" size="md" sx={{ fontWeight: 800 }} />
</Stack>
<AppButton
color="secondary"
variant="contained"
size="large"
disabled={busy}
onClick={handlePay}
endIcon="forward"
sx={{ py: 1.25, flex: 'none', minWidth: 168 }}
>
{initiate.isPending ? t('state_initiating') : initiate.isSuccess ? t('state_redirecting') : t('cta_pay')}
</AppButton>
</Stack>
<Stack direction="row" sx={{ gap: 0.5, alignItems: 'center', justifyContent: 'center' }}>
<AppIcon icon="lock" size={14} color="var(--bal-text-secondary)" />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('secure_gateway_notice')}
</Typography>
</Stack>
{/* The f11 BNPL branch (D1): «پرداخت اقساطی» → the installment wizard, reached with `?request_id=`. */}
{BNPL_ENABLED ? (
<AppButton
variant="outlined"
color="secondary"
startIcon="installments"
disabled={busy}
onClick={() => router.push(`/${locale}${ROUTES.CHECKOUT_BNPL}?${CHECKOUT_QUERY_REQUEST_ID}=${requestId}`)}
>
{t('bnpl_option')}
</AppButton>
) : null}
</Stack>
</StickyActionBar>
{/* Mobile-only: the same actions in the bottom sticky bar; desktop already shows them in the side
panel above. Spacer keeps the sticky bar from overlapping the last content on a short viewport. */}
<Box sx={{ display: { xs: 'block', md: 'none' } }}>
<Stack sx={{ pb: 1 }} />
<StickyActionBar>{payActions}</StickyActionBar>
</Box>
</Stack>
);
}
@@ -238,7 +238,7 @@ export default function BookingRequestStatusPage() {
color="secondary"
variant="contained"
size="large"
endIcon="payment"
endIcon="forward"
onClick={() => router.push(`/${locale}${ROUTES.CHECKOUT}?request_id=${request.id}`)}
sx={{ py: 1.25 }}
>
@@ -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>
);
}
@@ -2,7 +2,8 @@
import { Suspense, useCallback, useMemo, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { Chip, Stack, Typography } from '@mui/material';
import { Box, Chip, Stack, Typography } from '@mui/material';
import type { SxProps, Theme } from '@mui/material';
import { AppButton, AppLoading, EmptyState, ErrorState, NurseResultCard } from '@/components';
import { ROUTES } from '@/constants';
import { useServiceCategories } from '@/services/catalog';
@@ -15,6 +16,15 @@ import { SEARCH_PAGE_SIZE } from '@/services/search/constants';
import { formatIrrToToman } from '@/utils';
import type { NurseSearchResult } from '@/services/search/types';
/** Single column on mobile; two columns above `md` (~900px) so the extra desktop width goes toward
* wider cards instead of one long phone-column list (§3.10 — a full list+detail split is DEFERRED). */
const RESULTS_GRID_SX: SxProps<Theme> = {
display: 'grid',
gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' },
gap: 1.5,
alignItems: 'start',
};
/**
* C2 — Results (نتایج جستجو): the rating-sorted list of **only verified, accepting** nurses for the
* carried filter set. The filter set lives in the URL (the deep-linkable, back/forward-safe cache key),
@@ -123,17 +133,19 @@ function ResultsScreen() {
</Stack>
{isLoading ? (
<Stack sx={{ gap: 1.5 }}>
<Box sx={RESULTS_GRID_SX}>
{[0, 1, 2, 3].map((key) => (
<NurseResultCard.Skeleton key={key} />
))}
</Stack>
</Box>
) : isError ? (
<ErrorState message={t('results_error')} retryLabel={tc('retry')} onRetry={() => refetch()} />
) : items.length === 0 ? (
<RelaxFiltersEmptyState onRelax={backToFilters} />
) : (
<Stack sx={{ gap: 1.5 }}>
// Above ~900px (`md`), a two-column grid uses the extra width instead of one long phone-column
// (the doc's "wider cards" option — a full list+detail split is DEFERRED, see the phase doc).
<Box sx={RESULTS_GRID_SX}>
{items.map((nurse) => (
<NurseResultCard
key={`${nurse.nurseId}-${nurse.variantId}`}
@@ -148,12 +160,12 @@ function ResultsScreen() {
color="primary"
onClick={() => setPageSize((size) => size + SEARCH_PAGE_SIZE)}
disabled={isFetching}
sx={{ alignSelf: 'center' }}
sx={{ alignSelf: 'center', gridColumn: '1 / -1' }}
>
{t('load_more')}
</AppButton>
) : null}
</Stack>
</Box>
)}
</Stack>
);
@@ -1,15 +1,21 @@
'use client';
import { useState } from 'react';
import { Suspense, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import { Box, MenuItem, Skeleton, Stack, TextField } from '@mui/material';
import { AppLoading } from '@/components';
import { AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager, ConfirmDialog, SupportAlertCard } from '@/components/admin';
import { useAdminCapabilities } from '@/hooks';
import { useAdminCapabilities, useAdminListState } from '@/hooks';
import { useAuth } from '@/context/auth';
import { ADMIN_PAGE_SIZE } from '@/services/admin/constants';
import type { SupportAlert, SupportAlertStatus, SupportAlertType } from '@/services/admin/types';
import { useSupportAlerts, useAssignSupportAlert, useResolveSupportAlert } from '@/services/admin';
interface AlertFilters {
status?: SupportAlertStatus;
type?: SupportAlertType;
}
const STATUSES: readonly SupportAlertStatus[] = ['open', 'assigned', 'resolved'];
const TYPES: readonly SupportAlertType[] = [
'low_rating',
@@ -23,32 +29,60 @@ const TYPES: readonly SupportAlertType[] = [
'emergency',
];
const EMPTY: AlertFilters = { status: 'open' };
function parseFilters(params: URLSearchParams): AlertFilters {
const status = params.get('status') as SupportAlertStatus | null;
const type = params.get('type') as SupportAlertType | null;
return {
status: status && STATUSES.includes(status) ? status : undefined,
type: type && TYPES.includes(type) ? type : undefined,
};
}
function serializeFilters(filters: AlertFilters): Record<string, string> {
const r: Record<string, string> = {};
if (filters.status) r.status = filters.status;
if (filters.type) r.type = filters.type;
return r;
}
/**
* Support-alert triage board (f15) the **internal-only** worklist over `support_alerts`. Filter by
* type/status; assign to self or resolve with a note. This data appears in **no** customer/nurse/partner
* surface (phase §5). Server enforces the role scope; `canManageAlerts` only hides the controls.
*/
export default function AdminAlertsPage() {
return (
<Suspense fallback={<AppLoading />}>
<AdminAlertsPageInner />
</Suspense>
);
}
function AdminAlertsPageInner() {
const t = useTranslations('admin');
const caps = useAdminCapabilities();
const { enqueueSnackbar } = useSnackbar();
const [authState] = useAuth();
const meId = authState.currentUser?.id ?? 1;
const meId = authState.currentUser?.id;
const [status, setStatus] = useState<SupportAlertStatus | ''>('open');
const [type, setType] = useState<SupportAlertType | ''>('');
const [page, setPage] = useState(1);
const listState = useAdminListState<AlertFilters>({ parse: parseFilters, serialize: serializeFilters, empty: EMPTY });
const { applied: filters, page } = listState;
const [resolving, setResolving] = useState<SupportAlert | null>(null);
const filters = { status: status || undefined, type: type || undefined };
const alerts = useSupportAlerts(filters, page);
const assign = useAssignSupportAlert();
const resolve = useResolveSupportAlert();
const items = alerts.data?.items ?? [];
const pageCount = Math.max(1, Math.ceil((alerts.data?.total ?? 0) / ADMIN_PAGE_SIZE));
const total = alerts.data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / ADMIN_PAGE_SIZE));
// Assign-to-self MUST NEVER default to a guessed user (the fixed defect: `?? 1`) — the button is simply
// disabled with a "loading your account" tooltip until the real id has hydrated.
const onAssignSelf = (alert: SupportAlert) => {
if (meId == null) return;
assign.mutate(
{ alertId: alert.id, ownerUserId: meId },
{ onSuccess: () => enqueueSnackbar(t('alert_assigned'), { variant: 'success' }) },
@@ -68,6 +102,11 @@ export default function AdminAlertsPage() {
);
};
const setStatusFilter = (value: SupportAlertStatus | '') =>
listState.applyFilters({ ...filters, status: value || undefined });
const setTypeFilter = (value: SupportAlertType | '') =>
listState.applyFilters({ ...filters, type: value || undefined });
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader
@@ -79,11 +118,8 @@ export default function AdminAlertsPage() {
select
size="small"
label={t('alert_col_status')}
value={status}
onChange={(e) => {
setStatus(e.target.value as SupportAlertStatus | '');
setPage(1);
}}
value={filters.status ?? ''}
onChange={(e) => setStatusFilter(e.target.value as SupportAlertStatus | '')}
sx={{ minWidth: 140 }}
>
<MenuItem value="">{t('filter_all')}</MenuItem>
@@ -97,11 +133,8 @@ export default function AdminAlertsPage() {
select
size="small"
label={t('alert_col_type')}
value={type}
onChange={(e) => {
setType(e.target.value as SupportAlertType | '');
setPage(1);
}}
value={filters.type ?? ''}
onChange={(e) => setTypeFilter(e.target.value as SupportAlertType | '')}
sx={{ minWidth: 180 }}
>
<MenuItem value="">{t('filter_all')}</MenuItem>
@@ -130,6 +163,8 @@ export default function AdminAlertsPage() {
canAct={caps.canManageAlerts}
onAssignSelf={onAssignSelf}
onResolve={setResolving}
assignSelfDisabled={meId == null}
assignSelfDisabledTitle={t('assign_me_loading')}
/>
))}
</Stack>
@@ -138,11 +173,11 @@ export default function AdminAlertsPage() {
<AdminPager
page={page}
pageCount={pageCount}
onPrev={() => setPage((p) => Math.max(1, p - 1))}
onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
onPrev={() => listState.goToPage(Math.max(1, page - 1))}
onNext={() => listState.goToPage(Math.min(pageCount, page + 1))}
prevLabel={t('prev_page')}
nextLabel={t('next_page')}
indicator={t('page_indicator', { page })}
indicator={t('page_indicator', { page, total: pageCount })}
/>
<ConfirmDialog
@@ -1,15 +1,35 @@
'use client';
import { useState } from 'react';
import { Suspense, useMemo } from 'react';
import { useTranslations } from 'next-intl';
import { Box, Skeleton, Stack, TextField } from '@mui/material';
import { AppButton } from '@/components';
import { AppButton, AppLoading, JalaliDateField } from '@/components';
import { AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager, AuditLogRow } from '@/components/admin';
import { actorLabelFrom } from '@/components/admin/AuditLogRow';
import { useAdminListState } from '@/hooks';
import { AUDIT_PAGE_SIZE } from '@/services/admin/constants';
import type { AuditFilters } from '@/services/admin/types';
import { useAuditLogs } from '@/services/admin';
import { useAuditLogs, useUserLookup } from '@/services/admin';
const EMPTY: AuditFilters = {};
function parseFilters(params: URLSearchParams): AuditFilters {
return {
entityType: params.get('entityType') ?? undefined,
entityId: params.get('entityId') ?? undefined,
from: params.get('from') ?? undefined,
to: params.get('to') ?? undefined,
};
}
function serializeFilters(filters: AuditFilters): Record<string, string> {
const r: Record<string, string> = {};
if (filters.entityType) r.entityType = filters.entityType;
if (filters.entityId) r.entityId = filters.entityId;
if (filters.from) r.from = filters.from;
if (filters.to) r.to = filters.to;
return r;
}
/**
* Append-only audit-log viewer (f15) a read-only, filtered, paginated table of every admin state change,
* each row expandable to its `changed_fields` diff. There is **no** edit/delete affordance (phase §5). The
@@ -17,24 +37,33 @@ const EMPTY: AuditFilters = {};
* page are the cache key, so switching filters/pages never refetches data already held.
*/
export default function AdminAuditPage() {
return (
<Suspense fallback={<AppLoading />}>
<AdminAuditPageInner />
</Suspense>
);
}
function AdminAuditPageInner() {
const t = useTranslations('admin');
const [draft, setDraft] = useState<AuditFilters>(EMPTY);
const [applied, setApplied] = useState<AuditFilters>(EMPTY);
const [page, setPage] = useState(1);
const listState = useAdminListState<AuditFilters>({ parse: parseFilters, serialize: serializeFilters, empty: EMPTY });
const { draft, setDraft, applied, page } = listState;
const audit = useAuditLogs(applied, page);
const items = audit.data?.items ?? [];
const pageCount = Math.max(1, Math.ceil((audit.data?.total ?? 0) / AUDIT_PAGE_SIZE));
const total = audit.data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / AUDIT_PAGE_SIZE));
const from = items.length === 0 ? 0 : (page - 1) * AUDIT_PAGE_SIZE + 1;
const to = (page - 1) * AUDIT_PAGE_SIZE + items.length;
const apply = () => {
setApplied(draft);
setPage(1);
};
const clear = () => {
setDraft(EMPTY);
setApplied(EMPTY);
setPage(1);
};
// Batch id→name resolve (3.2/3.6) — one request for every actor rendered on this page, never one per row.
const actorIds = useMemo(
() => [...new Set((audit.data?.items ?? []).map((e) => e.actorUserId).filter((id): id is number => id != null))].sort(
(a, b) => a - b,
),
[audit.data?.items],
);
const userLookup = useUserLookup(actorIds);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
@@ -60,26 +89,24 @@ export default function AdminAuditPage() {
onChange={(e) => setDraft((d) => ({ ...d, entityId: e.target.value || undefined }))}
sx={{ minWidth: 120 }}
/>
<TextField
size="small"
type="date"
<JalaliDateField
label={t('audit_from')}
value={draft.from ?? ''}
onChange={(e) => setDraft((d) => ({ ...d, from: e.target.value || undefined }))}
slotProps={{ inputLabel: { shrink: true } }}
value={draft.from ?? null}
onChange={(iso) => setDraft((d) => ({ ...d, from: iso }))}
max={draft.to}
sx={{ minWidth: 160 }}
/>
<TextField
size="small"
type="date"
<JalaliDateField
label={t('audit_to')}
value={draft.to ?? ''}
onChange={(e) => setDraft((d) => ({ ...d, to: e.target.value || undefined }))}
slotProps={{ inputLabel: { shrink: true } }}
value={draft.to ?? null}
onChange={(iso) => setDraft((d) => ({ ...d, to: iso }))}
min={draft.from}
sx={{ minWidth: 160 }}
/>
<AppButton variant="contained" color="primary" onClick={apply}>
<AppButton variant="contained" color="primary" onClick={listState.apply}>
{t('apply')}
</AppButton>
<AppButton variant="text" color="inherit" onClick={clear}>
<AppButton variant="text" color="inherit" onClick={listState.clear}>
{t('clear')}
</AppButton>
</Stack>
@@ -93,19 +120,22 @@ export default function AdminAuditPage() {
) : (
<Stack sx={{ gap: 1 }}>
{items.map((entry) => (
<AuditLogRow key={entry.id} entry={entry} />
<AuditLogRow key={entry.id} entry={entry} actorLabel={actorLabelFrom(userLookup.data, entry.actorUserId)} />
))}
</Stack>
)}
{items.length > 0 ? (
<Box sx={{ typography: 'caption', color: 'text.secondary' }}>{t('showing_range', { from, to, total })}</Box>
) : null}
<AdminPager
page={page}
pageCount={pageCount}
onPrev={() => setPage((p) => Math.max(1, p - 1))}
onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
onPrev={() => listState.goToPage(Math.max(1, page - 1))}
onNext={() => listState.goToPage(Math.min(pageCount, page + 1))}
prevLabel={t('prev_page')}
nextLabel={t('next_page')}
indicator={t('page_indicator', { page })}
indicator={t('page_indicator', { page, total: pageCount })}
/>
</Box>
);
@@ -1,5 +1,5 @@
'use client';
import { useMemo, useState } from 'react';
import { Suspense, useMemo, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import {
@@ -17,10 +17,10 @@ import {
TextField,
Typography,
} from '@mui/material';
import { AppButton, AppIcon } from '@/components';
import { AdminEmptyState, AdminErrorState, AdminPageHeader, ConfigRow } from '@/components/admin';
import { AppButton, AppIcon, AppLoading } from '@/components';
import { AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager, ConfigRow } from '@/components/admin';
import { formatShamsiDateTime } from '@/utils';
import { useAdminCapabilities } from '@/hooks';
import { useAdminCapabilities, useAdminListState } from '@/hooks';
import { CONFIG_GROUPS, RATE_CONFIG_KEYS } from '@/services/admin/constants';
import type { PlatformConfig } from '@/services/admin/types';
import { usePlatformConfigs, useUpdatePlatformConfig, useConfigChangeHistory } from '@/services/admin';
@@ -58,9 +58,18 @@ function validate(config: PlatformConfig, value: string): string | null {
* `data_type` (phase §5).
*/
export default function AdminConfigPage() {
return (
<Suspense fallback={<AppLoading />}>
<AdminConfigPageInner />
</Suspense>
);
}
function AdminConfigPageInner() {
const t = useTranslations('admin');
const caps = useAdminCapabilities();
const configs = usePlatformConfigs(1);
const listState = useAdminListState<Record<string, never>>({ parse: () => ({}), serialize: () => ({}), empty: {} });
const configs = usePlatformConfigs(listState.page);
const [editing, setEditing] = useState<PlatformConfig | null>(null);
const [historyKey, setHistoryKey] = useState<string | null>(null);
@@ -101,6 +110,19 @@ export default function AdminConfigPage() {
))
)}
<AdminPager
page={listState.page}
pageCount={Math.max(1, Math.ceil((configs.data?.total ?? 0) / (configs.data?.pageSize ?? 1)))}
onPrev={() => listState.goToPage(Math.max(1, listState.page - 1))}
onNext={() => listState.goToPage(listState.page + 1)}
prevLabel={t('prev_page')}
nextLabel={t('next_page')}
indicator={t('page_indicator', {
page: listState.page,
total: Math.max(1, Math.ceil((configs.data?.total ?? 0) / (configs.data?.pageSize ?? 1))),
})}
/>
{editing ? <ConfigEditDialog config={editing} onClose={() => setEditing(null)} /> : null}
<ConfigHistoryDrawer configKey={historyKey} onClose={() => setHistoryKey(null)} />
</Box>
@@ -149,7 +171,6 @@ function ConfigEditDialog({ config, onClose }: { config: PlatformConfig; onClose
autoFocus
multiline={config.dataType === 'json'}
minRows={config.dataType === 'json' ? 4 : 1}
type={config.dataType === 'int' || config.dataType === 'decimal' ? 'text' : 'text'}
value={value}
onChange={(e) => setValue(e.target.value)}
label={t('cfg_col_value')}
@@ -179,15 +200,22 @@ function ConfigEditDialog({ config, onClose }: { config: PlatformConfig; onClose
function ConfigHistoryDrawer({ configKey, onClose }: { configKey: string | null; onClose: () => void }) {
const t = useTranslations('admin');
const locale = useLocale();
const history = useConfigChangeHistory(configKey, 1, configKey != null);
const [page, setPage] = useState(1);
const history = useConfigChangeHistory(configKey, page, configKey != null);
// RTL-aware: the drawer slides from the reading-end (left on fa/RTL, right on en/LTR).
const anchor = locale === 'fa' ? 'left' : 'right';
const pageCount = Math.max(1, Math.ceil((history.data?.total ?? 0) / (history.data?.pageSize ?? 1)));
const close = () => {
setPage(1);
onClose();
};
return (
<Drawer anchor={anchor} open={configKey != null} onClose={onClose} slotProps={{ paper: { sx: { width: { xs: '100%', sm: 420 }, p: 3 } } }}>
<Drawer anchor={anchor} open={configKey != null} onClose={close} slotProps={{ paper: { sx: { width: { xs: '100%', sm: 420 }, p: 3 } } }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">{configKey ? t('cfg_history_title', { key: configKey }) : ''}</Typography>
<AppButton variant="text" color="inherit" onClick={onClose} sx={{ minWidth: 0 }}>
<AppButton variant="text" color="inherit" onClick={close} sx={{ minWidth: 0 }}>
<AppIcon icon="close" />
</AppButton>
</Stack>
@@ -202,9 +230,15 @@ function ConfigHistoryDrawer({ configKey, onClose }: { configKey: string | null;
<Stack sx={{ gap: 1.5 }}>
{history.data?.items.map((change) => (
<Box key={change.id} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, p: 1.5 }}>
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontWeight: 700 }}>
{t('cfg_history_change', { old: change.oldValue ?? '—', new: change.newValue ?? '—' })}
</Typography>
<Stack direction="row" sx={{ alignItems: 'center', gap: 0.75, flexWrap: 'wrap' }}>
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontWeight: 700 }}>
{t('cfg_history_change_old', { old: change.oldValue ?? '—' })}
</Typography>
<AppIcon icon="forward" size={14} color="var(--bal-text-secondary)" />
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontWeight: 700 }}>
{t('cfg_history_change_new', { new: change.newValue ?? '—' })}
</Typography>
</Stack>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{formatShamsiDateTime(change.occurredAt, locale)}
{change.actorUserId != null ? ` · #${change.actorUserId}` : ''}
@@ -213,6 +247,16 @@ function ConfigHistoryDrawer({ configKey, onClose }: { configKey: string | null;
))}
</Stack>
)}
<AdminPager
page={page}
pageCount={pageCount}
onPrev={() => setPage((p) => Math.max(1, p - 1))}
onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
prevLabel={t('prev_page')}
nextLabel={t('next_page')}
indicator={t('page_indicator', { page, total: pageCount })}
/>
</Drawer>
);
}
@@ -1,5 +1,5 @@
'use client';
import { useState } from 'react';
import { Suspense, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import {
@@ -16,13 +16,24 @@ import {
Switch,
TextField,
} from '@mui/material';
import { AppButton } from '@/components';
import { AdminDataTable, AdminEmptyState, AdminErrorState, AdminPageHeader, type AdminTableColumn } from '@/components/admin';
import { AppButton, AppLoading, JalaliDateField } from '@/components';
import { AdminDataTable, AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager, type AdminTableColumn } from '@/components/admin';
import { formatShamsiDate } from '@/utils';
import { useAdminCapabilities } from '@/hooks';
import { useAdminCapabilities, useAdminListState } from '@/hooks';
import { ADMIN_PAGE_SIZE } from '@/services/admin/constants';
import type { Holiday, HolidayInput, HolidayType } from '@/services/admin/types';
import { useHolidays, useUpsertHoliday } from '@/services/admin';
/** Today's LOCAL date as ISO `YYYY-MM-DD` never `toISOString()`, which converts to UTC first and can
* land on the wrong day near local midnight (the same class of bug fixed in the payout window default). */
function todayLocalIso(): string {
const d = new Date();
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
const HOLIDAY_TYPES: readonly HolidayType[] = ['official', 'religious', 'national'];
/**
@@ -32,11 +43,21 @@ const HOLIDAY_TYPES: readonly HolidayType[] = ['official', 'religious', 'nationa
* computes the shift itself (phase §5).
*/
export default function AdminHolidaysPage() {
return (
<Suspense fallback={<AppLoading />}>
<AdminHolidaysPageInner />
</Suspense>
);
}
function AdminHolidaysPageInner() {
const t = useTranslations('admin');
const locale = useLocale();
const caps = useAdminCapabilities();
const holidays = useHolidays({}, 1);
const listState = useAdminListState<Record<string, never>>({ parse: () => ({}), serialize: () => ({}), empty: {} });
const holidays = useHolidays({}, listState.page);
const [editing, setEditing] = useState<Holiday | 'new' | null>(null);
const pageCount = Math.max(1, Math.ceil((holidays.data?.total ?? 0) / ADMIN_PAGE_SIZE));
const columns: AdminTableColumn<Holiday>[] = [
{ key: 'date', header: t('hol_col_date'), render: (h) => formatShamsiDate(h.holidayDate, locale) },
@@ -96,6 +117,16 @@ export default function AdminHolidaysPage() {
<AdminDataTable columns={columns} rows={holidays.data?.items ?? []} getRowKey={(h) => h.id} ariaLabel={t('hol_title')} />
)}
<AdminPager
page={listState.page}
pageCount={pageCount}
onPrev={() => listState.goToPage(Math.max(1, listState.page - 1))}
onNext={() => listState.goToPage(Math.min(pageCount, listState.page + 1))}
prevLabel={t('prev_page')}
nextLabel={t('next_page')}
indicator={t('page_indicator', { page: listState.page, total: pageCount })}
/>
{editing ? (
<HolidayDialog holiday={editing === 'new' ? null : editing} onClose={() => setEditing(null)} />
) : null}
@@ -103,18 +134,16 @@ export default function AdminHolidaysPage() {
);
}
const TODAY_ISO = ''; // seeded below via state default so no Date at module load
function HolidayDialog({ holiday, onClose }: { holiday: Holiday | null; onClose: () => void }) {
const t = useTranslations('admin');
const { enqueueSnackbar } = useSnackbar();
const upsert = useUpsertHoliday();
const [form, setForm] = useState<HolidayInput>({
holidayDate: holiday?.holidayDate?.slice(0, 10) ?? TODAY_ISO,
const [form, setForm] = useState<HolidayInput>(() => ({
holidayDate: holiday?.holidayDate?.slice(0, 10) ?? todayLocalIso(),
nameFa: holiday?.nameFa ?? '',
type: holiday?.type ?? 'official',
isBankClosed: holiday?.isBankClosed ?? true,
});
}));
const valid = form.holidayDate.length > 0 && form.nameFa.trim().length > 0;
@@ -136,13 +165,11 @@ function HolidayDialog({ holiday, onClose }: { holiday: Holiday | null; onClose:
<DialogTitle sx={{ fontWeight: 800 }}>{holiday ? t('hol_edit') : t('hol_add')}</DialogTitle>
<DialogContent>
<Stack sx={{ gap: 2, mt: 1 }}>
<TextField
type="date"
<JalaliDateField
label={t('hol_col_date')}
value={form.holidayDate}
onChange={(e) => setForm((f) => ({ ...f, holidayDate: e.target.value }))}
value={form.holidayDate || null}
onChange={(iso) => setForm((f) => ({ ...f, holidayDate: iso }))}
disabled={!!holiday}
slotProps={{ inputLabel: { shrink: true } }}
/>
<TextField
label={t('hol_name_fa')}
@@ -1,13 +1,14 @@
'use client';
import { ReactNode, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { useParams } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import { Box, Divider, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material';
import { AppButton, StatusChip, TrustBadge } from '@/components';
import { AdminEmptyState, AdminErrorState, ConfirmDialog } from '@/components/admin';
import { useAdminCapabilities } from '@/hooks';
import { Box, Divider, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, PageHeader, StatusChip, TrustBadge } from '@/components';
import { AdminEmptyState, AdminErrorState, ConfirmDialog, NursePicker } from '@/components/admin';
import { useAdminCapabilities, useAdminBackToList } from '@/hooks';
import { ROUTES } from '@/constants';
import type { AdminUserSummary } from '@/services/admin/types';
import {
usePartnerCenter,
useCenterSponsoredNurses,
@@ -27,9 +28,9 @@ import { CENTER_STATE_KIND, PartnerCenterFormDialog } from '../page';
export default function AdminPartnerCenterDetailPage() {
const t = useTranslations('admin');
const locale = useLocale();
const router = useRouter();
const caps = useAdminCapabilities();
const { enqueueSnackbar } = useSnackbar();
const goBack = useAdminBackToList(`/${locale}${ROUTES.ADMIN_PARTNERS}`);
const params = useParams<{ id: string }>();
const parsed = Number(params?.id);
@@ -43,7 +44,7 @@ export default function AdminPartnerCenterDetailPage() {
const [confirmVerify, setConfirmVerify] = useState(false);
const [editing, setEditing] = useState(false);
const [assignId, setAssignId] = useState('');
const [assignNurseUser, setAssignNurseUser] = useState<AdminUserSummary | null>(null);
const data = center.data;
@@ -57,14 +58,14 @@ export default function AdminPartnerCenterDetailPage() {
};
const onAssign = () => {
const nurseProfileId = Number(assignId);
if (!Number.isFinite(nurseProfileId) || nurseProfileId <= 0) return;
const nurseProfileId = assignNurseUser?.nurseProfileId;
if (nurseProfileId == null) return;
assignNurse.mutate(
{ nurseProfileId, unlink: false },
{
onSuccess: () => {
enqueueSnackbar(t('partner_nurse_assigned'), { variant: 'success' });
setAssignId('');
setAssignNurseUser(null);
},
},
);
@@ -77,17 +78,7 @@ export default function AdminPartnerCenterDetailPage() {
);
};
const back = (
<AppButton
variant="text"
color="primary"
startIcon="partners"
onClick={() => router.push(`/${locale}${ROUTES.ADMIN_PARTNERS}`)}
sx={{ alignSelf: 'flex-start' }}
>
{t('back')}
</AppButton>
);
const back = <PageHeader title={t('partner_detail_title')} onBack={goBack} backLabel={t('back')} />;
if (center.isLoading) {
return (
@@ -124,18 +115,13 @@ export default function AdminPartnerCenterDetailPage() {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Stack sx={{ gap: 0.75 }}>
{back}
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center', flexWrap: 'wrap' }}>
<Typography variant="h5" component="h1" sx={{ fontWeight: 800 }}>
{t('partner_detail_title')}
</Typography>
<StatusChip status={CENTER_STATE_KIND[data.onboardingState]} label={t(`center_state_${data.onboardingState}`)} />
</Stack>
<Typography variant="subtitle1" sx={{ color: 'text.secondary' }}>
{data.name}
</Typography>
</Stack>
<PageHeader
title={t('partner_detail_title')}
subtitle={data.name}
onBack={goBack}
backLabel={t('back')}
meta={<StatusChip status={CENTER_STATE_KIND[data.onboardingState]} label={t(`center_state_${data.onboardingState}`)} />}
/>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack divider={<Divider flexItem />} sx={{ gap: 1.25 }}>
@@ -221,21 +207,22 @@ export default function AdminPartnerCenterDetailPage() {
{caps.canManagePartners ? (
<Stack direction="row" sx={{ gap: 1, alignItems: 'flex-start', flexWrap: 'wrap' }}>
<TextField
size="small"
type="number"
label={t('partner_assign_nurse_ph')}
value={assignId}
onChange={(e) => setAssignId(e.target.value)}
slotProps={{ htmlInput: { min: 1, step: 1 } }}
sx={{ minWidth: 200 }}
/>
<Box sx={{ minWidth: 260 }}>
<NursePicker
value={assignNurseUser}
onChange={setAssignNurseUser}
label={t('partner_assign_nurse_ph')}
placeholder={t('user_picker_search_ph')}
noOptionsText={t('user_picker_no_options')}
loadingText={t('user_picker_loading')}
/>
</Box>
<AppButton
variant="contained"
color="primary"
startIcon="assign"
onClick={onAssign}
disabled={assignNurse.isPending || Number(assignId) <= 0}
disabled={assignNurse.isPending || assignNurseUser?.nurseProfileId == null}
sx={{ mt: 0.25 }}
>
{t('partner_assign_nurse')}
@@ -1,5 +1,5 @@
'use client';
import { useState } from 'react';
import { Suspense, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { useSnackbar } from 'notistack';
@@ -16,7 +16,7 @@ import {
TextField,
Typography,
} from '@mui/material';
import { AppButton, StatusChip } from '@/components';
import { AppButton, AppLoading, StatusChip } from '@/components';
import type { StatusKind } from '@/components';
import {
AdminDataTable,
@@ -24,14 +24,21 @@ import {
AdminErrorState,
AdminPageHeader,
AdminPager,
UserPicker,
type AdminTableColumn,
} from '@/components/admin';
import { useAdminCapabilities } from '@/hooks';
import { useAdminCapabilities, useAdminListState } from '@/hooks';
import { adminPartnerCenterPath } from '@/constants';
import { PARTNER_PAGE_SIZE } from '@/services/partnerCenter/constants';
import type { CenterOnboardingState, PartnerCenter, PartnerCenterInput } from '@/services/partnerCenter/types';
import type { AdminUserSummary } from '@/services/admin/types';
import { useUserLookup } from '@/services/admin';
import { usePartnerCenters, useCreatePartnerCenter, useUpdatePartnerCenter } from '@/services/partnerCenter';
/** No list-level filters today (the list is unfiltered) — `useAdminListState` still URL-syncs the page. */
type PartnersListFilters = Record<string, never>;
const EMPTY_FILTERS: PartnersListFilters = {};
/** State → semantic chip color. verified = green, pending = amber, suspended = red, draft = neutral. */
export const CENTER_STATE_KIND: Record<CenterOnboardingState, StatusKind> = {
verified: 'verified',
@@ -48,16 +55,33 @@ export const CENTER_STATE_KIND: Record<CenterOnboardingState, StatusKind> = {
* it is only ever entered here, never displayed (the list carries no IBAN at all).
*/
export default function AdminPartnersPage() {
return (
<Suspense fallback={<AppLoading />}>
<AdminPartnersPageInner />
</Suspense>
);
}
function AdminPartnersPageInner() {
const t = useTranslations('admin');
const locale = useLocale();
const router = useRouter();
const caps = useAdminCapabilities();
const [page, setPage] = useState(1);
const [creating, setCreating] = useState(false);
const listState = useAdminListState<PartnersListFilters>({
parse: () => EMPTY_FILTERS,
serialize: () => ({}),
empty: EMPTY_FILTERS,
});
const page = listState.page;
const centers = usePartnerCenters({}, page);
const items = centers.data?.items ?? [];
const pageCount = Math.max(1, Math.ceil((centers.data?.total ?? 0) / PARTNER_PAGE_SIZE));
const total = centers.data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / PARTNER_PAGE_SIZE));
const from = items.length === 0 ? 0 : (page - 1) * PARTNER_PAGE_SIZE + 1;
const to = (page - 1) * PARTNER_PAGE_SIZE + items.length;
const columns: AdminTableColumn<PartnerCenter>[] = [
{ key: 'name', header: t('partner_col_name'), render: (c) => c.name },
@@ -97,17 +121,18 @@ export default function AdminPartnersPage() {
getRowKey={(c) => c.id}
ariaLabel={t('partner_title')}
onRowClick={(c) => router.push(`/${locale}${adminPartnerCenterPath(c.id)}`)}
footer={total > 0 ? t('showing_range', { from, to, total }) : undefined}
/>
)}
<AdminPager
page={page}
pageCount={pageCount}
onPrev={() => setPage((p) => Math.max(1, p - 1))}
onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
onPrev={() => listState.goToPage(Math.max(1, page - 1))}
onNext={() => listState.goToPage(Math.min(pageCount, page + 1))}
prevLabel={t('prev_page')}
nextLabel={t('next_page')}
indicator={t('page_indicator', { page })}
indicator={t('page_indicator', { page, total: pageCount })}
/>
{creating ? <PartnerCenterFormDialog center={null} onClose={() => setCreating(false)} /> : null}
@@ -115,7 +140,9 @@ export default function AdminPartnersPage() {
);
}
/** The editable slice of `PartnerCenterInput`, kept as strings for controlled text/number inputs. */
/** The editable slice of `PartnerCenterInput`, kept as strings for controlled text/number inputs except
* `adminUser`, the resolved picker selection (3.2), never a hand-typed id. `undefined` = untouched (the
* edit-mode existing admin, once resolved, still shows); `null` = the admin explicitly cleared the field. */
interface CenterFormState {
name: string;
legalEntityType: string;
@@ -125,7 +152,7 @@ interface CenterFormState {
settlementIban: string;
isMerchantOfRecord: boolean;
commissionRate: string;
adminUserId: string;
adminUser: AdminUserSummary | null | undefined;
}
function initialForm(center: PartnerCenter | null): CenterFormState {
@@ -139,7 +166,7 @@ function initialForm(center: PartnerCenter | null): CenterFormState {
settlementIban: '',
isMerchantOfRecord: center?.isMerchantOfRecord ?? false,
commissionRate: center != null ? String(center.commissionRate) : '',
adminUserId: center?.adminUserId != null ? String(center.adminUserId) : '',
adminUser: undefined,
};
}
@@ -158,6 +185,14 @@ export function PartnerCenterFormDialog({ center, onClose }: { center: PartnerCe
const mutation = isEdit ? update : create;
const [form, setForm] = useState<CenterFormState>(() => initialForm(center));
// Edit mode: the center already has an adminUserId (a plain number) — resolve it to a name so the picker
// opens pre-filled with a person, never a bare id (3.2). Derived in render (never synced into state via an
// effect): once the admin actually picks someone, `form.adminUser` wins over the resolved existing one.
const existingAdminId = center?.adminUserId ?? null;
const existingAdminLookup = useUserLookup(existingAdminId != null ? [existingAdminId] : []);
const resolvedExistingAdmin = existingAdminId != null ? (existingAdminLookup.data?.get(existingAdminId) ?? null) : null;
const displayedAdminUser = form.adminUser !== undefined ? form.adminUser : resolvedExistingAdmin;
const set = <K extends keyof CenterFormState>(key: K, value: CenterFormState[K]) =>
setForm((f) => ({ ...f, [key]: value }));
@@ -179,7 +214,7 @@ export function PartnerCenterFormDialog({ center, onClose }: { center: PartnerCe
settlementIban: form.settlementIban.trim() || null,
isMerchantOfRecord: form.isMerchantOfRecord,
commissionRate: commission,
adminUserId: form.adminUserId.trim() === '' ? null : Number(form.adminUserId),
adminUserId: displayedAdminUser?.id ?? null,
};
mutation.mutate(input, {
onSuccess: () => {
@@ -235,12 +270,13 @@ export function PartnerCenterFormDialog({ center, onClose }: { center: PartnerCe
onChange={(e) => set('commissionRate', e.target.value)}
slotProps={{ htmlInput: { min: 0, max: 0.999, step: 0.01 } }}
/>
<TextField
type="number"
<UserPicker
value={displayedAdminUser}
onChange={(u) => set('adminUser', u)}
label={t('partner_admin_user')}
value={form.adminUserId}
onChange={(e) => set('adminUserId', e.target.value)}
slotProps={{ htmlInput: { min: 1, step: 1 } }}
placeholder={t('user_picker_search_ph')}
noOptionsText={t('user_picker_no_options')}
loadingText={t('user_picker_loading')}
/>
</Stack>
</DialogContent>
@@ -1,18 +1,22 @@
'use client';
import { FunctionComponent, ReactNode, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { FunctionComponent, ReactNode, Suspense, useState } from 'react';
import { useParams } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import { Box, Chip, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material';
import { AppButton, StatusChip } from '@/components';
import { AppButton, AppLoading, PageHeader, StatusChip } from '@/components';
import type { StatusKind } from '@/components';
import { AdminEmptyState, AdminErrorState, AdminPager, ConfirmDialog } from '@/components/admin';
import { useAdminCapabilities } from '@/hooks';
import { useAdminCapabilities, useAdminListState } from '@/hooks';
import { ROUTES } from '@/constants';
import { formatIrrToToman, formatShamsiDate } from '@/utils';
import { usePayoutBatchDetail, useRecordTransferReference, useRetryPayout } from '@/services/payouts';
import type { AdminPayoutRow, PayoutBatchStatus, PayoutStatus } from '@/services/payouts/types';
/** This detail page has no filters — only a page number worth mirroring into the URL. */
type BatchRowsFilters = Record<string, never>;
const EMPTY_FILTERS: BatchRowsFilters = {};
const BATCH_STATUS_KIND: Record<PayoutBatchStatus, StatusKind> = {
draft: 'neutral',
processing: 'info',
@@ -35,33 +39,37 @@ const PAYOUT_STATUS_KIND: Record<PayoutStatus, StatusKind> = {
* `canPayout`. Money is display-only Toman; the client never recomputes amounts, eligibility, or dates.
*/
export default function AdminPayoutBatchDetailPage() {
return (
<Suspense fallback={<AppLoading />}>
<AdminPayoutBatchDetailScreen />
</Suspense>
);
}
/** Wrapped in `<Suspense>` above — `useAdminListState` calls `useSearchParams()`, which requires it. */
function AdminPayoutBatchDetailScreen() {
const t = useTranslations('admin');
const locale = useLocale();
const router = useRouter();
const caps = useAdminCapabilities();
const params = useParams<{ batchId: string }>();
const batchId = Number(params?.batchId);
const [page, setPage] = useState(1);
const { page, goToPage } = useAdminListState<BatchRowsFilters>({
parse: () => EMPTY_FILTERS,
serialize: () => ({}),
empty: EMPTY_FILTERS,
});
const detail = usePayoutBatchDetail(Number.isFinite(batchId) ? batchId : null, page);
const data = detail.data;
const pageCount = data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1;
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Stack sx={{ gap: 0.5 }}>
<AppButton
variant="text"
color="primary"
onClick={() => router.push(`/${locale}${ROUTES.ADMIN_PAYOUTS}`)}
sx={{ alignSelf: 'flex-start' }}
>
{t('back')}
</AppButton>
<Typography variant="h5" component="h1" sx={{ fontWeight: 800 }}>
{t('payout_batch_title', { id: batchId })}
</Typography>
</Stack>
<PageHeader
title={t('payout_batch_title', { id: batchId })}
backTo={`/${locale}${ROUTES.ADMIN_PAYOUTS}`}
backLabel={t('back')}
/>
{detail.isLoading ? (
<Stack sx={{ gap: 2 }}>
@@ -116,11 +124,11 @@ export default function AdminPayoutBatchDetailPage() {
<AdminPager
page={page}
pageCount={pageCount}
onPrev={() => setPage((p) => Math.max(1, p - 1))}
onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
onPrev={() => goToPage(Math.max(1, page - 1))}
onNext={() => goToPage(Math.min(pageCount, page + 1))}
prevLabel={t('prev_page')}
nextLabel={t('next_page')}
indicator={t('page_indicator', { page })}
indicator={t('page_indicator', { page, total: pageCount })}
/>
</>
)}
@@ -17,7 +17,7 @@ import {
TextField,
Typography,
} from '@mui/material';
import { AppButton, Money, StatusChip } from '@/components';
import { AppButton, JalaliDateField, Money, StatusChip } from '@/components';
import type { StatusKind } from '@/components';
import {
AdminDataTable,
@@ -30,7 +30,7 @@ import {
import type { AdminTableColumn } from '@/components/admin';
import { useAdminCapabilities } from '@/hooks';
import { adminPayoutBatchPath } from '@/constants';
import { formatShamsiDate } from '@/utils';
import { formatNumber, formatShamsiDate } from '@/utils';
import { PAYOUTS_PAGE_SIZE } from '@/services/payouts/constants';
import { usePayoutBatches, usePreviewPayoutBatch, useRunPayoutBatch } from '@/services/payouts';
import type { PayoutBatchStatus, PayoutBatchSummary } from '@/services/payouts/types';
@@ -52,8 +52,17 @@ const BATCH_STATUSES: readonly PayoutBatchStatus[] = [
'failed',
];
/** UTC ISO date (`YYYY-MM-DD`) — the wire shape for the batch window. */
const isoDate = (d: Date): string => d.toISOString().slice(0, 10);
/**
* ISO date (`YYYY-MM-DD`) the wire shape for the batch window. Formats using the browser's **local**
* date fields, never `toISOString()` (which converts to UTC first): near Tehran local midnight that would
* silently roll the date back/forward a day from the admin's actual wall-clock date.
*/
const isoDate = (d: Date): string => {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
};
/**
* Admin payout-batch dashboard (f15) the reconciliation list of weekly `nurse_payout_batches` and the
@@ -173,7 +182,7 @@ export default function AdminPayoutsPage() {
onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
prevLabel={t('prev_page')}
nextLabel={t('next_page')}
indicator={t('page_indicator', { page })}
indicator={t('page_indicator', { page, total: pageCount })}
/>
{previewOpen ? <PreviewBatchDialog canPayout={caps.canPayout} onClose={() => setPreviewOpen(false)} /> : null}
@@ -230,22 +239,13 @@ function PreviewBatchDialog({ canPayout, onClose }: { canPayout: boolean; onClos
<DialogTitle sx={{ fontWeight: 800 }}>{t('payout_preview_title')}</DialogTitle>
<DialogContent>
<Stack direction="row" sx={{ gap: 1.5, mt: 1, flexWrap: 'wrap', alignItems: 'center' }}>
<TextField
type="date"
<JalaliDateField
size="small"
label={t('payout_period_start')}
value={periodStart}
onChange={(e) => setPeriodStart(e.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
/>
<TextField
type="date"
size="small"
label={t('payout_period_end')}
value={periodEnd}
onChange={(e) => setPeriodEnd(e.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
onChange={setPeriodStart}
/>
<JalaliDateField size="small" label={t('payout_period_end')} value={periodEnd} onChange={setPeriodEnd} />
<AppButton
variant="outlined"
color="primary"
@@ -361,12 +361,41 @@ function PreviewBatchDialog({ canPayout, onClose }: { canPayout: boolean; onClos
<ConfirmDialog
open={runConfirmOpen}
title={t('payout_run_confirm_title')}
body={t('payout_run_confirm_body')}
body={
result ? (
<Stack sx={{ gap: 1.5, mt: 0.5 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('payout_run_summary_intro')}
</Typography>
<Money amountIrr={result.totalNetIrr} size="lg" tone="emphasis" />
<Stack sx={{ gap: 0.5 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', gap: 1 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('payout_run_count_label')}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{formatNumber(result.eligible.length, locale)}
</Typography>
</Stack>
<Stack direction="row" sx={{ justifyContent: 'space-between', gap: 1 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('payout_col_processing')}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{formatShamsiDate(result.processingDate, locale)}
</Typography>
</Stack>
</Stack>
</Stack>
) : null
}
confirmLabel={t('payout_run')}
cancelLabel={t('cancel')}
onConfirm={onRunConfirm}
onClose={() => setRunConfirmOpen(false)}
loading={run.isPending}
requireTypedConfirmation={result ? ['تایید', result.totalNetIrr] : []}
typedConfirmationLabel={t('payout_run_type_to_confirm')}
/>
</Dialog>
);
@@ -1,11 +1,11 @@
'use client';
import { useState } from 'react';
import { Suspense, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import { Box, Chip, MenuItem, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material';
import { AppButton, RatingInput } from '@/components';
import { AppButton, AppLoading, RatingInput } from '@/components';
import { AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager, ConfirmDialog } from '@/components/admin';
import { useAdminCapabilities } from '@/hooks';
import { useAdminCapabilities, useAdminListState } from '@/hooks';
import { REVIEWS_PAGE_SIZE } from '@/services/reviews/constants';
import type { ModerationAction, ModerationQueueItem, ModerationStatus } from '@/services/reviews/types';
import { useModerationQueue, useModerateReview } from '@/services/reviews';
@@ -29,13 +29,33 @@ const STATUS_CHIP_COLOR: Record<ModerationStatus, 'default' | 'success' | 'warni
* Publishing recomputes the nurse aggregate **server-side**; the mutation invalidates the queue so the row
* leaves on success. `canModerate` only hides the controls the server enforces the role scope.
*/
const DEFAULT_STATUS: ModerationStatus = 'pending_moderation';
function parseFilters(params: URLSearchParams): { status: ModerationStatus } {
const status = params.get('status') as ModerationStatus | null;
return { status: status && MODERATION_STATUSES.includes(status) ? status : DEFAULT_STATUS };
}
export default function AdminReviewsPage() {
return (
<Suspense fallback={<AppLoading />}>
<AdminReviewsPageInner />
</Suspense>
);
}
function AdminReviewsPageInner() {
const t = useTranslations('admin');
const caps = useAdminCapabilities();
const { enqueueSnackbar } = useSnackbar();
const [status, setStatus] = useState<ModerationStatus>('pending_moderation');
const [page, setPage] = useState(1);
const listState = useAdminListState<{ status: ModerationStatus }>({
parse: parseFilters,
serialize: (f): Record<string, string> => (f.status !== DEFAULT_STATUS ? { status: f.status } : {}),
empty: { status: DEFAULT_STATUS },
});
const status = listState.applied.status;
const page = listState.page;
const [pending, setPending] = useState<{ item: ModerationQueueItem; action: ModerationAction } | null>(null);
const queue = useModerationQueue({ status }, page);
@@ -44,6 +64,8 @@ export default function AdminReviewsPage() {
const items = queue.data?.items ?? [];
const pageCount = Math.max(1, Math.ceil((queue.data?.total ?? 0) / REVIEWS_PAGE_SIZE));
const setStatus = (next: ModerationStatus) => listState.applyFilters({ status: next });
const requireReason = pending?.action === 'hide' || pending?.action === 'reject';
const onConfirm = (reason?: string) => {
@@ -70,10 +92,7 @@ export default function AdminReviewsPage() {
size="small"
label={t('filter_label')}
value={status}
onChange={(e) => {
setStatus(e.target.value as ModerationStatus);
setPage(1);
}}
onChange={(e) => setStatus(e.target.value as ModerationStatus)}
sx={{ minWidth: 180 }}
>
{MODERATION_STATUSES.map((s) => (
@@ -107,11 +126,11 @@ export default function AdminReviewsPage() {
<AdminPager
page={page}
pageCount={pageCount}
onPrev={() => setPage((p) => Math.max(1, p - 1))}
onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
onPrev={() => listState.goToPage(Math.max(1, page - 1))}
onNext={() => listState.goToPage(Math.min(pageCount, page + 1))}
prevLabel={t('prev_page')}
nextLabel={t('next_page')}
indicator={t('page_indicator', { page })}
indicator={t('page_indicator', { page, total: pageCount })}
/>
<ConfirmDialog
@@ -8,7 +8,7 @@
* grid lists **active** grants (revoked rows are filtered out); an audited confirm dialog fronts every
* revoke and grant.
*/
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import {
@@ -31,12 +31,13 @@ import {
AdminErrorState,
AdminPageHeader,
ConfirmDialog,
UserPicker,
type AdminTableColumn,
} from '@/components/admin';
import { formatShamsiDate } from '@/utils';
import { useAdminCapabilities } from '@/hooks';
import type { AdminRole, RoleGrant } from '@/services/admin/types';
import { useAdminRoles, useGrantRole, useRevokeRole } from '@/services/admin';
import type { AdminRole, AdminUserSummary, RoleGrant } from '@/services/admin/types';
import { useAdminRoles, useGrantRole, useRevokeRole, useUserLookup } from '@/services/admin';
/** The fine-grained admin roles the grid grants (aligned with the b2 `AdminRole` enum). */
const ROLES: readonly AdminRole[] = ['super_admin', 'admin', 'support', 'finance', 'moderation'];
@@ -56,8 +57,13 @@ export default function AdminRolesPage() {
// Only active grants — a revoked grant leaves the grid.
const active = (roles.data ?? []).filter((g) => g.revokedAt == null);
// Batch id→name resolve (3.2) — one request for every grant row, never one per row.
const userIds = useMemo(() => [...new Set(active.map((g) => g.userId))].sort((a, b) => a - b), [active]);
const userLookup = useUserLookup(userIds);
const nameFor = (userId: number): string => userLookup.data?.get(userId)?.displayName ?? `#${userId}`;
const columns: AdminTableColumn<RoleGrant>[] = [
{ key: 'user', header: t('role_col_user'), render: (g) => `#${g.userId}` },
{ key: 'user', header: t('role_col_user'), render: (g) => nameFor(g.userId) },
{
key: 'role',
header: t('role_col_role'),
@@ -131,7 +137,7 @@ export default function AdminRolesPage() {
<ConfirmDialog
open={revoking != null}
title={t('role_revoke')}
body={revoking ? t('role_revoke_confirm', { role: t(`role_${revoking.role}`), id: revoking.userId }) : undefined}
body={revoking ? t('role_revoke_confirm', { role: t(`role_${revoking.role}`), name: nameFor(revoking.userId) }) : undefined}
confirmLabel={t('role_revoke')}
cancelLabel={t('cancel')}
confirmColor="error"
@@ -149,16 +155,15 @@ function GrantRoleDialog({ onClose }: { onClose: () => void }) {
const { enqueueSnackbar } = useSnackbar();
const grant = useGrantRole();
const [userId, setUserId] = useState('');
const [user, setUser] = useState<AdminUserSummary | null>(null);
const [role, setRole] = useState<AdminRole>('support');
const parsedId = Number(userId);
const valid = /^\d+$/.test(userId.trim()) && parsedId > 0;
const valid = user != null;
const onGrant = () => {
if (!valid) return;
if (!user) return;
grant.mutate(
{ userId: parsedId, role },
{ userId: user.id, role },
{
onSuccess: () => {
enqueueSnackbar(t('role_updated'), { variant: 'success' });
@@ -173,12 +178,13 @@ function GrantRoleDialog({ onClose }: { onClose: () => void }) {
<DialogTitle sx={{ fontWeight: 800 }}>{t('role_grant')}</DialogTitle>
<DialogContent>
<Stack sx={{ gap: 2, mt: 1 }}>
<TextField
<UserPicker
value={user}
onChange={setUser}
label={t('role_col_user')}
type="number"
value={userId}
onChange={(e) => setUserId(e.target.value)}
slotProps={{ htmlInput: { min: 1 } }}
placeholder={t('user_picker_search_ph')}
noOptionsText={t('user_picker_no_options')}
loadingText={t('user_picker_loading')}
/>
<TextField select label={t('role_col_role')} value={role} onChange={(e) => setRole(e.target.value as AdminRole)}>
{ROLES.map((r) => (
@@ -188,7 +194,7 @@ function GrantRoleDialog({ onClose }: { onClose: () => void }) {
))}
</TextField>
{valid ? (
<DialogContentText>{t('role_grant_confirm', { role: t(`role_${role}`), id: parsedId })}</DialogContentText>
<DialogContentText>{t('role_grant_confirm', { role: t(`role_${role}`), name: user.displayName })}</DialogContentText>
) : null}
</Stack>
</DialogContent>
@@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { useParams } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import {
@@ -13,18 +13,22 @@ import {
TextField,
ToggleButton,
ToggleButtonGroup,
Typography,
Tooltip,
} from '@mui/material';
import { AppButton, StatusChip } from '@/components';
import { AppButton, PageHeader, StatusChip } from '@/components';
import type { StatusKind } from '@/components';
import { AdminEmptyState, AdminErrorState, AdminMessageBubble, RefundPanel } from '@/components/admin';
import { useAdminCapabilities } from '@/hooks';
import { useThreadScroll } from '@/components/messaging';
import { AdminEmptyState, AdminErrorState, AdminMessageBubble, ConfirmDialog, RefundPanel } from '@/components/admin';
import { useAdminBackToList, useAdminCapabilities } from '@/hooks';
import { useAuth } from '@/context/auth';
import { ROUTES } from '@/constants';
import { useAdminTicket, usePostAdminMessage } from '@/services/tickets';
import { useAdminTicket, useAssignTicket, useCloseTicket, usePostAdminMessage, useReopenTicket } from '@/services/tickets';
import { TICKET_LIFECYCLE_ENABLED } from '@/services/tickets/constants';
import type { TicketAuthorRole, TicketStatus } from '@/services/tickets/types';
/** Status → chip color: an open ticket is pending work, a closed one is neutral (mirrors the queue). */
const STATUS_KIND: Record<TicketStatus, StatusKind> = { open: 'pending', closed: 'neutral' };
const REFUND_PANEL_ID = 'admin-ticket-refund-panel';
/** The `tickets` author-label key. `admin` has no `author_admin` key — staff read as "support" (`author_support`). */
function authorLabelKey(role: TicketAuthorRole): string {
@@ -40,18 +44,22 @@ function makeClientMessageId(): string {
* The admin ticket thread (f15) the full conversation **including internal notes** (the admin lens carries
* `isInternal`; the user app never does). Staff read the whole thread and reply as **either** a participant-
* visible reply **or** a staff-only internal note (the composer toggles `isInternal`; `AdminMessageBubble`
* renders internal notes distinctly). Sends are optimistic (`usePostAdminMessage`) the draft clears only on
* confirm. When the ticket is a **refund** case linked to a booking, the admin opens the `RefundPanel` inline
* (it always initiates from a ticket, never a standalone form). Composer + refund are gated on the principal's
* capabilities; the server is the real authority.
* renders internal notes distinctly, and ui-phase-11 makes the composer itself amber-tinted in internal mode
* so a staff member can never post an internal note publicly by mistake). Sends are optimistic
* (`usePostAdminMessage`) the draft clears only on confirm; the thread opens scrolled to the newest message
* (`useThreadScroll`). When the ticket is a **refund** case linked to a booking, the admin opens the
* `RefundPanel` inline. Close/reopen/assign-to-me (REQ-063) are gated behind `TICKET_LIFECYCLE_ENABLED` no
* live route yet AND the principal's `canManageTickets` capability; the server is the real authority.
*/
export default function AdminTicketThreadPage() {
const t = useTranslations('admin');
const tickets = useTranslations('tickets');
const locale = useLocale();
const router = useRouter();
const caps = useAdminCapabilities();
const { enqueueSnackbar } = useSnackbar();
const [authState] = useAuth();
const meId = authState.currentUser?.id;
const goBack = useAdminBackToList(`/${locale}${ROUTES.ADMIN_TICKETS}`);
const params = useParams<{ id: string }>();
const parsed = Number(params?.id);
@@ -59,12 +67,20 @@ export default function AdminTicketThreadPage() {
const { data: detail, isLoading, isError, refetch } = useAdminTicket(ticketId || null);
const post = usePostAdminMessage(ticketId);
const closeTicket = useCloseTicket(ticketId);
const reopenTicket = useReopenTicket(ticketId);
const assignTicket = useAssignTicket(ticketId);
const [mode, setMode] = useState<'reply' | 'internal'>('reply');
const [body, setBody] = useState('');
const [refundShown, setRefundShown] = useState(false);
const [closeDialogOpen, setCloseDialogOpen] = useState(false);
const [reopenDialogOpen, setReopenDialogOpen] = useState(false);
const isInternal = mode === 'internal';
const messageCount = detail?.messages.length ?? 0;
const lastMessage = detail?.messages[messageCount - 1];
const { bottomRef } = useThreadScroll(messageCount, lastMessage?.isMine ?? false);
const send = () => {
const trimmed = body.trim();
@@ -80,16 +96,62 @@ export default function AdminTicketThreadPage() {
);
};
const onCloseConfirm = () => {
closeTicket.mutate(undefined, {
onSuccess: () => {
setCloseDialogOpen(false);
enqueueSnackbar(t('ticket_closed_ok'), { variant: 'success' });
},
});
};
const onReopenConfirm = () => {
reopenTicket.mutate(undefined, {
onSuccess: () => {
setReopenDialogOpen(false);
enqueueSnackbar(t('ticket_reopened_ok'), { variant: 'success' });
},
});
};
const onAssignMe = () => {
if (meId == null || assignTicket.isPending) return;
assignTicket.mutate({ ownerUserId: meId }, { onSuccess: () => enqueueSnackbar(t('ticket_assigned_me'), { variant: 'success' }) });
};
const showRefund = !!detail && detail.category === 'refund' && detail.bookingId != null && caps.canRefund;
const showLifecycle = TICKET_LIFECYCLE_ENABLED && caps.canManageTickets;
const lifecycleActions = showLifecycle && detail ? (
<>
{detail.status === 'open' ? (
<AppButton variant="outlined" color="primary" startIcon="close" onClick={() => setCloseDialogOpen(true)}>
{t('ticket_close')}
</AppButton>
) : (
<AppButton variant="outlined" color="primary" startIcon="refresh" onClick={() => setReopenDialogOpen(true)}>
{t('ticket_reopen')}
</AppButton>
)}
<Tooltip title={meId == null ? t('assign_me_loading') : ''}>
<span>
<AppButton
variant="outlined"
color="primary"
startIcon="assign"
onClick={onAssignMe}
disabled={meId == null || assignTicket.isPending}
>
{t('ticket_assign_me')}
</AppButton>
</span>
</Tooltip>
</>
) : null;
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 820, mx: 'auto', width: '100%' }}>
<AppButton
variant="text"
color="primary"
onClick={() => router.push(`/${locale}${ROUTES.ADMIN_TICKETS}`)}
sx={{ alignSelf: 'flex-start' }}
>
<AppButton variant="text" color="primary" onClick={goBack} sx={{ alignSelf: 'flex-start' }}>
{t('back')}
</AppButton>
@@ -106,44 +168,47 @@ export default function AdminTicketThreadPage() {
) : (
<>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1.5 }}>
<Typography variant="h6" component="h1" sx={{ fontWeight: 800 }} dir="ltr">
{t('ticket_thread_title', { ref: detail.referenceCode })}
</Typography>
{detail.subject ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{detail.subject}
</Typography>
) : null}
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap', alignItems: 'center' }}>
<Chip size="small" label={t(`tcat_${detail.category}`)} />
<StatusChip status={STATUS_KIND[detail.status]} label={t(`tstatus_${detail.status}`)} />
{detail.bookingId != null ? (
<Chip size="small" variant="outlined" label={t('refund_linked_booking', { id: detail.bookingId })} />
) : null}
{detail.refundId != null ? (
<Chip size="small" variant="outlined" label={t('ticket_linked_refund', { id: detail.refundId })} />
) : null}
</Stack>
<PageHeader
title={t('ticket_thread_title', { ref: detail.referenceCode })}
subtitle={detail.subject ?? undefined}
actions={lifecycleActions}
meta={
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap', alignItems: 'center' }}>
<Chip size="small" label={t(`tcat_${detail.category}`)} />
<StatusChip status={STATUS_KIND[detail.status]} label={t(`tstatus_${detail.status}`)} />
{detail.bookingId != null ? (
<Chip size="small" variant="outlined" label={t('refund_linked_booking', { id: detail.bookingId })} />
) : null}
{detail.refundId != null ? (
<Chip size="small" variant="outlined" label={t('ticket_linked_refund', { id: detail.refundId })} />
) : null}
</Stack>
}
/>
{showRefund ? (
<Box>
<AppButton
variant="outlined"
color="primary"
startIcon="refunds"
onClick={() => setRefundShown((v) => !v)}
{showRefund ? (
<Box sx={{ mt: 1.5 }}>
<AppButton
variant="outlined"
color="primary"
startIcon="refunds"
aria-expanded={refundShown}
aria-controls={REFUND_PANEL_ID}
onClick={() => setRefundShown((v) => !v)}
>
{t('refund_open')}
</AppButton>
<Collapse in={refundShown} unmountOnExit>
<Paper
id={REFUND_PANEL_ID}
elevation={0}
sx={{ mt: 1.5, p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
>
{t('refund_open')}
</AppButton>
<Collapse in={refundShown} unmountOnExit>
<Paper elevation={0} sx={{ mt: 1.5, p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<RefundPanel bookingId={detail.bookingId as number} ticketId={detail.id} />
</Paper>
</Collapse>
</Box>
) : null}
</Stack>
<RefundPanel bookingId={detail.bookingId as number} ticketId={detail.id} />
</Paper>
</Collapse>
</Box>
) : null}
</Paper>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, maxHeight: 520, overflowY: 'auto', p: 0.5 }}>
@@ -154,10 +219,20 @@ export default function AdminTicketThreadPage() {
authorLabel={tickets(authorLabelKey(m.authorRole))}
/>
))}
<div ref={bottomRef} />
</Box>
{caps.canManageTickets ? (
<Paper elevation={0} sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper
elevation={0}
sx={{
p: 2,
border: '1px solid',
borderColor: isInternal ? 'var(--bal-warning)' : 'divider',
borderRadius: 2,
bgcolor: isInternal ? 'var(--bal-warning-soft)' : 'background.paper',
}}
>
<Stack sx={{ gap: 1.5 }}>
<ToggleButtonGroup
size="small"
@@ -185,11 +260,34 @@ export default function AdminTicketThreadPage() {
disabled={post.isPending || body.trim().length === 0}
sx={{ alignSelf: 'flex-start' }}
>
{t('ticket_send')}
{isInternal ? t('ticket_send_internal') : t('ticket_send')}
</AppButton>
</Stack>
</Paper>
) : null}
<ConfirmDialog
open={closeDialogOpen}
title={t('ticket_close')}
body={t('ticket_close_confirm')}
confirmLabel={t('ticket_close')}
cancelLabel={t('cancel')}
onConfirm={onCloseConfirm}
onClose={() => setCloseDialogOpen(false)}
loading={closeTicket.isPending}
confirmColor="primary"
/>
<ConfirmDialog
open={reopenDialogOpen}
title={t('ticket_reopen')}
body={t('ticket_reopen_confirm')}
confirmLabel={t('ticket_reopen')}
cancelLabel={t('cancel')}
onConfirm={onReopenConfirm}
onClose={() => setReopenDialogOpen(false)}
loading={reopenTicket.isPending}
confirmColor="primary"
/>
</>
)}
</Box>
@@ -1,9 +1,9 @@
'use client';
import { useState } from 'react';
import { Suspense } from 'react';
import { useRouter } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { Box, Chip, MenuItem, Skeleton, Stack, TextField } from '@mui/material';
import { AppButton, StatusChip } from '@/components';
import { AppButton, AppLoading, StatusChip } from '@/components';
import type { StatusKind } from '@/components';
import {
AdminDataTable,
@@ -14,9 +14,11 @@ import {
type AdminTableColumn,
} from '@/components/admin';
import { adminTicketThreadPath } from '@/constants';
import { useAdminListState } from '@/hooks';
import { useAdminTickets } from '@/services/tickets';
import { TICKETS_PAGE_SIZE } from '@/services/tickets/constants';
import type { AdminTicketFilters, AdminTicketSummary, TicketCategory, TicketStatus } from '@/services/tickets/types';
import { formatShamsiDate } from '@/utils';
const STATUSES: readonly TicketStatus[] = ['open', 'closed'];
const CATEGORIES: readonly TicketCategory[] = ['coordination', 'support', 'refund', 'emergency'];
@@ -24,35 +26,55 @@ const CATEGORIES: readonly TicketCategory[] = ['coordination', 'support', 'refun
const STATUS_KIND: Record<TicketStatus, StatusKind> = { open: 'pending', closed: 'neutral' };
const EMPTY: AdminTicketFilters = {};
function parseFilters(params: URLSearchParams): AdminTicketFilters {
return {
status: (params.get('status') as TicketStatus | null) ?? undefined,
category: (params.get('category') as TicketCategory | null) ?? undefined,
referenceCode: params.get('referenceCode') ?? undefined,
};
}
function serializeFilters(filters: AdminTicketFilters): Record<string, string> {
const record: Record<string, string> = {};
if (filters.status) record.status = filters.status;
if (filters.category) record.category = filters.category;
if (filters.referenceCode) record.referenceCode = filters.referenceCode;
return record;
}
/**
* The admin global ticket queue (f15) EVERY ticket across the platform (not one viewer's), the entry point
* into a case. Filter by status/category/reference; a row opens the admin thread where internal notes and the
* refund panel live. The filter **draft** commits to the query only on Apply, so typing a reference never
* refetches; the applied filters + page are the cache key (`useAdminTickets`), so revisiting a filter/page
* serves from cache. This surface is staff-only the server enforces the scope; the UI just routes here.
* refetches; the applied filters + page are mirrored into the URL (`useAdminListState`, ui-phase-11) so
* browser back/refresh/a pasted link all reproduce the exact same queue view. This surface is staff-only
* the server enforces the scope; the UI just routes here.
*/
export default function AdminTicketsPage() {
return (
<Suspense fallback={<AppLoading />}>
<AdminTicketsQueue />
</Suspense>
);
}
function AdminTicketsQueue() {
const t = useTranslations('admin');
const locale = useLocale();
const router = useRouter();
const [draft, setDraft] = useState<AdminTicketFilters>(EMPTY);
const [applied, setApplied] = useState<AdminTicketFilters>(EMPTY);
const [page, setPage] = useState(1);
const { draft, setDraft, applied, page, apply, clear, goToPage } = useAdminListState<AdminTicketFilters>({
parse: parseFilters,
serialize: serializeFilters,
empty: EMPTY,
});
const tickets = useAdminTickets(applied, page);
const items = tickets.data?.items ?? [];
const pageCount = Math.max(1, Math.ceil((tickets.data?.total ?? 0) / TICKETS_PAGE_SIZE));
const apply = () => {
setApplied(draft);
setPage(1);
};
const clear = () => {
setDraft(EMPTY);
setApplied(EMPTY);
setPage(1);
};
const total = tickets.data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / TICKETS_PAGE_SIZE));
const from = items.length === 0 ? 0 : (page - 1) * TICKETS_PAGE_SIZE + 1;
const to = items.length === 0 ? 0 : from + items.length - 1;
const columns: AdminTableColumn<AdminTicketSummary>[] = [
{
@@ -76,6 +98,12 @@ export default function AdminTicketsPage() {
render: (row) => <StatusChip status={STATUS_KIND[row.status]} label={t(`tstatus_${row.status}`)} />,
},
{ key: 'booking', header: t('ticket_col_booking'), render: (row) => row.bookingId ?? '—' },
{
key: 'activity',
header: t('ticket_activity_col'),
minWidth: 140,
render: (row) => formatShamsiDate(row.createdAt, locale),
},
];
return (
@@ -149,17 +177,18 @@ export default function AdminTicketsPage() {
getRowKey={(row) => row.id}
ariaLabel={t('ticket_title')}
onRowClick={(row) => router.push(`/${locale}${adminTicketThreadPath(row.id)}`)}
footer={t('showing_range', { from, to, total })}
/>
)}
<AdminPager
page={page}
pageCount={pageCount}
onPrev={() => setPage((p) => Math.max(1, p - 1))}
onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
onPrev={() => goToPage(Math.max(1, page - 1))}
onNext={() => goToPage(Math.min(pageCount, page + 1))}
prevLabel={t('prev_page')}
nextLabel={t('next_page')}
indicator={t('page_indicator', { page })}
indicator={t('page_indicator', { page, total: pageCount })}
/>
</Box>
);
@@ -1,8 +1,101 @@
import { getTranslations } from 'next-intl/server';
import { PlaceholderScreen } from '@/components';
'use client';
import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Box, Chip, Stack, TextField, Typography } from '@mui/material';
import { AdminDataTable, AdminEmptyState, AdminPageHeader, type AdminTableColumn } from '@/components/admin';
import { AppLink } from '@/components';
import { ROUTES } from '@/constants';
import { useUserSearch } from '@/services/admin';
import { useDebouncedValue } from '@/services/search';
import type { AdminUserSummary, DirectoryUserRole } from '@/services/admin/types';
export default async function AdminUsersPage() {
const t = await getTranslations('nav');
const tShell = await getTranslations('shell');
return <PlaceholderScreen icon="users" title={t('users')} description={tShell('placeholder_body')} />;
const SEARCH_DEBOUNCE_MS = 300;
const MIN_QUERY_LENGTH = 2;
/** Directory role → chip label key. */
const ROLE_LABEL_KEY: Record<DirectoryUserRole, string> = {
customer: 'user_role_customer',
nurse: 'user_role_nurse',
admin: 'user_role_admin',
partner: 'user_role_partner',
};
/**
* Read-first admin user directory (phase §3.8) search by name/phone over the same admin user-directory
* seam `UserPicker`/`NursePicker` use (REQ-061, gap mock-backed until delivered). Built because the REQ
* this console depends on is filed and mocked-behind-the-seam this same phase; it becomes real the moment
* `services/admin`'s seam flips. Each row links into the audit log filtered to that user (the one console
* that already supports an entity filter) a ticket-queue deep link isn't offered because the admin
* ticket queue has no actor/user filter to land on (would be a dishonest link).
*/
export default function AdminUsersPage() {
const t = useTranslations('admin');
const locale = useLocale();
const [query, setQuery] = useState('');
const debounced = useDebouncedValue(query, SEARCH_DEBOUNCE_MS);
const search = useUserSearch(debounced);
const items = search.data ?? [];
const hasQuery = debounced.trim().length >= MIN_QUERY_LENGTH;
const columns: AdminTableColumn<AdminUserSummary>[] = [
{ key: 'name', header: t('user_col_name'), render: (u) => u.displayName, minWidth: 180 },
{
key: 'phone',
header: t('user_col_phone'),
render: (u) => (
<Box component="span" dir="ltr">
{u.maskedPhone}
</Box>
),
},
{ key: 'id', header: t('user_col_id'), render: (u) => `#${u.id}` },
{
key: 'roles',
header: t('user_col_roles'),
render: (u) => (
<Stack direction="row" sx={{ gap: 0.5, flexWrap: 'wrap' }}>
{u.roles.map((r) => (
<Chip key={r} size="small" variant="outlined" label={t(ROLE_LABEL_KEY[r])} />
))}
</Stack>
),
},
{
key: 'actions',
header: '',
align: 'right' as const,
render: (u) => (
<AppLink to={`/${locale}${ROUTES.ADMIN_AUDIT}?entityType=User&entityId=${u.id}`}>{t('user_view_audit')}</AppLink>
),
},
];
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader title={t('user_title')} subtitle={t('user_subtitle')} />
<TextField
size="small"
label={t('user_search_label')}
placeholder={t('user_picker_search_ph')}
value={query}
onChange={(e) => setQuery(e.target.value)}
sx={{ maxWidth: 360 }}
/>
{!hasQuery ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('user_search_hint')}
</Typography>
) : search.isFetching ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('user_picker_loading')}
</Typography>
) : items.length === 0 ? (
<AdminEmptyState icon="users" title={t('user_empty')} />
) : (
<AdminDataTable columns={columns} rows={items} getRowKey={(u) => u.id} ariaLabel={t('user_title')} />
)}
</Box>
);
}
@@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { Suspense, useCallback, useEffect, useMemo, useState } from 'react';
import { useParams, useRouter, useSearchParams } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import {
@@ -15,25 +15,21 @@ import {
TextField,
Typography,
} from '@mui/material';
import { AppButton, StatusChip } from '@/components';
import { AppButton, AppLoading, JalaliDateField, PageHeader, StatusChip } from '@/components';
import type { StatusKind } from '@/components';
import {
AdminEmptyState,
AdminErrorState,
AdminPageHeader,
ConfirmDialog,
DocumentViewer,
} from '@/components/admin';
import { ROUTES } from '@/constants';
import { AdminEmptyState, AdminErrorState, ConfirmDialog, DocumentViewer } from '@/components/admin';
import { ROUTES, adminVerificationCasePath } from '@/constants';
import { formatShamsiDate } from '@/utils';
import { useAdminCapabilities } from '@/hooks';
import { useAdminBackToList, useAdminCapabilities } from '@/hooks';
import {
useApproveVerification,
useDecideStep,
useRejectVerification,
useVerificationCase,
useVerificationQueue,
} from '@/services/verification';
import type { AdminVerificationStepDetail, VerificationStepStatus } from '@/services/verification/types';
import { parseQueueFilters, queueCaseHref } from '../queueFilters';
/** The three credential-bearing step types — a Pass here opens the structured credential form. */
const CREDENTIAL_STEP_CODES: readonly string[] = ['moh_competency_license', 'ino_membership', 'criminal_record'];
@@ -48,6 +44,14 @@ const STEP_STATUS_KIND: Record<VerificationStepStatus, StatusKind> = {
expired: 'rejected',
};
export default function AdminVerificationCasePage() {
return (
<Suspense fallback={<AppLoading />}>
<AdminVerificationCaseScreen />
</Suspense>
);
}
/**
* Verification case (b6 `AdminVerificationsController`) the trust desk works one nurse: the identity on
* file for cross-check, every step with its status + documents (each `DocumentViewer` re-signs its own
@@ -55,12 +59,18 @@ const STEP_STATUS_KIND: Record<VerificationStepStatus, StatusKind> = {
* (encrypted) credential via a structured form; recorded credentials are listed by **type** only the
* number never crosses the wire. The whole verification is approvable only when every required step has
* passed; a decision re-aggregates server-side (flipping `is_verified`) and removes the case from the queue.
*
* «پرونده بعدی»/«پرونده قبلی» + arrow keys move through the **current queue page's order** without
* returning to the list: the case URL carries the queue's `status`/`search`/`page` (`queueCaseHref`), so
* re-deriving the same filters here hits `useVerificationQueue`'s cache the list already primed (no extra
* fetch in the common flow). Fetching adjacent pages is out of scope prev/next disable at the page's ends.
*/
export default function AdminVerificationCasePage() {
function AdminVerificationCaseScreen() {
const t = useTranslations('admin');
const locale = useLocale();
const router = useRouter();
const params = useParams<{ nurseId: string }>();
const searchParams = useSearchParams();
const nurseVerificationId = Number(params?.nurseId);
const caps = useAdminCapabilities();
const { enqueueSnackbar } = useSnackbar();
@@ -74,7 +84,36 @@ export default function AdminVerificationCasePage() {
const [approveOpen, setApproveOpen] = useState(false);
const [rejectOpen, setRejectOpen] = useState(false);
const backToQueue = () => router.push(`/${locale}${ROUTES.ADMIN_VERIFICATION}`);
const backToQueue = useAdminBackToList(`/${locale}${ROUTES.ADMIN_VERIFICATION}`);
// Re-derive the SAME filters/page the queue list would have parsed from its own URL, so this query
// reuses the list's cached page (React Query keys structurally) instead of an unfiltered refetch.
const queueFilters = useMemo(() => parseQueueFilters(searchParams), [searchParams]);
const queuePage = useMemo(() => {
const raw = Number(searchParams.get('page'));
return Number.isInteger(raw) && raw > 0 ? raw : 1;
}, [searchParams]);
const queueQuery = useVerificationQueue(queueFilters, queuePage);
const queueItems = queueQuery.data?.items ?? [];
const caseIndex = queueItems.findIndex((item) => item.nurseVerificationId === nurseVerificationId);
const prevCase = caseIndex > 0 ? queueItems[caseIndex - 1] : null;
const nextCase = caseIndex >= 0 && caseIndex < queueItems.length - 1 ? queueItems[caseIndex + 1] : null;
const goToCase = useCallback(
(id: number) => router.push(queueCaseHref(locale, adminVerificationCasePath(id), queueFilters, queuePage)),
[router, locale, queueFilters, queuePage],
);
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
const tag = (document.activeElement as HTMLElement | null)?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
if (event.key === 'ArrowRight' && prevCase) goToCase(prevCase.nurseVerificationId);
else if (event.key === 'ArrowLeft' && nextCase) goToCase(nextCase.nurseVerificationId);
}
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [prevCase, nextCase, goToCase]);
const allPassed = !!data && data.steps.length > 0 && data.steps.every((step) => step.status === 'passed');
@@ -103,12 +142,33 @@ export default function AdminVerificationCasePage() {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Stack sx={{ gap: 1 }}>
<AppButton variant="text" color="primary" onClick={backToQueue} sx={{ alignSelf: 'flex-start' }}>
{t('back')}
</AppButton>
<AdminPageHeader title={t('ver_case_title')} />
</Stack>
<PageHeader
title={t('ver_case_title')}
onBack={backToQueue}
backLabel={t('back')}
actions={
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
<AppButton
variant="outlined"
color="inherit"
startIcon="back"
onClick={() => prevCase && goToCase(prevCase.nurseVerificationId)}
disabled={!prevCase}
>
{t('ver_prev_case')}
</AppButton>
<AppButton
variant="outlined"
color="inherit"
endIcon="forward"
onClick={() => nextCase && goToCase(nextCase.nurseVerificationId)}
disabled={!nextCase}
>
{t('ver_next_case')}
</AppButton>
</Stack>
}
/>
{isLoading ? (
<Stack sx={{ gap: 2 }}>
@@ -408,24 +468,15 @@ function CredentialDialog({
value={issuingAuthority}
onChange={(e) => setIssuingAuthority(e.target.value)}
/>
<TextField
<JalaliDateField fullWidth label={t('ver_issued_at')} value={issuedAt || null} onChange={setIssuedAt} />
<JalaliDateField
fullWidth
type="date"
label={t('ver_issued_at')}
value={issuedAt}
onChange={(e) => setIssuedAt(e.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
/>
<TextField
fullWidth
type="date"
label={t('ver_expires_at')}
value={expiresAt}
onChange={(e) => setExpiresAt(e.target.value)}
value={expiresAt || null}
onChange={setExpiresAt}
required={expiryRequired}
error={expiryMissing}
helperText={expiryMissing ? t('ver_expiry_required') : undefined}
slotProps={{ inputLabel: { shrink: true } }}
/>
</Stack>
</DialogContent>
@@ -1,9 +1,9 @@
'use client';
import { useState } from 'react';
import { Suspense, useEffect, useRef } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { Box, Chip, MenuItem, Skeleton, Stack, TextField } from '@mui/material';
import { AppIcon, StatusChip } from '@/components';
import { Box, Chip, Skeleton, Stack, Tab, Tabs, TextField } from '@mui/material';
import { AppButton, AppIcon, AppLoading, StatusChip } from '@/components';
import type { StatusKind } from '@/components';
import {
AdminDataTable,
@@ -14,13 +14,21 @@ import {
} from '@/components/admin';
import type { AdminTableColumn } from '@/components/admin';
import { adminVerificationCasePath } from '@/constants';
import { formatShamsiDate } from '@/utils';
import { formatNumber, formatRelativeTime, formatShamsiDate } from '@/utils';
import { useAdminListState } from '@/hooks';
import { useVerificationQueue } from '@/services/verification';
import { ADMIN_QUEUE_PAGE_SIZE } from '@/services/verification/constants';
import type { AdminVerificationQueueItem, VerificationAggregateStatus } from '@/services/verification/types';
import type {
AdminVerificationQueueFilters,
AdminVerificationQueueItem,
VerificationAggregateStatus,
} from '@/services/verification/types';
import { EMPTY_QUEUE_FILTERS, parseQueueFilters, queueCaseHref, serializeQueueFilters } from './queueFilters';
/** The queue status filter — a subset of the aggregate statuses the desk works (default all). */
type QueueStatusFilter = '' | 'pending' | 'in_review';
/** SLA thresholds for the waiting-time column's color (display-only client signal — never a server rule). */
const WAITING_TIME_WARNING_HOURS = 48;
const WAITING_TIME_ALARM_HOURS = 96;
const MS_PER_HOUR = 60 * 60 * 1000;
/** Aggregate status → chip kind. `in_review` reads as informational; a rejected/suspended case shows red. */
const AGG_STATUS_KIND: Record<VerificationAggregateStatus, StatusKind> = {
@@ -32,24 +40,66 @@ const AGG_STATUS_KIND: Record<VerificationAggregateStatus, StatusKind> = {
suspended: 'rejected',
};
export default function AdminVerificationQueuePage() {
return (
<Suspense fallback={<AppLoading />}>
<AdminVerificationQueueScreen />
</Suspense>
);
}
/**
* Verification review queue (b6 `AdminVerificationsController`) the trust desk's worklist, one row per
* nurse folded from the per-step endpoint. Filter by status (all / pending / in_review); each row surfaces
* the step progress, the next pending step, when it was submitted, and a warning when a credential is
* expiring. A row opens its case. The filter + page are the query key, so switching them reuses cached
* pages; a decision on a case invalidates the queue so the desk re-renders without a manual refresh.
* nurse folded from the per-step endpoint. Status tabs (all / pending / in_review, badge-counted when the
* server serves `counts` REQ-062) replace the old lone select; a name/phone search follows the same
* draft-vs-applied Apply/Clear pattern as `admin/tickets`/`admin/audit`. Filters + page are URL-synced via
* `useAdminListState`, so a queue row carries them forward into the case URL (`queueCaseHref`) the case
* page re-derives the same query key to reuse this cache for next/prev case navigation.
*/
export default function AdminVerificationQueuePage() {
function AdminVerificationQueueScreen() {
const t = useTranslations('admin');
const locale = useLocale();
const router = useRouter();
const [status, setStatus] = useState<QueueStatusFilter>('');
const [page, setPage] = useState(1);
const state = useAdminListState<AdminVerificationQueueFilters>({
parse: parseQueueFilters,
serialize: serializeQueueFilters,
empty: EMPTY_QUEUE_FILTERS,
});
const queue = useVerificationQueue({ status: status || undefined }, page);
// Tabs commit immediately (they're discrete, not free text) — `state.apply()` closes over the CURRENT
// render's `draft`, so calling it synchronously right after `setDraft` would still see the stale value.
// Deferring the commit to the render that follows the draft update reads the fresh `draft` correctly.
const applyPendingRef = useRef(false);
useEffect(() => {
if (applyPendingRef.current) {
applyPendingRef.current = false;
state.apply();
}
});
const selectStatus = (next: '' | 'pending' | 'in_review') => {
state.setDraft((d) => ({ ...d, status: next || undefined }));
applyPendingRef.current = true;
};
const queue = useVerificationQueue(state.applied, state.page);
const items = queue.data?.items ?? [];
const pageCount = Math.max(1, Math.ceil((queue.data?.total ?? 0) / ADMIN_QUEUE_PAGE_SIZE));
const total = queue.data?.total ?? 0;
const counts = queue.data?.counts;
const pageCount = Math.max(1, Math.ceil(total / ADMIN_QUEUE_PAGE_SIZE));
const tabLabel = (base: string, count: number | undefined): string =>
count === undefined ? base : `${base} (${formatNumber(count, locale)})`;
const footerText =
total > 0
? t('showing_range', {
from: (state.page - 1) * ADMIN_QUEUE_PAGE_SIZE + 1,
to: Math.min(state.page * ADMIN_QUEUE_PAGE_SIZE, total),
total,
})
: undefined;
const columns: AdminTableColumn<AdminVerificationQueueItem>[] = [
{
@@ -91,31 +141,56 @@ export default function AdminVerificationQueuePage() {
header: t('ver_col_submitted'),
render: (item) => (item.submittedAt ? formatShamsiDate(item.submittedAt, locale) : '—'),
},
{
key: 'waiting',
header: t('ver_col_waiting'),
render: (item) => {
if (!item.submittedAt) return '—';
const hours = (Date.now() - new Date(item.submittedAt).getTime()) / MS_PER_HOUR;
const color =
hours >= WAITING_TIME_ALARM_HOURS
? 'var(--bal-error)'
: hours >= WAITING_TIME_WARNING_HOURS
? 'var(--bal-warning)'
: undefined;
return (
<Box component="span" sx={color ? { color } : undefined}>
{formatRelativeTime(item.submittedAt, locale, formatShamsiDate)}
</Box>
);
},
},
];
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader
title={t('ver_title')}
subtitle={t('ver_subtitle')}
actions={
<TextField
select
size="small"
label={t('ver_col_status')}
value={status}
onChange={(e) => {
setStatus(e.target.value as QueueStatusFilter);
setPage(1);
}}
sx={{ minWidth: 160 }}
>
<MenuItem value="">{t('filter_all')}</MenuItem>
<MenuItem value="pending">{t('agg_pending')}</MenuItem>
<MenuItem value="in_review">{t('agg_in_review')}</MenuItem>
</TextField>
}
/>
<AdminPageHeader title={t('ver_title')} subtitle={t('ver_subtitle')} />
<Tabs value={state.draft.status ?? ''} onChange={(_event, value: '' | 'pending' | 'in_review') => selectStatus(value)}>
<Tab value="" data-tab="all" label={t('filter_all')} />
<Tab value="pending" data-tab="pending" label={tabLabel(t('agg_pending'), counts?.pending)} />
<Tab value="in_review" data-tab="in_review" label={tabLabel(t('agg_in_review'), counts?.in_review)} />
</Tabs>
<Stack
direction="row"
sx={{ gap: 1.5, flexWrap: 'wrap', alignItems: 'center', p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
>
<TextField
size="small"
label={t('ver_search_label')}
placeholder={t('ver_search_ph')}
value={state.draft.search ?? ''}
onChange={(e) => state.setDraft((d) => ({ ...d, search: e.target.value || undefined }))}
sx={{ minWidth: 240 }}
/>
<AppButton variant="contained" color="primary" onClick={state.apply}>
{t('apply')}
</AppButton>
<AppButton variant="text" color="inherit" onClick={state.clear}>
{t('clear')}
</AppButton>
</Stack>
{queue.isLoading ? (
<Stack sx={{ gap: 1 }}>{[0, 1, 2, 3].map((k) => <Skeleton key={k} variant="rounded" height={56} />)}</Stack>
@@ -129,18 +204,23 @@ export default function AdminVerificationQueuePage() {
rows={items}
getRowKey={(item) => item.nurseVerificationId}
ariaLabel={t('ver_title')}
onRowClick={(item) => router.push(`/${locale}${adminVerificationCasePath(item.nurseVerificationId)}`)}
footer={footerText}
onRowClick={(item) =>
router.push(
queueCaseHref(locale, adminVerificationCasePath(item.nurseVerificationId), state.applied, state.page),
)
}
/>
)}
<AdminPager
page={page}
page={state.page}
pageCount={pageCount}
onPrev={() => setPage((p) => Math.max(1, p - 1))}
onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
onPrev={() => state.goToPage(Math.max(1, state.page - 1))}
onNext={() => state.goToPage(Math.min(pageCount, state.page + 1))}
prevLabel={t('prev_page')}
nextLabel={t('next_page')}
indicator={t('page_indicator', { page })}
indicator={t('page_indicator', { page: state.page, total: pageCount })}
/>
</Box>
);
@@ -0,0 +1,41 @@
import type { AdminVerificationQueueFilters } from '@/services/verification/types';
/**
* The queue's URL-synced filter shape shared between the list (`page.tsx`, via `useAdminListState`)
* and the case detail (`[nurseId]/page.tsx`) so a case page can **re-derive the same query key** the
* list primed (`status`/`search`/`page` `useVerificationQueue`) and reuse its cached page for
* next/prev case navigation, with no extra URL param and no duplicate fetch in the common flow.
*/
export const EMPTY_QUEUE_FILTERS: AdminVerificationQueueFilters = {};
/** The URL query-param name for the name/phone search (REQ-062's proposed `q`). */
const SEARCH_PARAM = 'q';
export function parseQueueFilters(params: URLSearchParams): AdminVerificationQueueFilters {
const status = params.get('status');
return {
status: status === 'pending' || status === 'in_review' ? status : undefined,
search: params.get(SEARCH_PARAM) ?? undefined,
};
}
export function serializeQueueFilters(filters: AdminVerificationQueueFilters): Record<string, string> {
const record: Record<string, string> = {};
if (filters.status) record.status = filters.status;
if (filters.search) record[SEARCH_PARAM] = filters.search;
return record;
}
/** A case URL carrying the queue's current filters + page, so next/prev navigation (and a browser
* back/refresh) keeps resolving against the same cached queue page instead of an unfiltered default. */
export function queueCaseHref(
locale: string,
casePath: string,
filters: AdminVerificationQueueFilters,
page: number,
): string {
const params = new URLSearchParams(serializeQueueFilters(filters));
if (page > 1) params.set('page', String(page));
const qs = params.toString();
return `/${locale}${casePath}${qs ? `?${qs}` : ''}`;
}
@@ -283,7 +283,9 @@ function NotificationsEntryRow() {
</Stack>
<Typography
variant="body2"
sx={{ color: unread > 0 ? 'var(--bal-secondary)' : 'text.secondary', fontWeight: unread > 0 ? 700 : 400 }}
// --bal-secondary-dark, not --bal-secondary: the plain terracotta fails AA contrast for
// small text on a light surface (frontend-designer skill §2) — this is body copy, not an icon.
sx={{ color: unread > 0 ? 'var(--bal-secondary-dark)' : 'text.secondary', fontWeight: unread > 0 ? 700 : 400 }}
>
{unread > 0 ? t('notifications_entry_unread', { count: unread }) : t('notifications_entry_empty')}
</Typography>
@@ -5,7 +5,7 @@ import { useRouter } from 'next/navigation';
import { Box, ButtonBase, Collapse, Paper, Skeleton, Stack, Tab, Tabs, Typography } from '@mui/material';
import { AppIcon, EarningsBalanceHeader, EarningsRow, EmptyState, ErrorState, Money, Pager, SurfaceCard } from '@/components';
import { CONTENT_MAX_WIDTH } from '@/components/config';
import { nurseBookingDetailPath, nursePayoutDetailPath } from '@/constants';
import { DISPUTE_WINDOW_HOURS, nurseBookingDetailPath, nursePayoutDetailPath } from '@/constants';
import { formatShamsiDate } from '@/utils';
import { PAYOUTS_PAGE_SIZE } from '@/services/payouts/constants';
import { EARNINGS_STATES, type EarningsState } from '@/services/payouts/types';
@@ -187,7 +187,7 @@ function ExplainerCard({ open, onToggle }: { open: boolean; onToggle: () => void
<Stack component="ul" sx={{ gap: 0.75, mt: 1.5, mb: 0, pl: 2.5 }}>
{points.map((key) => (
<Typography key={key} component="li" variant="body2" sx={{ color: 'text.secondary' }}>
{t(key)}
{key === 'explainer_point_2' ? t(key, { hours: DISPUTE_WINDOW_HOURS }) : t(key)}
</Typography>
))}
</Stack>
@@ -0,0 +1,115 @@
'use client';
import type { ReactNode } from 'react';
import { useParams } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { PageHeader, StatusChip, StatusTimeline } from '@/components';
import type { StatusKind, TimelineNode, TimelineNodeState } from '@/components';
import { AdminEmptyState, AdminErrorState } from '@/components/admin';
import { CONTENT_MAX_WIDTH } from '@/components/config';
import { ROUTES } from '@/constants';
import { useAdminBackToList } from '@/hooks';
import { formatShamsiDate, formatShamsiDateTime } from '@/utils';
import { useMySponsoredBookingDetail } from '@/services/partnerCenter';
/** Same tone map as the bookings list — one status vocabulary across the portal. */
const PARTNER_BOOKING_STATUS_KIND: Record<string, StatusKind> = {
pending_payment: 'pending',
confirmed: 'info',
in_progress: 'active',
completed: 'verified',
disputed: 'rejected',
closed: 'neutral',
cancelled: 'rejected',
};
/** A booking's lifecycle is done once it reaches one of these — the timeline's last node reads as "current" otherwise. */
const TERMINAL_STATUSES = new Set(['completed', 'closed', 'cancelled']);
/**
* Partner portal scoped sponsored-booking detail (f15, REQ-064). Strictly read-only and bounded to what
* the portal is allowed to see: the patient's display name, the scheduled date, the current status, and a
* server-truth status timeline **no clinical content, no address, no money**. A bookings-list row deep-
* links here; tenancy (only bookings the signed-in center legally covers) is server-enforced.
*/
export default function PartnerBookingDetailPage() {
const t = useTranslations('partner');
const ta = useTranslations('admin');
const locale = useLocale();
const params = useParams<{ id: string }>();
const bookingId = Number(params?.id);
const goBack = useAdminBackToList(`/${locale}${ROUTES.PARTNER_BOOKINGS}`);
const detail = useMySponsoredBookingDetail(Number.isFinite(bookingId) ? bookingId : undefined);
const timelineNodes: TimelineNode[] =
detail.data?.timeline.map((entry, index, all) => {
const isLast = index === all.length - 1;
const state: TimelineNodeState = isLast && !TERMINAL_STATUSES.has(detail.data!.status) ? 'current' : 'completed';
return {
key: `${entry.status}-${index}`,
label: t(`bstatus_${entry.status}`),
timestamp: formatShamsiDateTime(entry.occurredAt, locale),
state,
};
}) ?? [];
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}>
<PageHeader title={t('booking_detail_title')} onBack={goBack} backLabel={t('booking_detail_back')} />
{detail.isLoading ? (
<Stack sx={{ gap: 2 }}>
<Skeleton variant="rounded" height={120} />
<Skeleton variant="rounded" height={200} />
</Stack>
) : detail.isError ? (
<AdminErrorState message={ta('error_generic')} retryLabel={ta('retry')} onRetry={() => detail.refetch()} />
) : !detail.data ? (
<AdminEmptyState icon="bookings" title={t('booking_detail_not_found')} />
) : (
<>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1.5 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('bookings_col_id')} #{detail.data.bookingId}
</Typography>
<StatusChip
status={PARTNER_BOOKING_STATUS_KIND[detail.data.status] ?? 'neutral'}
label={t(`bstatus_${detail.data.status}`)}
/>
</Stack>
<MetaLine label={t('booking_detail_patient')}>{detail.data.patientName}</MetaLine>
<MetaLine label={t('booking_detail_scheduled_date')}>
{formatShamsiDate(detail.data.scheduledDate, locale)}
</MetaLine>
</Stack>
</Paper>
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('booking_detail_timeline_title')}
</Typography>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<StatusTimeline nodes={timelineNodes} />
</Paper>
</Stack>
</>
)}
</Box>
);
}
function MetaLine({ label, children }: { label: string; children: ReactNode }) {
return (
<Stack direction="row" sx={{ gap: 1, justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{label}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{children}
</Typography>
</Stack>
);
}
@@ -1,39 +1,77 @@
'use client';
import { useState } from 'react';
import { Suspense } from 'react';
import { useRouter } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { Box, Chip, MenuItem, Skeleton, Stack, TextField } from '@mui/material';
import { Box, MenuItem, Skeleton, Stack, TextField } from '@mui/material';
import { AppButton, AppLoading, StatusChip } from '@/components';
import type { StatusKind } from '@/components';
import { AdminDataTable, AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager } from '@/components/admin';
import type { AdminTableColumn } from '@/components/admin';
import { partnerBookingDetailPath } from '@/constants';
import { useAdminListState } from '@/hooks';
import { formatShamsiDate } from '@/utils';
import type { SponsoredBooking } from '@/services/partnerCenter/types';
import type { SponsoredBooking, SponsoredBookingFilters } from '@/services/partnerCenter/types';
import { PARTNER_PAGE_SIZE } from '@/services/partnerCenter/constants';
import { useMySponsoredBookings } from '@/services/partnerCenter';
/**
* Booking lifecycle codes the center may legally cover the read-only filter options. Stable string codes
* (the wire enum); labels are the codes themselves since `status` is a free-form summary field, not a
* localized enum in the portal contract.
*/
/** Booking lifecycle codes the center may legally cover — the read-only filter options (stable wire codes). */
const BOOKING_STATUS_OPTIONS = ['pending_payment', 'confirmed', 'in_progress', 'completed', 'disputed', 'closed', 'cancelled'] as const;
/** Booking status → chip tone. Progression reads left-to-right calmer→done; disputed/cancelled are the alarm tier. */
const PARTNER_BOOKING_STATUS_KIND: Record<string, StatusKind> = {
pending_payment: 'pending',
confirmed: 'info',
in_progress: 'active',
completed: 'verified',
disputed: 'rejected',
closed: 'neutral',
cancelled: 'rejected',
};
const EMPTY_FILTERS: SponsoredBookingFilters = {};
function parseFilters(params: URLSearchParams): SponsoredBookingFilters {
return { status: params.get('status') ?? undefined };
}
function serializeFilters(filters: SponsoredBookingFilters): Record<string, string> {
return filters.status ? { status: filters.status } : {};
}
/**
* Partner portal sponsored bookings (f15). The read-only list of bookings the signed-in center legally
* covers (portal scope; server-enforced tenancy). An optional status filter (filter+page keyed cache) and a
* dense table of id/patient/date/status. No PII beyond the summary; the center never sees clinical detail.
* covers (portal scope; server-enforced tenancy). A status filter (applied+page mirrored into the URL via
* `useAdminListState`, ui-phase-11) and a dense table of id/patient/date/status every status label is
* translated (`bstatus_*`), never the raw wire code. Rows link to a scoped read-only detail (REQ-064).
* `useSearchParams` (inside `useAdminListState`) needs a `<Suspense>` boundary.
*/
export default function PartnerBookingsPage() {
return (
<Suspense fallback={<AppLoading />}>
<PartnerBookingsScreen />
</Suspense>
);
}
function PartnerBookingsScreen() {
const t = useTranslations('partner');
const ta = useTranslations('admin');
const locale = useLocale();
const router = useRouter();
const [status, setStatus] = useState<string>('');
const [page, setPage] = useState(1);
const { draft, setDraft, applied, page, apply, clear, goToPage } = useAdminListState<SponsoredBookingFilters>({
parse: parseFilters,
serialize: serializeFilters,
empty: EMPTY_FILTERS,
});
const filters = { status: status || undefined };
const bookings = useMySponsoredBookings(filters, page);
const bookings = useMySponsoredBookings(applied, page);
const items = bookings.data?.items ?? [];
const pageCount = Math.max(1, Math.ceil((bookings.data?.total ?? 0) / PARTNER_PAGE_SIZE));
const total = bookings.data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / PARTNER_PAGE_SIZE));
const from = items.length === 0 ? 0 : (page - 1) * PARTNER_PAGE_SIZE + 1;
const to = items.length === 0 ? 0 : from + items.length - 1;
const columns: AdminTableColumn<SponsoredBooking>[] = [
{ key: 'id', header: t('bookings_col_id'), render: (b) => `#${b.bookingId}` },
@@ -42,35 +80,42 @@ export default function PartnerBookingsPage() {
{
key: 'status',
header: t('bookings_col_status'),
render: (b) => <Chip size="small" variant="outlined" label={b.status} />,
render: (b) => (
<StatusChip status={PARTNER_BOOKING_STATUS_KIND[b.status] ?? 'neutral'} label={t(`bstatus_${b.status}`)} />
),
},
];
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader
title={t('bookings_title')}
actions={
<TextField
select
size="small"
label={t('bookings_col_status')}
value={status}
onChange={(e) => {
setStatus(e.target.value);
setPage(1);
}}
sx={{ minWidth: 180 }}
>
<MenuItem value="">{ta('filter_all')}</MenuItem>
{BOOKING_STATUS_OPTIONS.map((s) => (
<MenuItem key={s} value={s}>
{s}
</MenuItem>
))}
</TextField>
}
/>
<AdminPageHeader title={t('bookings_title')} />
<Stack
direction="row"
sx={{ gap: 1.5, flexWrap: 'wrap', alignItems: 'center', p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
>
<TextField
select
size="small"
label={t('bookings_col_status')}
value={draft.status ?? ''}
onChange={(e) => setDraft({ status: e.target.value || undefined })}
sx={{ minWidth: 180 }}
>
<MenuItem value="">{ta('filter_all')}</MenuItem>
{BOOKING_STATUS_OPTIONS.map((s) => (
<MenuItem key={s} value={s}>
{t(`bstatus_${s}`)}
</MenuItem>
))}
</TextField>
<AppButton variant="contained" color="primary" onClick={apply}>
{ta('apply')}
</AppButton>
<AppButton variant="text" color="inherit" onClick={clear}>
{ta('clear')}
</AppButton>
</Stack>
{bookings.isLoading ? (
<Stack sx={{ gap: 2 }}>
@@ -88,17 +133,19 @@ export default function PartnerBookingsPage() {
rows={items}
getRowKey={(b) => b.bookingId}
ariaLabel={t('bookings_title')}
onRowClick={(b) => router.push(`/${locale}${partnerBookingDetailPath(b.bookingId)}`)}
footer={ta('showing_range', { from, to, total })}
/>
)}
<AdminPager
page={page}
pageCount={pageCount}
onPrev={() => setPage((p) => Math.max(1, p - 1))}
onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
onPrev={() => goToPage(Math.max(1, page - 1))}
onNext={() => goToPage(Math.min(pageCount, page + 1))}
prevLabel={ta('prev_page')}
nextLabel={ta('next_page')}
indicator={ta('page_indicator', { page })}
indicator={ta('page_indicator', { page, total: pageCount })}
/>
</Box>
);
@@ -1,7 +1,8 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { Suspense } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Alert, Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, AppLoading } from '@/components';
import {
AdminEmptyState,
AdminErrorState,
@@ -9,21 +10,86 @@ import {
AdminPager,
PartnerSettlementRow,
} from '@/components/admin';
import { useAdminListState } from '@/hooks';
import { formatIrrToToman, formatShamsiDate, toCsv } from '@/utils';
import { PARTNER_PAGE_SIZE } from '@/services/partnerCenter/constants';
import type { CenterInvoice } from '@/services/partnerCenter/types';
import { useMyPartnerCenter, useMySettlement } from '@/services/partnerCenter';
const NO_FILTERS: Record<string, never> = {};
/**
* Partner portal settlement & invoices (f15). **Merchant-of-record drives the whole view**: a non-MoR
* center settles through Balinyaar and issues no commission invoices, so it sees only the
* `settlement_not_mor` state (no table). A MoR center sees its per-booking commission invoices
* (`PartnerSettlementRow`, VAT-on-commission-only breakdown) with the masked settlement IBAN and a
* signed-URL PDF that opens in a new tab. Read-only.
* signed-URL PDF that opens in a new tab, plus a client-side CSV export of the currently loaded page. Read-
* only. `useSearchParams` (inside `useAdminListState`) needs a `<Suspense>` boundary.
*/
export default function PartnerSettlementPage() {
return (
<Suspense fallback={<AppLoading />}>
<PartnerSettlementScreen />
</Suspense>
);
}
/** Already-translated CSV column headers, keyed to the row shape `exportInvoicesCsv` builds below. */
interface InvoiceCsvHeaders {
invoiceNumber: string;
bookingId: string;
gross: string;
commission: string;
vat: string;
total: string;
issuedAt: string;
}
/** Builds + triggers the download of a CSV of the currently loaded invoices page (no new fetch). */
function exportInvoicesCsv(invoices: CenterInvoice[], locale: string, headers: InvoiceCsvHeaders): void {
const csv = toCsv(
invoices.map((inv) => ({
invoiceNumber: inv.invoiceNumber,
bookingId: inv.bookingId,
gross: formatIrrToToman(inv.grossIrr, locale),
commission: formatIrrToToman(inv.platformCommissionIrr, locale),
vat: formatIrrToToman(inv.vatIrr, locale),
total: formatIrrToToman(inv.totalIrr, locale),
issuedAt: formatShamsiDate(inv.issuedAt, locale),
})),
[
{ key: 'invoiceNumber', label: headers.invoiceNumber },
{ key: 'bookingId', label: headers.bookingId },
{ key: 'gross', label: headers.gross },
{ key: 'commission', label: headers.commission },
{ key: 'vat', label: headers.vat },
{ key: 'total', label: headers.total },
{ key: 'issuedAt', label: headers.issuedAt },
],
);
// UTF-8 BOM so Excel renders Persian text correctly.
const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'settlement.csv';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
function PartnerSettlementScreen() {
const t = useTranslations('partner');
const ta = useTranslations('admin');
const locale = useLocale();
const center = useMyPartnerCenter();
const [page, setPage] = useState(1);
// Page-only URL sync (no filters on this list) — survives refresh/back like the other portal worklists.
const { page, goToPage } = useAdminListState<Record<string, never>>({
parse: () => NO_FILTERS,
serialize: () => ({}),
empty: NO_FILTERS,
});
// Called unconditionally (rules of hooks); only rendered for a merchant-of-record center.
const settlement = useMySettlement(page);
@@ -58,7 +124,30 @@ export default function PartnerSettlementPage() {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader title={t('settlement_title')} />
<AdminPageHeader
title={t('settlement_title')}
actions={
<AppButton
variant="outlined"
color="primary"
startIcon="download"
disabled={settlement.isLoading || invoices.length === 0}
onClick={() =>
exportInvoicesCsv(invoices, locale, {
invoiceNumber: t('csv_col_invoice_number'),
bookingId: t('csv_col_booking_id'),
gross: t('csv_col_gross'),
commission: t('csv_col_commission'),
vat: t('csv_col_vat'),
total: t('csv_col_total'),
issuedAt: t('csv_col_issued_at'),
})
}
>
{t('settlement_export_csv')}
</AppButton>
}
/>
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
<Stack direction="row" sx={{ gap: 2, alignItems: 'baseline', justifyContent: 'space-between', flexWrap: 'wrap' }}>
@@ -98,11 +187,11 @@ export default function PartnerSettlementPage() {
<AdminPager
page={page}
pageCount={pageCount}
onPrev={() => setPage((p) => Math.max(1, p - 1))}
onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
onPrev={() => goToPage(Math.max(1, page - 1))}
onNext={() => goToPage(Math.min(pageCount, page + 1))}
prevLabel={ta('prev_page')}
nextLabel={ta('next_page')}
indicator={ta('page_indicator', { page })}
indicator={ta('page_indicator', { page, total: pageCount })}
/>
</Box>
);
@@ -0,0 +1,182 @@
import { getLocale, getTranslations } from 'next-intl/server';
import { Box, Container, Divider, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, AppLink, CategoryTile, EscrowNotice, SurfaceCard } from '@/components';
import BrandMark from '@/components/auth/BrandMark';
import { ROUTES } from '@/constants';
const CATEGORY_ICON_KEYS = ['elderly', 'post_surgery', 'infant', 'chronic', 'companionship'] as const;
const HOW_IT_WORKS_STEPS = [
{ icon: 'search', titleKey: 'step1_title', bodyKey: 'step1_body' },
{ icon: 'lock', titleKey: 'step2_title', bodyKey: null },
{ icon: 'verified', titleKey: 'step3_title', bodyKey: 'step3_body' },
] as const;
const TRUST_ROWS = [
{ icon: 'identity', titleKey: 'trust_identity_title', bodyKey: 'trust_identity_body' },
{ icon: 'license', titleKey: 'trust_license_title', bodyKey: 'trust_license_body' },
{ icon: 'verification', titleKey: 'trust_ino_title', bodyKey: 'trust_ino_body' },
{ icon: 'bank', titleKey: 'trust_bank_title', bodyKey: 'trust_bank_body' },
] as const;
/**
* The public front door (ui-phase-13) a marketing landing served AT `/` for a guest visitor via
* a middleware rewrite (see `middleware.ts`; the URL/canonical stays `/`, never `/welcome`).
* Deliberately a Server Component with no query hooks anywhere in the tree: first paint is the
* whole product here, so the page must never wait on an API call (§3.5 RSC-first, zero
* client-side data fetching). The only client leaves it composes (`CategoryTile`, `EscrowNotice`,
* `BrandMark`, `AppLink`/`AppButton`) are static-content components that fetch nothing.
* @component WelcomeScreen
*/
export default async function WelcomeScreen() {
const locale = await getLocale();
const t = await getTranslations('welcome');
const tLegal = await getTranslations('legal');
const loginHref = `/${locale}${ROUTES.LOGIN}`;
const nurseLoginHref = `/${locale}${ROUTES.LOGIN}?role=nurse`;
const termsHref = `/${locale}${ROUTES.TERMS}`;
const privacyHref = `/${locale}${ROUTES.PRIVACY}`;
return (
<Container maxWidth="sm" sx={{ py: { xs: 4, md: 6 } }}>
<Stack sx={{ gap: { xs: 6, md: 8 } }}>
{/* Hero */}
<Stack component="header" sx={{ alignItems: 'center', textAlign: 'center', gap: 2 }}>
<BrandMark withTagline />
<Typography variant="body1" sx={{ color: 'text.secondary', maxWidth: 420 }}>
{t('hero_subtitle')}
</Typography>
<AppButton to={loginHref} color="primary" variant="contained" size="large" sx={{ mt: 1, px: 4 }}>
{t('hero_cta')}
</AppButton>
</Stack>
{/* Category grid — static i18n content, not the live catalog (§3.1 tier a+b decision) */}
<Stack component="section" sx={{ gap: 2 }}>
<Typography variant="h6" component="h2" sx={{ fontWeight: 700 }}>
{t('categories_title')}
</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'repeat(2, 1fr)', sm: 'repeat(3, 1fr)' }, gap: 1.5 }}>
{CATEGORY_ICON_KEYS.map((key) => (
<CategoryTile key={key} label={t(`category_${key}`)} iconKey={key} href={loginHref} />
))}
</Box>
</Stack>
{/* How it works */}
<Stack component="section" sx={{ gap: 2.5 }}>
<Typography variant="h6" component="h2" sx={{ fontWeight: 700 }}>
{t('how_it_works_title')}
</Typography>
<Stack sx={{ gap: 2.5 }}>
{HOW_IT_WORKS_STEPS.map((step, index) => (
<Stack key={step.titleKey} direction="row" sx={{ gap: 2, alignItems: 'flex-start' }}>
<Box
sx={{
flexShrink: 0,
width: 40,
height: 40,
borderRadius: '50%',
bgcolor: 'var(--bal-primary-soft)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<AppIcon icon={step.icon} size={22} color="var(--bal-primary)" aria-hidden="true" />
</Box>
<Stack sx={{ gap: 0.75, flex: 1, pt: 0.5 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{index + 1}. {t(step.titleKey)}
</Typography>
{step.bodyKey ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t(step.bodyKey)}
</Typography>
) : (
// Step 2 (escrow) reuses the product-mandated verbatim EscrowNotice copy —
// never a paraphrase (§3.2).
<EscrowNotice />
)}
</Stack>
</Stack>
))}
</Stack>
</Stack>
{/* Trust / verification explainer */}
<Stack component="section" sx={{ gap: 2 }}>
<Typography variant="h6" component="h2" sx={{ fontWeight: 700 }}>
{t('trust_title')}
</Typography>
<SurfaceCard padding="lg">
<Stack sx={{ gap: 2.5 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('trust_intro')}
</Typography>
{TRUST_ROWS.map((row) => (
<Stack key={row.titleKey} direction="row" sx={{ gap: 1.5, alignItems: 'flex-start' }}>
<AppIcon
icon={row.icon}
size={20}
color="var(--bal-trust)"
style={{ flexShrink: 0, marginTop: 2 }}
aria-hidden="true"
/>
<Stack sx={{ gap: 0.25 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t(row.titleKey)}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t(row.bodyKey)}
</Typography>
</Stack>
</Stack>
))}
</Stack>
</SurfaceCard>
</Stack>
{/* Nurse recruitment */}
<SurfaceCard padding="lg" sx={{ bgcolor: 'var(--bal-primary-soft)', borderColor: 'var(--bal-primary)' }}>
<Stack sx={{ gap: 1.5, alignItems: 'flex-start' }}>
<Typography variant="h6" component="h2" sx={{ fontWeight: 700 }}>
{t('nurse_cta_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('nurse_cta_body')}
</Typography>
<AppButton to={nurseLoginHref} color="primary" variant="outlined">
{t('nurse_cta_button')}
</AppButton>
</Stack>
</SurfaceCard>
{/* Footer */}
<Stack component="footer" sx={{ gap: 2 }}>
<Divider />
<Stack direction="row" sx={{ gap: 3, flexWrap: 'wrap' }}>
<AppLink to={termsHref} color="text.secondary" underline="hover">
{tLegal('terms_title')}
</AppLink>
<AppLink to={privacyHref} color="text.secondary" underline="hover">
{tLegal('privacy_title')}
</AppLink>
</Stack>
<Stack sx={{ gap: 0.5 }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: 'text.secondary' }}>
{t('footer_contact_title')}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('footer_contact_body')}
</Typography>
</Stack>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('footer_copyright', { year: new Date().getFullYear() })}
</Typography>
</Stack>
</Stack>
</Container>
);
}
@@ -0,0 +1,85 @@
import { ImageResponse } from 'next/og';
import { BRAND } from '@/theme/colors';
export const alt = 'Balinyaar — trust-first home nursing marketplace';
export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';
/**
* The landing's og:image (ui-phase-13) a static composition built from the phase-0 brand mark
* (the rounded-square lockup + terracotta accent dot from `public/img/logo.svg`, reproduced in
* plain divs since Satori/`ImageResponse` renders a constrained CSS subset, not arbitrary SVG) on
* the brand teal/cream. Kept Latin-only (no Persian glyphs) `ImageResponse`'s bundled fallback
* font only covers Latin; rendering the Persian tagline here would need an embedded Mikhak font
* buffer, deliberately left for a follow-up once that's verified in a running server.
*/
export default function Image() {
return new ImageResponse(
(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 40,
backgroundColor: BRAND.teal,
}}
>
<div
style={{
width: 168,
height: 168,
borderRadius: 42,
backgroundColor: BRAND.tealDeep,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
}}
>
<div
style={{
width: 26,
height: 92,
borderRadius: 13,
backgroundColor: BRAND.creamSoft,
position: 'absolute',
left: 54,
}}
/>
<div
style={{
width: 62,
height: 62,
borderRadius: '50%',
border: `13px solid ${BRAND.creamSoft}`,
position: 'absolute',
right: 40,
}}
/>
<div
style={{
width: 22,
height: 22,
borderRadius: '50%',
backgroundColor: BRAND.terracotta,
position: 'absolute',
right: 34,
top: 34,
}}
/>
</div>
<div style={{ display: 'flex', fontSize: 76, fontWeight: 700, color: BRAND.creamSoft }}>
Balinyaar
</div>
<div style={{ display: 'flex', fontSize: 32, color: BRAND.tealOnDarkLight }}>
Trust-first home nursing
</div>
</div>
),
{ ...size },
);
}
@@ -0,0 +1,30 @@
import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import WelcomeScreen from './WelcomeScreen';
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'welcome' });
const tCommon = await getTranslations({ locale, namespace: 'common' });
// The middleware REWRITES an unauthenticated '/' to this route (never a redirect), so this is
// the content served at both '/{locale}' and '/{locale}/welcome' — canonicalize on the former,
// the URL a guest and every crawler actually sees.
const canonicalPath = `/${locale}`;
return {
description: t('meta_description'),
alternates: { canonical: canonicalPath },
openGraph: {
title: tCommon('brand'),
description: t('meta_description'),
type: 'website',
locale: locale === 'fa' ? 'fa_IR' : 'en_US',
url: canonicalPath,
},
};
}
export default function Page() {
return <WelcomeScreen />;
}
+4
View File
@@ -12,6 +12,7 @@ import { BRAND } from '@/theme/colors';
import { NotistackProvider } from '@/lib/toast';
import { QueryProvider } from '@/lib/query/QueryProvider';
import { routing } from '@/i18n/routing';
import { SITE_URL } from '@/config';
import '../globals.css';
import '@/theme/tokens.css';
@@ -80,6 +81,9 @@ export async function generateMetadata({
const t = await getTranslations({ locale: safeLocale, namespace: 'common' });
return {
// Absolute origin for every relative URL in child metadata (OG images, canonicals) — sourced
// from an env constant, never hard-coded, so it's correct per deployment (ui-phase-13).
metadataBase: new URL(SITE_URL),
title: {
template: safeLocale === 'fa' ? '%s | بالین‌یار' : '%s | Balinyaar',
default: safeLocale === 'fa' ? 'بالین‌یار' : 'Balinyaar',
+39
View File
@@ -28,3 +28,42 @@ a {
[dir='rtl'] [data-icon-directional] {
transform: scaleX(-1);
}
/*
* App-wide motion pass (phase 12) one restrained language: a calm 150200ms fade + small slide on
* route-group content and skeletoncontent swaps. `key`-triggered remounts (RouteFadeIn) or a plain
* mount (a skeleton branch replaced by a populated one) both replay this the same way there is
* exactly one animation definition for both cases, never a per-screen bespoke one.
*/
@keyframes bal-fade-in {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
[data-bal-route-fade] {
animation: bal-fade-in var(--bal-motion-base) var(--bal-easing-standard);
}
/*
* The single reduced-motion gate for the whole app (client/CLAUDE.md "Golden rules" CSS over JS for
* theme mechanics). This is the ONE place `prefers-reduced-motion: reduce` is handled every animation
* and transition this phase adds (the route fade above, MUI's own Dialog/Drawer/Menu/Collapse/Fade
* transitions, the admin config-history/ExplainerCard/EscrowExplainer chevron rotations) collapses to
* an effectively-instant appearance. Do not add a second, component-local reduced-motion check anywhere
* else extend this rule instead if a new motion primitive needs the same treatment.
*/
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
+41
View File
@@ -0,0 +1,41 @@
import type { MetadataRoute } from 'next';
import { SITE_URL } from '@/config';
/**
* Every top-level route segment that sits behind the auth middleware, for every actor (customer/
* nurse/admin/partner). Kept in sync by hand with `src/constants/routes.ts` there is no single
* "private roots" export there (only a `PUBLIC_PATHS` allow-list), and deriving one automatically
* would need to walk every `ROUTES.*` entry down to its first segment, which is more machinery
* than this short, reviewable list (ui-phase-13).
*/
const PRIVATE_ROOT_SEGMENTS = [
'select-role',
'onboarding',
'search',
'bookings',
'patients',
'addresses',
'wallet',
'profile',
'support',
'notifications',
'nurse',
'admin',
'partner',
];
/**
* Replaces the starter `public/robots.txt` (two contradictory `User-agent: *` blocks) with a real
* policy: allow the public marketing/auth surface, disallow every private root for every locale.
* @route /robots.txt
*/
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: '*',
allow: '/',
disallow: PRIVATE_ROOT_SEGMENTS.map((segment) => `/*/${segment}`),
},
sitemap: `${SITE_URL}/sitemap.xml`,
};
}
+20
View File
@@ -0,0 +1,20 @@
import type { MetadataRoute } from 'next';
import { routing } from '@/i18n/routing';
import { ROUTES } from '@/constants';
import { SITE_URL } from '@/config';
/** Public, unauthenticated pages only (ui-phase-13) mirrors `PUBLIC_PATHS` plus the locale root
* itself (the guest landing an unauthenticated `/` rewrites to). */
const PUBLIC_SITEMAP_PATHS: string[] = ['', ROUTES.LOGIN, ROUTES.TERMS, ROUTES.PRIVACY];
/** @route /sitemap.xml */
export default function sitemap(): MetadataRoute.Sitemap {
return PUBLIC_SITEMAP_PATHS.map((path) => ({
url: `${SITE_URL}/${routing.defaultLocale}${path}`,
alternates: {
languages: Object.fromEntries(
routing.locales.map((locale) => [locale, `${SITE_URL}/${locale}${path}`]),
),
},
}));
}
@@ -7,6 +7,7 @@ import StatusChip from '@/components/StatusChip';
import AppAlert from '@/components/common/AppAlert';
import AppIcon from '@/components/common/AppIcon';
import RefundEtaBanner from '@/components/RefundEtaBanner';
import { CANCELLATION_LEAD_HOURS } from '@/constants';
import { formatShamsiDate } from '@/utils';
import type { CancellationPolicyPreview } from '@/services/refunds/types';
@@ -50,7 +51,7 @@ const CancellationPolicyDisclosure: FunctionComponent<CancellationPolicyDisclosu
/>
</Stack>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t(`lead_${preview.leadTimeLabel}`)}
{t(`lead_${preview.leadTimeLabel}`, { hours: CANCELLATION_LEAD_HOURS })}
</Typography>
<Typography variant="body2" sx={{ color: 'var(--bal-money-emphasis)', fontWeight: 500 }}>
{t('fee_percent', { percent: feePercent })}
@@ -1,8 +1,17 @@
import { FunctionComponent } from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import mockRouter from 'next-router-mock';
import { ThemeProvider } from '../../theme';
import CategoryTile, { CategoryTileProps } from './CategoryTile';
// CategoryTile renders as an AppLink (not a button) when `href` is given (ui-phase-13's landing
// tiles) — AppLink reads next/navigation's usePathname, so it needs the same local mock AppLink's
// own test file uses.
jest.mock('next/navigation', () => ({
...jest.requireActual('next/navigation'),
usePathname: () => mockRouter.asPath,
}));
const ComponentToTest: FunctionComponent<CategoryTileProps> = (props) => (
<ThemeProvider>
<CategoryTile {...props} />
@@ -36,4 +45,11 @@ describe('<CategoryTile/> component', () => {
render(<ComponentToTest label="Elderly Care" iconKey="elderly" selected />);
expect(screen.getByRole('button')).toHaveAttribute('aria-pressed', 'true');
});
it('renders as a link (not a button) when href is given, for the public landing', () => {
render(<ComponentToTest label="Elderly Care" iconKey="elderly" href="/fa/login" />);
const link = screen.getByRole('link');
expect(link).toHaveAttribute('href', '/fa/login');
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
});
@@ -1,6 +1,7 @@
import { FunctionComponent } from 'react';
import { Box, ButtonBase, Typography } from '@mui/material';
import AppIcon from '../common/AppIcon';
import AppLink from '../common/AppLink';
/**
* Service-category `iconKey`s we render with a dedicated icon. Any other key (or a missing one from
@@ -22,59 +23,71 @@ export interface CategoryTileProps {
onClick?: () => void;
/** Selected state (the nurse builder's category step); the Home grid leaves it unset. */
selected?: boolean;
/**
* Renders the tile as a link to this internal path instead of a click handler the ui-phase-13
* static landing, whose tiles carry intent straight to `/login` rather than firing a search
* action. Mutually exclusive with `onClick`/`selected` (a link tile is never a toggle).
*/
href?: string;
}
/**
* A tappable tile for one service category the customer Home grid (data-driven, one per
* `service_category`) and the nurse builder's category step (with `selected`). Icon in a soft-teal
* disc over the localised label; the whole tile is a button so it's keyboard- and screen-reader-
* accessible. RTL-safe (no directional hard-coding).
* `service_category`), the nurse builder's category step (with `selected`), and the public landing
* (with `href`, linking straight to login). Icon in a soft-teal disc over the localised label; the
* whole tile is a single interactive element (a button, or via `href` the anchor itself
* through `ButtonBase`'s `component` swap, never a button nested inside a link) so it stays
* keyboard- and screen-reader-accessible. RTL-safe (no directional hard-coding).
* @component CategoryTile
*/
const CategoryTile: FunctionComponent<CategoryTileProps> = ({ label, iconKey, onClick, selected = false }) => (
<ButtonBase
focusRipple
onClick={onClick}
aria-pressed={selected}
data-selected={selected}
data-category-icon={resolveIcon(iconKey)}
sx={{
width: '100%',
height: '100%',
minHeight: 116,
p: 2,
borderRadius: 2,
border: '1px solid',
borderColor: selected ? 'var(--bal-primary)' : 'divider',
bgcolor: selected ? 'var(--bal-primary-soft)' : 'background.paper',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 1,
textAlign: 'center',
transition: 'border-color 120ms ease, transform 120ms ease',
'&:hover': { borderColor: 'var(--bal-primary)' },
'&:active': { transform: 'scale(0.98)' },
}}
>
<Box
const CategoryTile: FunctionComponent<CategoryTileProps> = ({ label, iconKey, onClick, selected = false, href }) => {
const interactionProps = href
? { component: AppLink, to: href }
: { onClick, 'aria-pressed': selected, 'data-selected': selected };
return (
<ButtonBase
focusRipple
data-category-icon={resolveIcon(iconKey)}
{...interactionProps}
sx={{
width: 48,
height: 48,
borderRadius: '50%',
bgcolor: 'var(--bal-primary-soft)',
width: '100%',
height: '100%',
minHeight: 116,
p: 2,
borderRadius: 2,
border: '1px solid',
borderColor: selected ? 'var(--bal-primary)' : 'divider',
bgcolor: selected ? 'var(--bal-primary-soft)' : 'background.paper',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 1,
textAlign: 'center',
transition: 'border-color 120ms ease, transform 120ms ease',
'&:hover': { borderColor: 'var(--bal-primary)' },
'&:active': { transform: 'scale(0.98)' },
}}
>
<AppIcon icon={resolveIcon(iconKey)} size={28} color="var(--bal-primary)" />
</Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, lineHeight: 1.3 }}>
{label}
</Typography>
</ButtonBase>
);
<Box
sx={{
width: 48,
height: 48,
borderRadius: '50%',
bgcolor: 'var(--bal-primary-soft)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<AppIcon icon={resolveIcon(iconKey)} size={28} color="var(--bal-primary)" />
</Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, lineHeight: 1.3 }}>
{label}
</Typography>
</ButtonBase>
);
};
export default CategoryTile;
@@ -61,8 +61,9 @@ const CLOCK_ICON_SIZE: Record<CountdownTimerSize, number> = { sm: 16, md: 20, lg
* v2 adds, all opt-in and backward compatible: a progress-ring variant (`windowStart`), three urgency
* tiers (calm teal amber terracotta, via thresholds `urgent` still force-overrides for the two
* existing payment-window consumers), and a humanized coarse mode above `coarseThresholdSeconds`
* (`coarseLabel`) with `aria-live="polite"` applied only in coarse mode, since the fine ticking clock
* updates every second and would spam assistive tech if announced.
* (`coarseLabel`) with `aria-live="polite"` deliberately **not** on the fine ticking clock (every
* second would spam assistive tech), but present on the coarse label and on the one-time elapsed
* transition (`elapsedText`), the two moments actually worth announcing.
* @component CountdownTimer
*/
const CountdownTimer: FunctionComponent<CountdownTimerProps> = ({
@@ -121,7 +122,11 @@ const CountdownTimer: FunctionComponent<CountdownTimerProps> = ({
: undefined;
const content = elapsed ? (
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center', color: 'text.secondary' }}>
<Stack
direction="row"
sx={{ gap: 0.75, alignItems: 'center', color: 'text.secondary' }}
aria-live="polite"
>
<AppIcon icon="schedule" size={CLOCK_ICON_SIZE[size]} color="var(--bal-text-secondary)" />
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{elapsedText}
@@ -25,6 +25,8 @@ const STEPS: ExplainerStep[] = [
* alert line at the moment of maximum skepticism.
* @component EscrowExplainer
*/
const EXPLAINER_CONTENT_ID = 'escrow-explainer-content';
const EscrowExplainer: FunctionComponent = () => {
const t = useTranslations('payment');
const [open, setOpen] = useState(false);
@@ -38,6 +40,7 @@ const EscrowExplainer: FunctionComponent = () => {
size="small"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-controls={EXPLAINER_CONTENT_ID}
endIcon={
<Box
component="span"
@@ -50,7 +53,7 @@ const EscrowExplainer: FunctionComponent = () => {
>
{t('escrow_explainer_toggle')}
</AppButton>
<Collapse in={open}>
<Collapse in={open} id={EXPLAINER_CONTENT_ID}>
<Stack sx={{ gap: 1.5, p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack
direction="row"
@@ -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>
);
};
@@ -20,12 +20,15 @@ export interface PaymentStateCardProps {
* private `MessageCard`/`StateCard` functions (`checkout/page.tsx` + `bnpl/page.tsx`,
* `checkout/return/page.tsx` + `bnpl/return/page.tsx`) so the two flows can no longer visually drift.
* Presentational, caller-owned i18n; `children` is the actions slot (a single CTA or a stack of them).
* `aria-live="polite"` this card also carries the payment-status-poll's pendingsucceeded/failed
* transition (money's most anxious wait), so the outcome is announced without the user re-focusing it.
* @component PaymentStateCard
*/
const PaymentStateCard: FunctionComponent<PaymentStateCardProps> = ({ icon, tone, title, body, children }) => (
<Paper
elevation={0}
data-payment-state-card
aria-live="polite"
sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
>
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
@@ -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 (
@@ -4,6 +4,7 @@ import { useLocale, useTranslations } from 'next-intl';
import { Stack, Typography } from '@mui/material';
import AppAlert from '@/components/common/AppAlert';
import AppIcon from '@/components/common/AppIcon';
import { REFUND_ETA_MAX_BUSINESS_DAYS, REFUND_ETA_MIN_BUSINESS_DAYS } from '@/constants';
import { formatShamsiDate } from '@/utils';
import type { RefundChannel } from '@/services/refunds/types';
@@ -53,7 +54,10 @@ const RefundEtaBanner: FunctionComponent<RefundEtaBannerProps> = ({ channel, eta
<Typography variant="body2">{t(copy.bodyKey)}</Typography>
{channel === 'bnpl_revert' && (
<Typography variant="caption" sx={{ fontWeight: 500 }} data-testid="refund-eta-window">
{t('eta_business_days')}
{t('eta_business_days', {
minDays: REFUND_ETA_MIN_BUSINESS_DAYS,
maxDays: REFUND_ETA_MAX_BUSINESS_DAYS,
})}
{eta ? ` · ${t('eta_expected_label', { date: formatShamsiDate(eta, locale) })}` : ''}
</Typography>
)}
@@ -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>
);
@@ -38,4 +38,34 @@ describe('<AdminDataTable/>', () => {
fireEvent.click(screen.getByText('Beta'));
expect(onRowClick).toHaveBeenCalledWith(ROWS[1]);
});
it('renders the footer line only when passed', () => {
const { rerender, container } = wrap(<AdminDataTable columns={COLUMNS} rows={ROWS} getRowKey={(r) => r.id} />);
expect(container.querySelector('p')).not.toBeInTheDocument();
rerender(
<ThemeProvider>
<AdminDataTable columns={COLUMNS} rows={ROWS} getRowKey={(r) => r.id} footer="Showing 12 of 2" />
</ThemeProvider>,
);
expect(screen.getByText('Showing 12 of 2')).toBeInTheDocument();
});
it('fires onSortChange with the column key when a sortable header is clicked', () => {
const onSortChange = jest.fn();
const sortableColumns: AdminTableColumn<Row>[] = [
{ key: 'id', header: 'ID', render: (r) => r.id, sortable: true },
{ key: 'name', header: 'Name', render: (r) => r.name },
];
wrap(
<AdminDataTable
columns={sortableColumns}
rows={ROWS}
getRowKey={(r) => r.id}
sort={{ key: 'id', direction: 'asc' }}
onSortChange={onSortChange}
/>,
);
fireEvent.click(screen.getByRole('button', { name: /ID/ }));
expect(onSortChange).toHaveBeenCalledWith('id');
});
});
+97 -26
View File
@@ -7,8 +7,12 @@ import {
TableContainer,
TableHead,
TableRow,
TableSortLabel,
Typography,
} from '@mui/material';
export type AdminSortDirection = 'asc' | 'desc';
export interface AdminTableColumn<T> {
/** Stable column key (also `data-col` for tests). */
key: string;
@@ -19,6 +23,15 @@ export interface AdminTableColumn<T> {
/** Optional cell alignment (defaults to `inherit`, which follows text direction — RTL-safe). */
align?: 'inherit' | 'left' | 'center' | 'right';
width?: number | string;
/** Floor width so a narrow value (a status chip, a short date) doesn't collapse the column on scroll. */
minWidth?: number | string;
/** Marks the column as a server-param sort target — renders a `TableSortLabel`; requires `onSortChange`. */
sortable?: boolean;
}
export interface AdminSortState {
key: string;
direction: AdminSortDirection;
}
export interface AdminDataTableProps<T> {
@@ -29,6 +42,18 @@ export interface AdminDataTableProps<T> {
dense?: boolean;
/** Accessible table name (already translated). */
ariaLabel?: string;
/** Bounds the table to a scrollable viewport with a header that stays visible while scrolling opt in
* on long worklists (the filter object stays the query key; sort/page are just more of it, per 3.1). */
stickyHeader?: boolean;
/** `stickyHeader`'s scroll viewport height. */
stickyMaxHeight?: number | string;
/** The currently active server-param sort, or `null`/absent when unsorted. */
sort?: AdminSortState | null;
/** Fired with a sortable column's `key` when its header is clicked — the caller owns the 3-state cycle. */
onSortChange?: (key: string) => void;
/** Already-translated footer line («نمایش ۱۲۰ از ۱۲۴») rendered only when passed; every caller already
* holds `total` from its paginated query, so this mirrors `AdminPager`'s pre-translated-string convention. */
footer?: string;
}
/**
@@ -36,39 +61,85 @@ export interface AdminDataTableProps<T> {
* whole table scrolls horizontally inside its own container so a wide worklist never breaks the page layout
* (a hard responsive rule). Rows are optionally clickable (a queue row its case). Header/cell alignment
* defaults to `inherit` so it follows the active text direction (RTL-safe). Colors come from the palette.
* v2 (ui-phase-11) adds optional per-column server-param sort, a sticky-header scroll viewport for long
* pages, per-column `minWidth`, and a results footer line.
* @component AdminDataTable
*/
function AdminDataTable<T>({ columns, rows, getRowKey, onRowClick, dense = true, ariaLabel }: AdminDataTableProps<T>) {
function AdminDataTable<T>({
columns,
rows,
getRowKey,
onRowClick,
dense = true,
ariaLabel,
stickyHeader = false,
stickyMaxHeight = 640,
sort,
onSortChange,
footer,
}: AdminDataTableProps<T>) {
return (
<TableContainer component={Paper} elevation={0} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, overflowX: 'auto' }}>
<Table size={dense ? 'small' : 'medium'} aria-label={ariaLabel} sx={{ minWidth: 640 }}>
<TableHead>
<TableRow sx={{ '& th': { fontWeight: 700, color: 'text.secondary', bgcolor: 'action.hover' } }}>
{columns.map((col) => (
<TableCell key={col.key} align={col.align ?? 'inherit'} sx={{ width: col.width }}>
{col.header}
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{rows.map((row) => (
<TableRow
key={getRowKey(row)}
hover={!!onRowClick}
onClick={onRowClick ? () => onRowClick(row) : undefined}
sx={{ cursor: onRowClick ? 'pointer' : 'default', '&:last-child td': { border: 0 } }}
>
<>
<TableContainer
component={Paper}
elevation={0}
sx={{
border: '1px solid',
borderColor: 'divider',
borderRadius: 2,
overflowX: 'auto',
...(stickyHeader ? { maxHeight: stickyMaxHeight, overflowY: 'auto' } : {}),
}}
>
<Table stickyHeader={stickyHeader} size={dense ? 'small' : 'medium'} aria-label={ariaLabel} sx={{ minWidth: 640 }}>
<TableHead>
<TableRow sx={{ '& th': { fontWeight: 700, color: 'text.secondary', bgcolor: 'action.hover' } }}>
{columns.map((col) => (
<TableCell key={col.key} data-col={col.key} align={col.align ?? 'inherit'}>
{col.render(row)}
<TableCell
key={col.key}
align={col.align ?? 'inherit'}
sx={{ width: col.width, minWidth: col.minWidth }}
sortDirection={col.sortable && sort?.key === col.key ? sort.direction : false}
>
{col.sortable && onSortChange ? (
<TableSortLabel
active={sort?.key === col.key}
direction={sort?.key === col.key ? sort.direction : 'asc'}
onClick={() => onSortChange(col.key)}
>
{col.header}
</TableSortLabel>
) : (
col.header
)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</TableHead>
<TableBody>
{rows.map((row) => (
<TableRow
key={getRowKey(row)}
hover={!!onRowClick}
onClick={onRowClick ? () => onRowClick(row) : undefined}
sx={{ cursor: onRowClick ? 'pointer' : 'default', '&:last-child td': { border: 0 } }}
>
{columns.map((col) => (
<TableCell key={col.key} data-col={col.key} align={col.align ?? 'inherit'}>
{col.render(row)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
{footer ? (
<Typography variant="caption" sx={{ display: 'block', color: 'text.secondary', mt: 1 }}>
{footer}
</Typography>
) : null}
</>
);
}
@@ -33,4 +33,28 @@ describe('<AuditLogRow/>', () => {
expect(screen.getByText('0.09')).toBeInTheDocument();
expect(screen.getByText('0.10')).toBeInTheDocument();
});
it('toggles aria-expanded on the row header', () => {
wrap(<AuditLogRow entry={ENTRY} />);
const header = screen.getByRole('button');
expect(header).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(header);
expect(header).toHaveAttribute('aria-expanded', 'true');
});
it('falls back to #id when no actorLabel is resolved, else shows the name', () => {
const { rerender } = wrap(<AuditLogRow entry={ENTRY} />);
expect(screen.getByText('#3')).toBeInTheDocument();
rerender(
<ThemeProvider>
<AuditLogRow entry={ENTRY} actorLabel="سارا کریمی" />
</ThemeProvider>,
);
expect(screen.getByText('سارا کریمی')).toBeInTheDocument();
});
it('renders no button role for a row with no diff to expand', () => {
wrap(<AuditLogRow entry={{ ...ENTRY, changedFields: null }} />);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
});
+45 -9
View File
@@ -1,13 +1,15 @@
'use client';
import { FunctionComponent, useState } from 'react';
import { FunctionComponent, KeyboardEvent, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Box, Chip, Collapse, Paper, Stack, Table, TableBody, TableCell, TableHead, TableRow, Typography } from '@mui/material';
import { formatShamsiDateTime } from '@/utils';
import type { AuditLogEntry } from '@/services/admin/types';
import type { AdminUserSummary, AuditLogEntry } from '@/services/admin/types';
import AppIcon from '../common/AppIcon';
export interface AuditLogRowProps {
entry: AuditLogEntry;
/** Resolved actor name (3.2's batch id→label lookup) — falls back to `#id` until the REQ-061 lookup resolves. */
actorLabel?: string;
}
/** Stringify a diff value (JSON for objects, `—` for null). */
@@ -17,17 +19,40 @@ function displayValue(v: unknown): string {
return String(v);
}
/** The actor cell's display text — the resolved name when known, else the honest `#id` fallback. */
export function actorDisplay(actorUserId: number | null, actorLabel?: string): string {
if (actorUserId == null) return '—';
return actorLabel ?? `#${actorUserId}`;
}
/** Resolve a batch `lookupUsers` result into the label `AuditLogRow`/`actorDisplay` expects. */
export function actorLabelFrom(userMap: Map<number, AdminUserSummary> | undefined, userId: number | null): string | undefined {
if (userId == null) return undefined;
return userMap?.get(userId)?.displayName;
}
/**
* One row of the append-only audit viewer, with an expandable `changedFields` diff (old new per field;
* PII is server-redacted as `<redacted>`). Read-only by design there is **no** edit/delete affordance
* (phase §5). Presentational; the caller passes the paged entries.
* (phase §5). Presentational; the caller passes the paged entries + (once resolved) the actor's name. The
* expand chevron rotates and the header carries `aria-expanded` + button semantics so open/closed state is
* visible and keyboard-toggleable (ui-phase-11).
* @component AuditLogRow
*/
const AuditLogRow: FunctionComponent<AuditLogRowProps> = ({ entry }) => {
const AuditLogRow: FunctionComponent<AuditLogRowProps> = ({ entry, actorLabel }) => {
const t = useTranslations('admin');
const locale = useLocale();
const [open, setOpen] = useState(false);
const fields = entry.changedFields ? Object.entries(entry.changedFields) : [];
const hasDetail = fields.length > 0;
const toggle = () => setOpen((v) => !v);
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggle();
}
};
return (
<Paper
@@ -36,24 +61,35 @@ const AuditLogRow: FunctionComponent<AuditLogRowProps> = ({ entry }) => {
sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, overflow: 'hidden' }}
>
<Stack
role={hasDetail ? 'button' : undefined}
tabIndex={hasDetail ? 0 : undefined}
aria-expanded={hasDetail ? open : undefined}
direction="row"
sx={{ gap: 2, alignItems: 'center', p: 1.5, cursor: fields.length ? 'pointer' : 'default', flexWrap: 'wrap' }}
onClick={fields.length ? () => setOpen((v) => !v) : undefined}
sx={{ gap: 2, alignItems: 'center', p: 1.5, cursor: hasDetail ? 'pointer' : 'default', flexWrap: 'wrap' }}
onClick={hasDetail ? toggle : undefined}
onKeyDown={hasDetail ? onKeyDown : undefined}
>
<Chip size="small" variant="outlined" label={`${entry.entityType} #${entry.entityId}`} />
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{entry.action}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', flexGrow: 1 }}>
{entry.actorUserId != null ? `#${entry.actorUserId}` : '—'}
{actorDisplay(entry.actorUserId, actorLabel)}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{formatShamsiDateTime(entry.occurredAt, locale)}
</Typography>
{fields.length ? <AppIcon icon="expand" size={18} color="var(--bal-text-secondary)" /> : null}
{hasDetail ? (
<AppIcon
icon="expand"
size={18}
color="var(--bal-text-secondary)"
style={{ transition: 'transform var(--bal-motion-fast) var(--bal-easing-standard)', transform: open ? 'rotate(180deg)' : 'none' }}
/>
) : null}
</Stack>
<Collapse in={open && fields.length > 0}>
<Collapse in={open && hasDetail}>
<Box sx={{ px: 1.5, pb: 1.5 }}>
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 700 }}>
{t('audit_diff_title')}
@@ -41,4 +41,58 @@ describe('<ConfirmDialog/>', () => {
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
expect(onClose).toHaveBeenCalledTimes(1);
});
it('renders the typed-confirmation field when requireTypedConfirmation is set', () => {
wrap(
<ConfirmDialog
open
onConfirm={jest.fn()}
{...base}
requireTypedConfirmation={['CONFIRM', '150000']}
typedConfirmationLabel="Type to confirm"
/>,
);
expect(screen.getByLabelText('Type to confirm')).toBeInTheDocument();
});
it('keeps confirm disabled until the typed value matches, case/whitespace-insensitively', () => {
const onConfirm = jest.fn();
wrap(
<ConfirmDialog
open
onConfirm={onConfirm}
{...base}
requireTypedConfirmation={['CONFIRM', '150000']}
typedConfirmationLabel="Type to confirm"
/>,
);
const confirmBtn = screen.getByRole('button', { name: 'Run' });
const field = screen.getByLabelText('Type to confirm');
expect(confirmBtn).toBeDisabled();
fireEvent.change(field, { target: { value: 'nope' } });
expect(confirmBtn).toBeDisabled();
fireEvent.change(field, { target: { value: ' confirm ' } });
expect(confirmBtn).not.toBeDisabled();
fireEvent.click(confirmBtn);
expect(onConfirm).toHaveBeenCalledWith(undefined);
});
it('enables confirm on an exact match against a different allowed value (e.g. an amount)', () => {
wrap(
<ConfirmDialog
open
onConfirm={jest.fn()}
{...base}
requireTypedConfirmation={['CONFIRM', '150000']}
typedConfirmationLabel="Type to confirm"
/>,
);
const confirmBtn = screen.getByRole('button', { name: 'Run' });
fireEvent.change(screen.getByLabelText('Type to confirm'), { target: { value: '150000' } });
expect(confirmBtn).not.toBeDisabled();
});
});
@@ -0,0 +1,43 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ThemeProvider } from '../../../theme';
const useUserSearchMock = jest.fn();
jest.mock('@/services/admin', () => ({ useUserSearch: (...args: unknown[]) => useUserSearchMock(...args) }));
jest.mock('@/services/search', () => ({ useDebouncedValue: (value: unknown) => value }));
import NursePicker from './NursePicker';
import type { AdminUserSummary } from '@/services/admin/types';
const ZAHRA: AdminUserSummary = {
id: 101,
displayName: 'زهرا رضایی',
maskedPhone: '0912•••0001',
roles: ['nurse'],
nurseProfileId: 15,
};
describe('<NursePicker/>', () => {
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
beforeEach(() => {
useUserSearchMock.mockReset();
useUserSearchMock.mockReturnValue({ data: [ZAHRA], isFetching: false });
});
it('always searches with roleFilter="nurse"', async () => {
const user = userEvent.setup();
wrap(<NursePicker value={null} onChange={jest.fn()} label="پرستار" />);
await user.type(screen.getByLabelText('پرستار'), 'زهرا');
expect(useUserSearchMock).toHaveBeenLastCalledWith('زهرا', 'nurse');
});
it('returns the picked option, carrying nurseProfileId', async () => {
const onChange = jest.fn();
const user = userEvent.setup();
wrap(<NursePicker value={null} onChange={onChange} label="پرستار" />);
await user.click(screen.getByLabelText('پرستار'));
await user.click(await screen.findByText('زهرا رضایی'));
expect(onChange).toHaveBeenCalledWith(ZAHRA);
});
});
@@ -0,0 +1,14 @@
import { FunctionComponent } from 'react';
import UserPicker, { type UserPickerProps } from '../UserPicker';
export type NursePickerProps = Omit<UserPickerProps, 'roleFilter'>;
/**
* Thin `UserPicker` variant fixed to `roleFilter="nurse"` the sponsored-nurse assignment picker
* (`admin/partners/[id]`). The selected `AdminUserSummary.nurseProfileId` is the id the caller actually
* needs (a different id space than `.id`, the user id) see `services/admin/types.ts`.
* @component NursePicker
*/
const NursePicker: FunctionComponent<NursePickerProps> = (props) => <UserPicker {...props} roleFilter="nurse" />;
export default NursePicker;
@@ -0,0 +1,2 @@
export { default } from './NursePicker';
export type { NursePickerProps } from './NursePicker';
@@ -13,6 +13,11 @@ export interface SupportAlertCardProps {
canAct?: boolean;
onAssignSelf?: (alert: SupportAlert) => void;
onResolve?: (alert: SupportAlert) => void;
/** True while the caller's own id hasn't hydrated yet disables "assign to me" rather than falling back
* to a guessed id (phase §3.2: never default assign-to-self to user #1). */
assignSelfDisabled?: boolean;
/** Already-translated tooltip shown on the disabled "assign to me" button while hydrating. */
assignSelfDisabledTitle?: string;
}
/** Alert status → semantic chip kind. */
@@ -37,7 +42,14 @@ const SEVERITY_ACCENT: Record<SupportAlertSeverity, string> = {
* namespace keyed off the stable code.
* @component SupportAlertCard
*/
const SupportAlertCard: FunctionComponent<SupportAlertCardProps> = ({ alert, canAct = false, onAssignSelf, onResolve }) => {
const SupportAlertCard: FunctionComponent<SupportAlertCardProps> = ({
alert,
canAct = false,
onAssignSelf,
onResolve,
assignSelfDisabled = false,
assignSelfDisabledTitle,
}) => {
const t = useTranslations('admin');
const locale = useLocale();
@@ -85,7 +97,14 @@ const SupportAlertCard: FunctionComponent<SupportAlertCardProps> = ({ alert, can
{canAct && alert.status !== 'resolved' ? (
<Stack direction="row" sx={{ gap: 1, mt: 1.5, flexWrap: 'wrap' }}>
{alert.status === 'open' ? (
<AppButton variant="outlined" color="primary" startIcon="assign" onClick={() => onAssignSelf?.(alert)}>
<AppButton
variant="outlined"
color="primary"
startIcon="assign"
onClick={() => onAssignSelf?.(alert)}
disabled={assignSelfDisabled}
title={assignSelfDisabled ? assignSelfDisabledTitle : undefined}
>
{t('alert_assign_me')}
</AppButton>
) : null}
@@ -0,0 +1,46 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ThemeProvider } from '../../../theme';
const useUserSearchMock = jest.fn();
jest.mock('@/services/admin', () => ({ useUserSearch: (...args: unknown[]) => useUserSearchMock(...args) }));
jest.mock('@/services/search', () => ({ useDebouncedValue: (value: unknown) => value }));
import UserPicker from './UserPicker';
import type { AdminUserSummary } from '@/services/admin/types';
const MARYAM: AdminUserSummary = { id: 2, displayName: 'مریم احمدی', maskedPhone: '0912•••0002', roles: ['admin'] };
describe('<UserPicker/>', () => {
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
beforeEach(() => {
useUserSearchMock.mockReset();
useUserSearchMock.mockReturnValue({ data: [MARYAM], isFetching: false });
});
it('renders the label and, once opened, an option with name + masked phone + id', async () => {
const user = userEvent.setup();
wrap(<UserPicker value={null} onChange={jest.fn()} label="کاربر" />);
const input = screen.getByLabelText('کاربر');
await user.click(input);
expect(await screen.findByText('مریم احمدی')).toBeInTheDocument();
expect(screen.getByText('0912•••0002 · #2')).toBeInTheDocument();
});
it('calls onChange with the selected user on pick', async () => {
const onChange = jest.fn();
const user = userEvent.setup();
wrap(<UserPicker value={null} onChange={onChange} label="کاربر" />);
await user.click(screen.getByLabelText('کاربر'));
await user.click(await screen.findByText('مریم احمدی'));
expect(onChange).toHaveBeenCalledWith(MARYAM);
});
it('passes the typed query + roleFilter through to useUserSearch', async () => {
const user = userEvent.setup();
wrap(<UserPicker value={null} onChange={jest.fn()} label="پرستار" roleFilter="nurse" />);
await user.type(screen.getByLabelText('پرستار'), 'زهرا');
await waitFor(() => expect(useUserSearchMock).toHaveBeenLastCalledWith('زهرا', 'nurse'));
});
});
@@ -0,0 +1,110 @@
'use client';
import { FunctionComponent, useState } from 'react';
import Autocomplete from '@mui/material/Autocomplete';
import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
import Stack from '@mui/material/Stack';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import { useUserSearch } from '@/services/admin';
import { useDebouncedValue } from '@/services/search';
import type { AdminUserSummary, DirectoryUserRole } from '@/services/admin/types';
export interface UserPickerProps {
value: AdminUserSummary | null;
onChange: (user: AdminUserSummary | null) => void;
/** Already-translated field label. */
label: string;
placeholder?: string;
helperText?: string;
noOptionsText?: string;
loadingText?: string;
/** Narrow the search to one coarse role (`NursePicker` passes `'nurse'`). */
roleFilter?: DirectoryUserRole;
disabled?: boolean;
error?: boolean;
}
const SEARCH_DEBOUNCE_MS = 300;
/**
* Async name/phone search over the admin user directory (REQ-061, gap mock-backed until delivered),
* replacing every raw numeric-id `TextField` on an audited action (role grants, partner-center admin
* assignment, sponsored-nurse assignment, alert assignment phase §3.2). Each option renders
* **name + masked phone + `#id`**, never a bare id, so the caller's confirm dialog can echo a resolved
* **person** instead of `#42`. `NursePicker` is a thin `roleFilter="nurse"` wrapper over this component.
* @component UserPicker
*/
const UserPicker: FunctionComponent<UserPickerProps> = ({
value,
onChange,
label,
placeholder,
helperText,
noOptionsText,
loadingText,
roleFilter,
disabled,
error,
}) => {
const [inputValue, setInputValue] = useState('');
const debouncedQuery = useDebouncedValue(inputValue, SEARCH_DEBOUNCE_MS);
const search = useUserSearch(debouncedQuery, roleFilter);
const options = search.data ?? [];
return (
<Autocomplete
value={value}
onChange={(_event, next) => onChange(next)}
inputValue={inputValue}
onInputChange={(_event, next) => setInputValue(next)}
options={options}
loading={search.isFetching}
disabled={disabled}
// The directory is already narrowed server-side (mock or real) — never re-filter client-side.
filterOptions={(opts) => opts}
getOptionLabel={(option) => option.displayName}
isOptionEqualToValue={(option, selected) => option.id === selected.id}
noOptionsText={noOptionsText}
loadingText={loadingText}
renderOption={(optionProps, option) => {
const { key, ...rest } = optionProps;
return (
<Box component="li" key={key} {...rest}>
<Stack sx={{ gap: 0.125, py: 0.25 }}>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{option.displayName}
</Typography>
<Typography component="span" variant="caption" dir="ltr" sx={{ color: 'text.secondary' }}>
{option.maskedPhone} · #{option.id}
</Typography>
</Stack>
</Box>
);
}}
renderInput={({ slotProps: autocompleteSlotProps, ...rest }) => (
<TextField
{...rest}
label={label}
placeholder={placeholder}
helperText={helperText}
error={error}
slotProps={{
...autocompleteSlotProps,
input: {
...autocompleteSlotProps.input,
endAdornment: (
<>
{search.isFetching ? <CircularProgress color="inherit" size={16} /> : null}
{autocompleteSlotProps.input.endAdornment}
</>
),
},
}}
/>
)}
/>
);
};
export default UserPicker;
@@ -0,0 +1,2 @@
export { default } from './UserPicker';
export type { UserPickerProps } from './UserPicker';
+4
View File
@@ -30,3 +30,7 @@ export { default as RefundPanel } from './RefundPanel';
export type { RefundPanelProps } from './RefundPanel';
export { default as AdminMessageBubble } from './AdminMessageBubble';
export type { AdminMessageBubbleProps } from './AdminMessageBubble';
export { default as UserPicker } from './UserPicker';
export type { UserPickerProps } from './UserPicker';
export { default as NursePicker } from './NursePicker';
export type { NursePickerProps } from './NursePicker';
+11 -2
View File
@@ -4,7 +4,7 @@ import { CircularProgress, Link as MuiLink, Stack, Typography } from '@mui/mater
import { useLocale, useTranslations } from 'next-intl';
import PhoneNumberField, { isIranianMobile } from '@/components/PhoneNumberField';
import { AppButton, AppLink } from '@/components';
import { AppButton, AppIcon, AppLink } from '@/components';
import { APP_ROLES, ROUTES, type AppRole } from '@/constants';
import { useRequestOtp } from '@/services/auth';
import type { ApiError } from '@/lib/api/errors';
@@ -102,9 +102,18 @@ const PhoneStep: FunctionComponent<PhoneStepProps> = ({ intendedRole, onSwitchRo
type="button"
onClick={onSwitchRole}
underline="hover"
sx={{ color: 'text.secondary', textAlign: 'center' }}
sx={{
color: 'text.secondary',
textAlign: 'center',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: 0.5,
mx: 'auto',
}}
>
{isNurse ? t('customer_switch') : t('nurse_switch')}
<AppIcon icon="forward" size={14} />
</MuiLink>
</Stack>
);
@@ -214,4 +214,4 @@ export const ICONS /* Note: Setting type disables property autocomplete :( was -
* chevrons pointing "start"). AppIcon applies the flip via a `data-icon-directional`
* attribute + the single CSS rule in globals.css add a name here, nothing else.
*/
export const DIRECTIONAL_ICONS = new Set<IconName>(['back', 'chevron_start', 'forward']);
export const DIRECTIONAL_ICONS = new Set<IconName>(['back', 'chevron_start', 'forward', 'send']);
@@ -126,4 +126,16 @@ describe('<AppIconButton/> component', () => {
expect(tooltip).toHaveTextContent(title);
expect(tooltip).toHaveClass('MuiTooltip-popper');
});
it('keeps an accessible name from .title even when disabled (no Tooltip is rendered)', () => {
const testId = randomText(8);
const title = randomText(16);
render(<ComponentToTest data-testid={testId} title={title} disabled />);
const button = screen.getByTestId(testId);
expect(button).toBeDisabled();
expect(button).toHaveAttribute('aria-label', title);
// No Tooltip popper mounted for a disabled button — the name comes from the button itself.
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
});
});
@@ -75,6 +75,10 @@ const AppIconButton: FunctionComponent<AppIconButtonProps> = ({
component={componentToRender}
color={colorToRender}
disabled={disabled}
// Named directly on the button (not only via the Tooltip wrap below) so an icon-only button
// keeps an accessible name even when disabled, when the Tooltip isn't rendered at all. A
// caller-supplied `aria-label` in restOfProps still wins (spread last).
aria-label={title}
sx={sxToRender}
{...restOfProps}
>
@@ -82,7 +86,7 @@ const AppIconButton: FunctionComponent<AppIconButtonProps> = ({
{children}
</IconButton>
);
}, [color, componentToRender, children, disabled, icon, isMuiColor, sx, iconProps, restOfProps]);
}, [color, componentToRender, children, disabled, icon, isMuiColor, sx, iconProps, restOfProps, title]);
// When title is set, wrap the IconButton with Tooltip.
// Note: when IconButton is disabled the Tooltip is not working, so we don't need it
@@ -41,4 +41,58 @@ describe('<ConfirmDialog/>', () => {
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
expect(onClose).toHaveBeenCalledTimes(1);
});
it('renders the typed-confirmation field when requireTypedConfirmation is set', () => {
wrap(
<ConfirmDialog
open
onConfirm={jest.fn()}
{...base}
requireTypedConfirmation={['CONFIRM', '150000']}
typedConfirmationLabel="Type to confirm"
/>,
);
expect(screen.getByLabelText('Type to confirm')).toBeInTheDocument();
});
it('keeps confirm disabled until the typed value matches, case/whitespace-insensitively', () => {
const onConfirm = jest.fn();
wrap(
<ConfirmDialog
open
onConfirm={onConfirm}
{...base}
requireTypedConfirmation={['CONFIRM', '150000']}
typedConfirmationLabel="Type to confirm"
/>,
);
const confirmBtn = screen.getByRole('button', { name: 'Run' });
const field = screen.getByLabelText('Type to confirm');
expect(confirmBtn).toBeDisabled();
fireEvent.change(field, { target: { value: 'nope' } });
expect(confirmBtn).toBeDisabled();
fireEvent.change(field, { target: { value: ' confirm ' } });
expect(confirmBtn).not.toBeDisabled();
fireEvent.click(confirmBtn);
expect(onConfirm).toHaveBeenCalledWith(undefined);
});
it('enables confirm on an exact match against a different allowed value (e.g. an amount)', () => {
wrap(
<ConfirmDialog
open
onConfirm={jest.fn()}
{...base}
requireTypedConfirmation={['CONFIRM', '150000']}
typedConfirmationLabel="Type to confirm"
/>,
);
const confirmBtn = screen.getByRole('button', { name: 'Run' });
fireEvent.change(screen.getByLabelText('Type to confirm'), { target: { value: '150000' } });
expect(confirmBtn).not.toBeDisabled();
});
});
@@ -27,6 +27,15 @@ export interface ConfirmDialogProps {
requireReason?: boolean;
reasonLabel?: string;
reasonPlaceholder?: string;
/**
* When set, confirm stays disabled until the typed value case-insensitively matches ONE of these
* (e.g. the literal word «تایید» OR the exact amount digit-string) the guard for an irreversible
* action like running a payout batch. A gate, not a payload: `onConfirm`'s signature is unchanged, the
* typed value itself is never passed to the caller.
*/
requireTypedConfirmation?: string[];
typedConfirmationLabel?: string;
typedConfirmationPlaceholder?: string;
/** MUI color for the confirm button — `error` for a destructive action. */
confirmColor?: 'primary' | 'error' | 'secondary';
}
@@ -52,21 +61,31 @@ const ConfirmDialog: FunctionComponent<ConfirmDialogProps> = ({
requireReason = false,
reasonLabel,
reasonPlaceholder,
requireTypedConfirmation,
typedConfirmationLabel,
typedConfirmationPlaceholder,
confirmColor = 'primary',
}) => {
const [reason, setReason] = useState('');
const [typedValue, setTypedValue] = useState('');
const close = () => {
setReason('');
setTypedValue('');
onClose();
};
const confirm = () => {
onConfirm(requireReason ? reason.trim() : undefined);
setReason('');
setTypedValue('');
};
const confirmDisabled = loading || (requireReason && reason.trim().length === 0);
const confirmDisabled =
loading ||
(requireReason && reason.trim().length === 0) ||
(requireTypedConfirmation != null &&
!requireTypedConfirmation.some((v) => v.trim().toLowerCase() === typedValue.trim().toLowerCase()));
return (
<Dialog open={open} onClose={loading ? undefined : close} fullWidth maxWidth="xs">
@@ -92,6 +111,17 @@ const ConfirmDialog: FunctionComponent<ConfirmDialogProps> = ({
sx={{ mt: 2 }}
/>
) : null}
{requireTypedConfirmation ? (
<TextField
autoFocus
fullWidth
value={typedValue}
onChange={(e) => setTypedValue(e.target.value)}
label={typedConfirmationLabel}
placeholder={typedConfirmationPlaceholder}
sx={{ mt: 2 }}
/>
) : null}
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<AppButton variant="text" color="inherit" onClick={close} disabled={loading}>
@@ -53,7 +53,10 @@ class ErrorBoundary extends Component<ErrorBoundaryProps, State> {
if (this.state.hasError) {
const { error, errorInfo } = this.state;
return (
<Stack sx={{ alignItems: 'center', justifyContent: 'center', gap: 1.5, py: 6, px: 2, textAlign: 'center' }}>
<Stack
role="alert"
sx={{ alignItems: 'center', justifyContent: 'center', gap: 1.5, py: 6, px: 2, textAlign: 'center' }}
>
<AppIcon icon="warning" size={36} color="var(--bal-warning)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{this.props.title}
@@ -21,13 +21,16 @@ export interface ErrorStateProps {
* The calm, branded "something went wrong" panel with a **required** retry affordance the fix for the
* false-empty class of defect (a failed query rendering as "no data" instead of an error). `onRetry` is
* mandatory by the type; there is no way to render this component without a way back. Presentational,
* caller-owned i18n throughout everything is already-translated copy.
* caller-owned i18n throughout everything is already-translated copy. `role="alert"` (implicit
* `aria-live="assertive"`) announces the failure to assistive tech without a per-call-site aria-live
* used at ~55 sites, this one change covers every query-failed moment in the app.
* @component ErrorState
*/
const ErrorState: FunctionComponent<ErrorStateProps> = ({ message, retryLabel, onRetry }) => (
<Paper
elevation={0}
data-error-state
role="alert"
sx={{
p: { xs: 3, sm: 5 },
textAlign: 'center',
@@ -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';
@@ -1,4 +1,4 @@
import { render, screen } from '@testing-library/react';
import { fireEvent, render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../../theme';
import PageHeader from './PageHeader';
@@ -16,6 +16,11 @@ describe('<PageHeader/>', () => {
expect(screen.getByRole('button', { name: 'Do' })).toBeInTheDocument();
});
it('renders the meta slot below the title when provided', () => {
wrap(<PageHeader title="T" meta={<span>Open</span>} />);
expect(screen.getByText('Open')).toBeInTheDocument();
});
it('omits the back button when backTo is not given', () => {
wrap(<PageHeader title="T" />);
expect(screen.queryByRole('link')).not.toBeInTheDocument();
@@ -25,4 +30,12 @@ describe('<PageHeader/>', () => {
wrap(<PageHeader title="T" backTo="/patients" backLabel="Back" />);
expect(screen.getByRole('link')).toHaveAttribute('href', '/patients');
});
it('renders a back button that calls onBack, not a link, when onBack is given', () => {
const onBack = jest.fn();
wrap(<PageHeader title="T" onBack={onBack} backLabel="Back" />);
expect(screen.queryByRole('link')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Back' }));
expect(onBack).toHaveBeenCalledTimes(1);
});
});
@@ -11,24 +11,53 @@ export interface PageHeaderProps {
subtitle?: string;
/** Optional action node (a button / filter) rendered end-aligned on desktop, wrapping on mobile. */
actions?: ReactNode;
/**
* Optional node rendered below the title/subtitle a chip row (status/category/linked-record chips)
* or any other short meta line that isn't a button (ui-phase-11: the admin ticket thread's category/
* status/linked-booking/linked-refund chips). Kept distinct from `actions`, which is end-aligned and
* reserved for buttons.
*/
meta?: ReactNode;
/** Optional back-navigation target — renders an RTL-flippable chevron before the title. */
backTo?: string;
/** Already-translated accessible label for the back button (required when `backTo` is set). */
/** Already-translated accessible label for the back button (required when `backTo` or `onBack` is set). */
backLabel?: string;
/**
* Optional back-navigation handler an alternative to `backTo` for a caller that needs `router.back()`
* semantics (e.g. `useAdminBackToList`) rather than a fixed link target. Takes precedence over `backTo`
* when both are given (they shouldn't be).
*/
onBack?: () => void;
}
/**
* The standard page header title + optional subtitle, an end-aligned actions slot, and an optional
* back affordance. The generalized, promoted form of `AdminPageHeader` (kept as a thin alias); every area
* page hand-rolling its own `h5`/`h1` + subtitle block should adopt this instead. Presentational,
* caller-owned i18n, RTL-safe (logical flex, the `back` icon mirrors automatically under `dir="rtl"`).
* The standard page header title + optional subtitle + optional meta row, an end-aligned actions slot,
* and an optional back affordance. The generalized, promoted form of `AdminPageHeader` (kept as a thin
* alias); every area page hand-rolling its own `h5`/`h1` + subtitle block should adopt this instead.
* Presentational, caller-owned i18n, RTL-safe (logical flex, the `back` icon mirrors automatically under
* `dir="rtl"`).
* @component PageHeader
*/
const PageHeader: FunctionComponent<PageHeaderProps> = ({ title, subtitle, actions, backTo, backLabel }) => (
const PageHeader: FunctionComponent<PageHeaderProps> = ({
title,
subtitle,
actions,
meta,
backTo,
backLabel,
onBack,
}) => (
<Stack direction="row" sx={{ gap: 2, alignItems: 'flex-start', justifyContent: 'space-between', flexWrap: 'wrap' }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'flex-start' }}>
{backTo ? (
<AppIconButton icon="back" to={backTo} title={backLabel} sx={{ mt: 0.25 }} iconProps={{ size: 20 }} />
{backTo || onBack ? (
<AppIconButton
icon="back"
to={onBack ? undefined : backTo}
onClick={onBack}
title={backLabel}
sx={{ mt: 0.25 }}
iconProps={{ size: 20 }}
/>
) : null}
<Box>
<Typography variant="h5" component="h1" sx={{ fontWeight: 700 }}>
@@ -39,6 +68,7 @@ const PageHeader: FunctionComponent<PageHeaderProps> = ({ title, subtitle, actio
{subtitle}
</Typography>
) : null}
{meta ? <Box sx={{ mt: 1 }}>{meta}</Box> : null}
</Box>
</Stack>
{actions ? <Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>{actions}</Box> : null}
@@ -0,0 +1,59 @@
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../../theme';
let pathname = '/nurse/earnings';
jest.mock('@/i18n/navigation', () => ({
usePathname: () => pathname,
}));
import RouteFadeIn from './RouteFadeIn';
describe('<RouteFadeIn/> component', () => {
beforeEach(() => {
pathname = '/nurse/earnings';
});
it('renders its children', () => {
render(
<ThemeProvider>
<RouteFadeIn>
<span>content</span>
</RouteFadeIn>
</ThemeProvider>,
);
expect(screen.getByText('content')).toBeInTheDocument();
});
it('marks the wrapper with the shared fade-in animation hook', () => {
render(
<ThemeProvider>
<RouteFadeIn>
<span>content</span>
</RouteFadeIn>
</ThemeProvider>,
);
expect(screen.getByText('content').parentElement).toHaveAttribute('data-bal-route-fade');
});
it('remounts (and would replay the animation) when the route changes', () => {
const { rerender } = render(
<ThemeProvider>
<RouteFadeIn>
<span data-testid="marker">a</span>
</RouteFadeIn>
</ThemeProvider>,
);
const first = screen.getByTestId('marker');
pathname = '/nurse/visits';
rerender(
<ThemeProvider>
<RouteFadeIn>
<span data-testid="marker">b</span>
</RouteFadeIn>
</ThemeProvider>,
);
expect(screen.getByTestId('marker')).not.toBe(first);
});
});
@@ -0,0 +1,24 @@
'use client';
import { FunctionComponent, PropsWithChildren } from 'react';
import Box from '@mui/material/Box';
import { usePathname } from '@/i18n/navigation';
/**
* The one route/content-transition primitive for the app-wide motion pass: a calm 150200ms fade +
* small slide (the `bal-fade-in` keyframe in `globals.css`) that plays once when its subtree mounts.
* Keyed on the locale-stripped pathname so React remounts (and replays the animation) on navigation,
* but never on an in-place re-render (a refetch, a state update) motion marks a *route change*, not
* "data changed". The keyframe itself carries no reduced-motion branch; the single app-wide gate in
* `globals.css` handles that for every consumer, this one included.
* @component RouteFadeIn
*/
const RouteFadeIn: FunctionComponent<PropsWithChildren> = ({ children }) => {
const pathname = usePathname();
return (
<Box key={pathname} data-bal-route-fade sx={{ minWidth: 0 }}>
{children}
</Box>
);
};
export default RouteFadeIn;
@@ -0,0 +1,3 @@
import RouteFadeIn from './RouteFadeIn';
export default RouteFadeIn;
+8
View File
@@ -20,6 +20,9 @@ import JalaliDateIntentPicker from './JalaliDateIntentPicker';
import LocaleSwitcher from './LocaleSwitcher';
import StickyActionBar from './StickyActionBar';
import Pager from './Pager';
import InitialsAvatar from './InitialsAvatar';
import FormDialogShell from './FormDialogShell';
import RouteFadeIn from './RouteFadeIn';
export {
ErrorBoundary,
@@ -44,6 +47,9 @@ export {
LocaleSwitcher,
StickyActionBar,
Pager,
InitialsAvatar,
FormDialogShell,
RouteFadeIn,
};
export type { EmptyStateProps } from './EmptyState';
export type { ErrorStateProps } from './ErrorState';
@@ -59,3 +65,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();
});
});

Some files were not shown because too many files have changed in this diff Show More