Compare commits

...

15 Commits

Author SHA1 Message Date
hamid 850cdf3414 add refinement phases 2026-07-10 20:59:47 +03:30
hamid a87b47bedb Add frontend phase 15 updates: complete MVP for admin backoffice and partner-center consoles, including new services and endpoints; document requests for backend enhancements. 2026-07-10 20:28:36 +03:30
hamid 70cf00ce4a frontend phase 15 2026-07-10 20:28:06 +03:30
hamid bc51cf59b4 frontend phase 14 2026-07-10 18:46:16 +03:30
hamid 85488bc25b frontend phase 13 2026-07-10 16:58:15 +03:30
hamid 6186f54294 frontend phase 12 2026-07-10 14:48:15 +03:30
hamid 67c028562e frontend phase 11 2026-07-10 13:55:42 +03:30
hamid ccfa27aff6 frontend phase 10 2026-07-10 12:51:53 +03:30
hamid 40cc1d163b frontend phase 9 2026-07-10 11:49:55 +03:30
hamid cd6c2591a6 backend phase 15 & frontend phase 8 2026-07-10 03:22:29 +03:30
hamid 93cc5ecb98 backend phase 14 & frontend phase 7 2026-07-09 15:30:03 +03:30
hamid de53f9d8a6 backend phase 13 & frontend phase 6 2026-07-09 04:09:35 +03:30
hamid dc64472631 frontend phase 5 & backend phase 12 2026-07-09 03:05:14 +03:30
hamid 465f75c29e backend phase 11 2026-07-09 02:13:30 +03:30
hamid 23605591eb ignore graphify 2026-07-09 01:04:44 +03:30
797 changed files with 92566 additions and 266 deletions
+1
View File
@@ -1 +1,2 @@
temp
**/graphify-out/*
+142 -21
View File
@@ -119,27 +119,81 @@ client/
│ │ ├── (customer)/ # Customer (family) app — mobile-first, bottom-tab nav; no URL segment
│ │ │ ├── layout.tsx # 'use client' — wraps CustomerLayout
│ │ │ ├── page.tsx # / (A5 home — 'use client'; greeting+avatar, search bar, data-driven category grid, first-login onboarding gate + record/profile nudges)
│ │ │ ├── search/page.tsx # /search — DEFERRED→f6 stub (PlaceholderScreen; Home search bar + category tiles land here carrying q/category_id)
│ │ │ ├── search/ # /search — f6 discovery: C1 filter screen (page.tsx: reused category grid + f3 region picker + prominent same-gender facet + Toman price + live-count CTA; useSearchFilters colocated controller) → results/ (C2) → nurse/[nurseId]/ (C3)
│ │ │ │ ├── page.tsx # C1 search & filter; reads ?category_id preselect; pushes filter set to C2 as URL query params
│ │ │ │ ├── useSearchFilters.ts # C1 colocated filter controller (debounced Toman price → IRR; derives the canonical NurseSearchFilters)
│ │ │ │ ├── results/page.tsx # C2 results — rating-sorted NurseResultCard list; all four states (skeleton/empty-relax/error/populated); load-more; filters live in the URL (the cache key)
│ │ │ │ └── nurse/[nurseId]/page.tsx # C3 nurse profile — badges (TrustBadge + نظام پرستاری) + attribute chips + a f13 tab strip: «خدمات» (ServicePriceRow list) / «نظرات» (ReviewsPanel — published-only aggregate+count + infinite list via services/reviews); "درخواست رزرو" hands off to /bookings/request (f7)
│ │ │ ├── onboarding/page.tsx # /onboarding — A3→A4 wizard (relation → first patient)
│ │ │ ├── bookings/page.tsx # /bookings
│ │ │ ├── patients/page.tsx # /patients — E1 list/CRUD (add/edit dialog reusing PatientForm, soft-archive)
│ │ │ ├── bookings/
│ │ │ ├── page.tsx # /bookings — f8 رزروها list (useBookingList('customer')); rows → booking detail
│ │ │ │ ├── [id]/page.tsx # /bookings/[id] — f8 customer booking detail (BookingDetailView viewerRole="customer") + f10 cancel/refund entry (CustomerBookingActions) + f13 review entry (LeaveReviewCta: on a completed/closed booking, «ثبت نظر» → review page, flips to a passive "under review" affordance once reviewed — reuses the cached booking + my-review query)
│ │ │ │ ├── request/page.tsx # /bookings/request — f7 C4 request form (patient/variant/address/date/time + first-class caregiver-gender + stage-1 notes); C3 hands off the nurse/variant/required_gender here → creates a request → C5
│ │ │ │ ├── request/[id]/page.tsx # /bookings/request/[id] — f7 C5 awaiting screen: summary card + 3-step tracker + polled status; response countdown → (accept) 30-min payment countdown + checkout CTA / (reject/expire/cancel) terminal cards; converted → booking deep-link (bookingId, REQ-017)
│ │ │ │ ├── [id]/invoice/page.tsx # /bookings/[id]/invoice — f9 commission invoice (b11): number + Shamsi date, reconciling lines with the VAT-on-commission line, read-only مودیان state; pdfUrl download or window.print receipt
│ │ │ │ ├── [id]/cancel/page.tsx # /bookings/[id]/cancel — f10 cancellation flow: policy-fee disclosure (CancellationPolicyDisclosure) + reason + acknowledge → confirm → useCancelBooking → refund status
│ │ │ │ ├── [id]/refund_status/page.tsx # /bookings/[id]/refund_status — f10 customer refund status (RefundStatusCard): pending → on-its-way → completed, BNPL ~710-day ETA, failed=contact-support; polls only while non-terminal
│ │ │ │ ├── [id]/review/page.tsx # /bookings/[id]/review — f13 leave-a-review (b14): RatingInput + body + ReviewTagSelector; gated on completed/closed + server can_review + 1:1; on submit → persistent "under review" (pending_moderation, never public here); already-reviewed shows the review state, never a 2nd form (services/reviews)
│ │ │ │ └── checkout/ # f9 checkout flow (C5 accept CTA lands on page.tsx with ?request_id=)
│ │ │ │ ├── page.tsx # C6 خلاصه و پرداخت — acceptance badge, served reconciling breakdown (PriceBreakdown), EscrowNotice, payment-window countdown, «ادامه پرداخت ←» (idempotency-key-per-attempt) + «پرداخت اقساطی» → f11 BNPL wizard
│ │ │ │ ├── gateway/page.tsx # dev mock-gateway page — TEST HARNESS standing in for the PSP redirect (mock redirectUrl points here; success/failure buttons drive both return branches)
│ │ │ │ ├── return/page.tsx # return-from-gateway — confirm return → pending-callback poll (backoff, stops on terminal) → succeeded (invalidate + hand off) / failed retry / window-expired
│ │ │ │ ├── confirmation/page.tsx # payment success — «مشاهده رزرو» (booking detail) + «دانلود فاکتور» (invoice); REUSED by f11 (?method=bnpl adds «پرداخت‌شده با اقساط» — a settled BNPL order is a card payment net-of-fee)
│ │ │ │ └── bnpl/ # f11 BNPL installment checkout (the alternate branch off C6, reached with ?request_id=)
│ │ │ │ ├── page.tsx # D1→D4 stateful wizard (StepperHeader): D1 method/provider · D2 plan · D3 eligibility · D4 schedule+contract → provider handoff; card fall-back → C6 everywhere
│ │ │ │ ├── MethodStep.tsx # D1 روش پرداخت — payable amount + full-card option + provider option cards (from useBnplOptions, never hardcoded)
│ │ │ │ ├── PlanStep.tsx # D2 انتخاب طرح — single-select BnplPlanCard group (served monthly/down-payment)
│ │ │ │ ├── EligibilityStep.tsx # D3 اعتبارسنجی — کد ملی + prefilled موبایل + consent gate → useCheckEligibility → approved(ceiling)/declined(+card)
│ │ │ │ ├── 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
│ │ │ │ └── return/page.tsx # settle (useAcceptBnplSchedule) → invalidate → reused confirmation (?method=bnpl) / retry / card
│ │ │ ├── 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)
│ │ │ ├── wallet/page.tsx # /wallet
│ │ │ ── profile/page.tsx # /profile — customer profile + emergency contact (no national-ID)
│ │ │ ├── wallet/ # /wallet — f11 D5 پیگیری اقساط (page.tsx = thin shell → WalletInstallments.tsx: provider-reported outstanding balance + due list + early-pay provider hand-off; self-contained for f12 nurse-earnings later)
│ │ │ ── profile/page.tsx # /profile — customer profile + emergency contact (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
│ │ │ ├── layout.tsx # 'use client' — wraps NurseLayout
│ │ │ ├── page.tsx # /nurse (dashboard)
│ │ │ ├── requests/ # /nurse/requests — f7 incoming booking-requests inbox (page.tsx: pending list, per-request countdown + gender chip + notes preview) ↔ requests/[id]/page.tsx detail (only customerNotes + masked city/district; accept/reject-with-reason invalidate inbox+detail)
│ │ │ ├── profile/page.tsx # /nurse/profile — B7 profile bootstrap (avatar+bio+years; unverified placeholder)
│ │ │ ├── services/ # /nurse/services — B7 services half: offerings list ↔ variant builder (page.tsx switches mode; MyServicesList + VariantBuilder co-located)
│ │ │ ├── services/ # /nurse/services — B7 services half: offerings list ↔ variant builder (page.tsx switches mode; MyServicesList + VariantBuilder + PublishGate co-located; PublishGate is the f5 verification-gated go-live)
│ │ │ ├── coverage/page.tsx # /nurse/coverage — F3 coverage-area editor (whole-city/district areas, dup-blocked)
│ │ │ ├── bank/page.tsx # /nurse/bank — payout IBAN + ownership states (pending/verified/mismatch)
│ │ │ ├── verification/page.tsx # /nurse/verification
│ │ │ └── visits/page.tsx # /nurse/visits (EVV)
│ │ └── admin/ # Admin/backoffice (/admin/…) — desktop sidebar shell
│ │ ├── layout.tsx # 'use client' — wraps AdminLayout
│ │ ├── page.tsx # /admin (overview)
│ │ ├── users/page.tsx # /admin/users
│ │ └── notifications/page.tsx # /admin/notifications
│ │ │ ├── bank/page.tsx # /nurse/bank — payout IBAN + ownership states (pending/verified/mismatch); the f5 bank_account_verification step deep-links here
│ │ │ ├── verification/ # /nurse/verification — f5 trust flow: ONE cached VerificationStatus query, four views
│ │ │ │ ├── page.tsx # B3 hub — "X از Y" meter + data-driven checklist (StatusChip rows) + single continue CTA + not_started/approved states; dev-only mock admin-decision sim
│ │ ├── identity/page.tsx # B4 — national-ID (checksum) + card/selfie local capture → automated KYC + chained Shahkar
│ │ │ │ ├── credentials/page.tsx # B5 — INO number + specialty chips + a DocumentUpload per manual step (data-driven) → in_review
│ │ │ │ ├── review/page.tsx # B6 — under-review (same status query, condensed mini-checklist)
│ │ │ │ ├── VerificationChecklist.tsx # B3 body: meter + step rows (co-located, page-only)
│ │ │ │ └── verificationSteps.ts # step→label/chip/route helpers + synthetic mobile step (keeps rendering data-driven)
│ │ │ ├── visits/ # /nurse/visits — f8 EVV: page.tsx = ویزیت امروز today-sessions feed (per-session check-in/out via useEvvController + advisory EvvStatusBanner) ↔ visits/[id]/page.tsx nurse booking detail (BookingDetailView viewerRole="nurse": EVV controls + gated care card) + f13 NurseVisitNotesPanel.tsx (co-located, BELOW the EVV banner: today's task checklist + free-text note composer + read-only continuity history — APPEND-ONLY, never wires useUpdateCareRecord; services/patientRecords)
│ │ │ ├── earnings/ # /nurse/earnings — f12 nurse earnings (read-only): page.tsx = EarningsBalanceHeader (net payable balance + 4 buckets, negative "owed back") + cadence/dispute-window explainer + state-segmented EarningsRow list (deep-links to /nurse/visits/[id]) ↔ payouts/page.tsx (PayoutHistoryRow list) → payouts/[id]/page.tsx (payout/batch reconciliation detail: money decomposition + masked IBAN + booking links)
│ │ │ ├── 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).
│ │ │ ├── layout.tsx # 'use client' — wraps AdminLayout (capability-gated nav)
│ │ │ ├── page.tsx # /admin — 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
│ │ └── 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' — wraps PartnerLayout (own partner nav)
│ │ ├── page.tsx # /partner — 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
│ └── (public-routes)/
│ ├── layout.tsx # 'use client' — wraps PublicLayout
│ └── login/page.tsx # /login — phone-OTP login (A1/A2 customer, B1/B2 nurse switch)
@@ -153,12 +207,36 @@ client/
│ ├── ConditionChips/ # Multi-select patient-condition chips (stable codes, translated labels)
│ ├── RelationSelect/ # Single-select relation radio cards (parent/spouse/child/self)
│ ├── PatientForm/ # A4 patient form (name/age/gender/conditions/relation) — reused create+edit
│ ├── PatientCard/ # E1 patient summary card + edit/archive actions
│ ├── PatientCard/ # E1 patient summary card (composes the shared PatientHeader) + edit/archive actions + optional onOpen tap-to-open (→ f13 E2 record)
│ ├── BankStatusPanel/ # Nurse bank-account ownership state (pending/verified/mismatch), masked IBAN
│ ├── CategoryTile/ # f4 tappable service-category tile (icon+label; `selected` state for the builder) — Home grid + builder step 1 (tested)
│ ├── PriceDisplay/ # f4 price renderer: money-util Toman + i18n unit label + unit-aware estimated total (never a total from price alone) (tested)
│ ├── VariantCard/ # f4 nurse offering card: display_name, PriceDisplay, active/deactivated distinction, edit/deactivate (no delete) (tested)
│ ├── TrustBadge/ # f5 public trust signal (verified/unverified/expired) off --bal-* tokens — nurse profile + reused by f6 search/public profile (tested)
│ ├── DocumentUpload/ # f5 reusable doc uploader: client type/size validation, progress %, success/retry, re-upload on reject; server-metadata truth (local-capture mode too) (tested)
│ ├── NurseResultCard/ # f6 C2 result card: avatar+name, reused verified TrustBadge, rating+review count, optional distance chip, "from X تومان/unit" via PriceDisplay; presentational + memoized (tested)
│ ├── ServicePriceRow/ # f6 C3 service line: localised name + PriceDisplay (money util + i18n unit label); reused by the booking summary later (tested)
│ ├── CountdownTimer/ # f7 pure presentational countdown to a server-frozen UTC deadline; owns its own 1s tick (only it re-renders), stops + shows elapsed text at zero, locale digits LTR (tested)
│ ├── BookingRequestSummaryCard/ # f7 engagement summary (nurse+rating, patient, priced service, address, Shamsi time) — shared by C5 + nurse detail + later f8 booking detail (tested)
│ ├── PriceBreakdown/ # f9 reconciling money breakdown (rows + bold total, all IRR digit-strings via the money util; dev-guard console.errors when rows ≠ total) — C6 + invoice now, f10/f11 refund/BNPL later (tested)
│ ├── EscrowNotice/ # f9 product-mandated escrow trust callout (verbatim fa copy, --bal-info tone, lock icon) — C6 now, f10/f11 reuse the identical message (tested)
│ ├── PaymentStatusBadge/ # f9 b10 payment status (pending/succeeded/failed) → StatusChip kind + payment.pstatus_* label (tested)
│ ├── CancellationPolicyDisclosure/ # f10 pre-confirm cancel disclosure: policy-tier label (off cancellation_policy_code) + refund %/fee % + PriceBreakdown refund-vs-fee split (reconciles) + multi-session refundable/locked breakdown + admin-approval explainer + RefundEtaBanner (tested)
│ ├── RefundStatusCard/ # f10 customer refund view: 3-step stepper (submitted→on-its-way→completed) + refunded amount + optional fee-leg split + masked ref + failed=contact-support (no retry); reused on booking detail + refund-status page (tested)
│ ├── RefundEtaBanner/ # f10 per-channel refund ETA — bnpl_revert surfaces the ~710 business-day window honestly (never instant), psp_card/manual wording; one branch on refund_channel (tested)
│ ├── BnplPlanCard/ # f11 D2 installment-plan option card (terracotta): term/installments + interest-free/fee sub-label + served monthly amount + down-payment indicator; single-select (tested)
│ ├── InstallmentScheduleRow/ # f11 repayment row: down-payment(«امروز»)/installment + Shamsi due date + served amount + optional provider-reported status chip; reused by D4 schedule + D5 wallet due list (tested)
│ ├── EarningsBalanceHeader/ # f12 nurse net payable balance + 4-bucket breakdown (pending/eligible/paid/clawback off --bal-{warning,info,success,error}); renders a negative net as an explicit "owed back" state (magnitude only, never a bare minus) (tested)
│ ├── EarningsRow/ # f12 one earnings item: three-amount «gross commission = your payout» breakdown via PriceBreakdown + one of four visually-distinct state chips + state affordance (pending→display-only dispute-window CountdownTimer, eligible→awaiting-batch, paid→paid_at+ref+payout link, clawback_applied→net explanation); deep-links to /nurse/visits/[id] (tested)
│ ├── PayoutHistoryRow/ # f12 one nurse_payouts row: net transferred + payout-status chip (pending/submitted/paid/failed) + period + masked IBAN (last-4, dir=ltr) + transfer ref + read-only failure banner (no nurse retry) (tested)
│ ├── 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)
│ ├── booking/ # f8 post-payment engagement composites (import from @/components/booking). BookingDetailView (both-roles smart container, role-conditioned EVV+gated care), BookingStatusTimeline (server-truth 7-status timeline over StepperHeader), SessionList→SessionCard (per-session schedule/status/EVV CTA), EvvStatusBanner (advisory in/out-of-range/no-gps), CareInstructionsCard (decrypted clinical read), BookingMoneySummary (gross/commission/payout display-only); useEvvController (GPS-capture + check-in/out orchestration), format.ts + statusKind.ts helpers. 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
│ └── auth/ # Auth-flow composites: LoginFlow, PhoneStep, OtpStep, RoleRouter, SelectRole, AuthCard, BrandMark, AuthSplash, useCountdown
├── i18n/
│ ├── routing.ts # defineRouting — locales: ['en', 'fa'], defaultLocale: 'fa'
@@ -207,6 +285,26 @@ client/
│ ├── addresses/ # F3 customer address book CRUD + set-primary (single-primary invariant; invalidate-on-mutation)
│ ├── serviceAreas/ # F3 nurse coverage areas add/remove (areaExists dup-guard; districtId=null = whole city)
│ ├── catalog/ # F4 catalog skeleton + nurse pricing variants (b5). Reference data (categories, category option groups) cached session-long like geography (Infinity staleTime); myVariants invalidated on mutation. useServiceCategories/useCategoryOptionGroups/useMyVariants/useCreateVariant/useUpdateVariant/useSetVariantActive; seam+mock+client; names.ts locale-label helper
│ ├── search/ # F6 family discovery (b7). The **filter object IS the query key** (searchKeys.results + canonicalizeSearchFilters): identical/reverted filters reuse cache with zero network (keepPreviousData avoids flashing). useNurseSearch/useNurseProfile/useDebouncedValue; filterParams.ts = the shared C1↔C2 URL (de)serializer; seam+mock(PRIMARY)+client. Mock supplies name/avatar/distance/profile/reviews that b7's index row + b5/b6 reads don't yet expose (gap filed in for-backend.md). Every returned row is verified-by-invariant — the UI never re-filters
│ ├── verification/ # F5 nurse trust flow (b6). ONE cached status() query drives B3+B6; every mutation invalidates it. useVerificationStatus/useStartVerification/useSubmitIdentity/useRunBankVerification/useUploadVerificationDocument/useSubmitCredentials/useNurseTrustBadge; seam+mock(primary)+client; validation.ts (national-ID checksum); types export ownBadgeState/publicBadgeState/isApproved
│ ├── bookingRequests/ # F7 pre-payment request lifecycle (b8). Money-free create→accept/reject/cancel + role-scoped inbox + single get. useCreateBookingRequest/useBookingRequest(polls until terminal)/useNurseRequestInbox/useCustomerRequests/useAccept/useReject/useCancel; seam+mock(PRIMARY, shared in-memory state machine — customer create ↔ nurse inbox ↔ accept flips C5; lazy expiry sweep)+client. Server-frozen UTC deadlines rendered by CountdownTimer (never recomputed); two-stage disclosure (nurse `get(id,'nurse')` masks address); variantPrice client-augmented (REQ-013). Contract-live but mock-primary because inputs (search/patients/addresses) are mock-primary
│ ├── bookings/ # F8 post-payment engagement (b9) — the SIBLING of bookingRequests, NOT a rename. useBookingDetail/useBookingSessions(select over detail — sessions are embedded)/useBookingList/useTodaySessions/useSessionEvv/useCareInstructions(enabled-gated)/useCheckInVisit/useCheckOutVisit; seam+mock(PRIMARY, seeded confirmed bookings + sessions + care + EVV state machine)+client(1:1 b9)+serverApi(RSC-prefetch seam, real-path). evv/locationProvider.ts = the ILocationProvider GPS seam (real navigator.geolocation vs mock coords by NEXT_PUBLIC_EVV_MOCK_GPS in_range|out_of_range|denied). Money display-only (gross=commission+payout server-side); timeline=server truth; care read gated to assigned nurse; EVV mismatch/denial advisory (never blocks); EVV mutations invalidate detail+session+today+list
│ ├── payment/ # F9 checkout & card capture (b10) + customer invoice read (b11). useCheckoutSummary/useInitiatePayment(caller owns the per-ATTEMPT Idempotency-Key)/useConfirmGatewayReturn/usePaymentOutcome(backoff poll, stops on terminal + bounded attempts)/useInvoice(immutable, long staleTime, 404=not-issued not error); invalidations.ts = the one post-capture cache transition (request detail/lists + bookings lists/detail + summary/outcome — never a blanket refetch); seam+mock(PRIMARY — the conversion trigger bridging the f7↔f8 mock stores: capture converts the request, inserts a confirmed booking, issues the b11-shaped invoice)+client (initiate/invoice = real b10/b11 contract; summary = REQ-016 proposed route; outcome = mapped booking_requests/get, REQ-017). Money = served IRR digit-strings; rows reconcile by construction; a 409 on the money path is benign convergence, never a toast
│ ├── refunds/ # F10 customer cancellation + refund status (b11). resolveCancellationPolicy/cancelBooking/getRefundByBooking/getRefund. useCancellationPolicyPreview/useCancelBooking/useRefundStatus(polls only while non-terminal); invalidations.ts primes the fresh refund + invalidates booking detail/lists on cancel; seam+mock(PRIMARY — reads the f8 bookings store to resolve tier+per-session refundability, flips the booking cancelled, drives card-immediate/BNPL-processing refunds)+client. Contract is admin-only (REQ-019/020/021 fill the customer cancel command, policy preview, refund-by-booking + decomposition). Money = IRR digit-strings, BigInt; refund %+fee disclosed before confirm; refunds never self-issued
│ ├── bnpl/ # F11 BNPL installment checkout (b12) — the alternate branch off C6. useBnplOptions/useCheckEligibility/useBnplSchedule/useIssueBnplToken/useAcceptBnplSchedule(invalidates booking+checkout+wallet)/useBnplOrder(bounded backoff poll)/useWalletInstallments; invalidations.ts reuses f9 invalidateAfterPaymentSuccess + the wallet key; seam+mock(PRIMARY)+client. Mock = the settle bridge: reuses the f9 conversion (mockInsertConvertedBooking + mockMarkBookingRequestConverted) — a settled BNPL order is a card payment net-of-fee — and seeds a provider-reported Wallet plan (D5). Contract serves only eligibility/initiate/status; options/schedule/wallet-installments/D3-KYC/customer-bookingId are REQ-022/023/024 gaps mocked behind the seam. Money = served IRR digit-strings (the mock computes plan/schedule with BigInt; components only format). D5 is provider-reported status, NOT a Balinyaar ledger; early-pay hands off to the provider
│ ├── 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
│ ├── 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)
│ │ # refunds → useRefundPreview/useInitiateRefund/useApproveRefund/useRejectRefund (b11, ticket-linked; REQ-035)
│ │ # payouts → usePayoutBatches/usePayoutBatchDetail/usePreviewPayoutBatch/useRunPayoutBatch(idempotency-keyed)/useRetryPayout/useRecordTransferReference (b13; REQ-036)
│ │ # reviews → useModerationQueue/useModerateReview (b14; REQ-037)
│ │ # tickets → useAdminTickets/useAdminTicket/useAdminTicketThread/usePostAdminMessage (b15; the ADMIN ticket types carry isInternal — the user-app types deliberately do NOT)
│ └── {domain}/
│ ├── types.ts # Request/response types + the domain's Api interface (the seam)
│ ├── keys.ts # React Query key factory (hierarchical)
@@ -299,12 +397,24 @@ async function MyServerComponent() {
- `'coverage'` — the nurse coverage-area editor (whole-city/specific-district scope, chips, duplicate + "won't appear in search" warnings)
- `'catalog'`**shared** catalog vocabulary: the five `price_unit` labels + count nouns + the estimated-total label (read by `PriceDisplay`; f6 reuses it customer-side)
- `'services'` — the f4 nurse Services & prices surface (offerings list, the variant builder steps/fields/validation, the duplicate-listing warning, deactivate confirm)
- `'search'` — the f4 deferred `/search` placeholder (title + "arrives next phase" + query/category echo); f6 fills it out
- `'search'` — the f6 discovery flow (C1/C2/C3): filter section labels, the same-gender facet + hint, sort/count (ICU plural), all four result states + "relax filters" suggestions, card labels (rating/distance/from-price), profile badges (تاییدشده/نظام پرستاری)/attribute chips/specialty codes/services/latest review, and the "درخواست رزرو" CTA
- `'booking'` — the f7 booking-request flow (C4 form fields/validation, C5 tracker steps + dual-countdown + terminal-state copy, the nurse inbox + detail, gender labels, per-status labels, summary-card captions) **and the f8 post-payment engagement** (booking-status timeline labels `bstatus_*`, session-status labels `sstatus_*`, the EVV banner variants `evv_banner_{in_range,out_of_range,no_gps}` + check-in/out CTAs + GPS-acquiring copy, the care-instructions section labels `care_*` + the customer "visible to your nurse only" copy, the money summary `money_*`, the dispute-window note, the bookings list `list_*`); consumed by the C4/C5 pages, the nurse requests pages, the f8 booking-detail/EVV pages, and the shared `BookingRequestSummaryCard` + `booking/` composites
- `'verification'` — the f5 nurse trust flow: B3/B4/B5/B6 copy, per-step labels + status labels (keyed off code, never derived), the DocumentUpload state chrome, TrustBadge labels, the honesty-sensitive manual-vs-auto copy, the publish-gate + shared-SIM/mismatch messages
- `'payment'` — the f9 checkout & invoice surface: C6 labels (breakdown rows هزینه خدمت/کارمزد بالین‌یار/مالیات/مبلغ کل, the **verbatim escrow copy** `escrow_notice`, «ادامه پرداخت ←», the BNPL seam), the card-flow states (initiating/redirecting/pending/failed/expired/already-paid), the confirmation + invoice screens (VAT-on-commission line, مودیان `moadian_*` states), `pstatus_*` transaction-status labels, and the dev mock-gateway harness copy; consumed by the checkout pages, the invoice page, `EscrowNotice`, and `PaymentStatusBadge`
- `'refunds'` — the f10 customer cancellation + refund-status surface: policy-tier labels keyed off `cancellation_policy_code` (`policy_*`), the lead-time + refund %/fee % disclosure, the refund-vs-fee breakdown rows, the multi-session refundable/locked reasons (`reason_*`), the admin-approval explainer, the three refund-status step + chip labels (`step_*`/`rstatus_*`), the per-channel ETA copy (`eta_*``bnpl_revert` 710-business-day window / `psp_card` / `manual`), and the failed/contact-support copy; consumed by the cancel + refund-status pages and `CancellationPolicyDisclosure`/`RefundStatusCard`/`RefundEtaBanner`
- `'bnpl'` — the f11 BNPL installment checkout (D1D5): the ownership-truth copy (`ownership_note`/`contract_note`/`provider_owned_note`/`paid_via_installments` — the agreement is customer↔provider, provider-financed, Balinyaar paid in full), provider names/taglines keyed off `provider_{code}`, the method/plan/eligibility/schedule labels, ICU-`number` plan params (`plan_term_months`/`plan_installments`/`plan_fee`/`down_payment_percent`/`installment_n` — Persian digits on `fa`), the declined/error copy + card fall-back, the D5 wallet outstanding-balance/due-list/`status_*` labels, and the handoff/settle states; consumed by the D1D4 wizard + gateway/return pages, `WalletInstallments`, the reused confirmation, and `BnplPlanCard`/`InstallmentScheduleRow`
- `'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`
- `'auth'` — the phone-OTP login flow, role router, and SelectRole screen (`common.brand`/`brand_tagline` for the wordmark)
- `'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`
**Namespace conventions for the phases to come** (seed each when its feature lands, in both locale
files): `onboarding`, `verification`, `search`, `booking`, `payment`, `bnpl`, `reviews`,
`notifications`, `admin`. Keep top-level keys as namespaces and both files in sync.
files): none — **MVP namespaces complete** (f15 seeded `admin` + `partner`). 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.
@@ -607,9 +717,20 @@ client design, but some hardening needs *server* coordination — don't silently
- **The middleware check is UX-only, not a security boundary:** it decodes the JWT and checks `exp` but
does **not** verify the signature. The API is the only authority; never gate real authorization on the
middleware or `isTokenAlive`.
- **Role gating is coarse:** the shells pick chrome from `currentUser.roles`, but cross-actor route access
isn't hard-guarded client-side yet (the server authorizes each call). Add route guards when a phase needs
them.
- **Role gating is coarse for chrome, fine for the backoffice:** the shells pick chrome from the collapsed
`currentUser.roles` (`useActorRole`). **f15 adds `useAdminCapabilities()` (`@/hooks`)** — a memoized selector
over the session's **fine-grained** `roleCodes` (hydrated from `/me` by `useSessionRoleSync`; `super_admin`/
`admin`/`support`/`finance`/`moderation`) that returns per-console booleans (`canVerify`/`canRefund`/
`canPayout`/`canModerate`/`canConfig`/`canManageAlerts`/`canManageTickets`/`canManagePartners`/`canViewAudit`/
`canManageRoles`). The `AdminLayout` nav and every admin action **hide/disable** on it so a role never sees a
control that will 403 — but it is a **display convenience only; the server authorizes every command** (never
gate real authz on it). Cross-actor route access still isn't hard-guarded client-side; add route guards when a
phase needs them. The **partner portal is a separate scope** — its pages resolve the caller's own center via
`useMyPartnerCenter()` (a 403/404 renders a non-leaking access-denied state), never a raw id.
- **Signed URLs are fetched on demand, never cached long-lived (f15):** verification documents load via a
short-lived signed URL fetched by `useVerificationDocumentUrl(documentId)` (short `staleTime`, `retry:false`) —
`DocumentViewer` re-requests it on expiry/error rather than reading the embedded URL from the long-lived case
query. Reuse this pattern for any short-lived signed asset (invoice PDFs, etc.).
- **Refresh-token rotation is wired** client-side (fetch-layer silent refresh + `useRefresh`), matching the
server's rotation + reuse-detection. The `refresh_token` cookie TTL (7d) is shorter than the server session
default (30d) — a follow-up can align the cookie `maxAge` to `refreshExpiresAt`.
+5
View File
@@ -5,6 +5,11 @@
// Learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';
// next/jest loads .env / .env.test but not .env.development, so NEXT_PUBLIC_API_URL (required by
// `@/config`) is absent in tests. Any test that renders a component using a `services/{domain}` hook pulls
// the real `clientApi` → `@/config` at import time, which throws without this. Provide a harmless default.
process.env.NEXT_PUBLIC_API_URL = process.env.NEXT_PUBLIC_API_URL || 'https://localhost:5002';
// To get 'next/router' working with tests
jest.mock('next/router', () => require('next-router-mock'));
+1238 -7
View File
File diff suppressed because it is too large Load Diff
+1238 -7
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,213 @@
'use client';
import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { Checkbox, FormControlLabel, MenuItem, Paper, Stack, TextField, Typography } from '@mui/material';
import AppButton from '@/components/common/AppButton';
import AppAlert from '@/components/common/AppAlert';
import AppLoading from '@/components/common/AppLoading';
import StepperHeader from '@/components/StepperHeader';
import CancellationPolicyDisclosure from '@/components/CancellationPolicyDisclosure';
import { ApiError } from '@/lib/api/errors';
import { bookingRefundStatusPath, ROUTES } from '@/constants';
import { formatIrrToToman } from '@/utils';
import { useCancelBooking, useCancellationPolicyPreview } from '@/services/refunds';
import type { CancelReasonCategory } from '@/services/refunds/types';
const REASON_CATEGORIES: CancelReasonCategory[] = [
'changed_mind',
'schedule_conflict',
'found_other_care',
'other',
];
/** Maps the cancel mutation's `409` code to a user-facing message; anything else is the generic failure. */
function cancelErrorKey(error: unknown): string {
if (error instanceof ApiError) {
if (error.code === 'not_cancellable') return 'err_not_cancellable';
if (error.code === 'nothing_refundable' || error.code === 'session_not_refundable') {
return 'err_nothing_refundable';
}
}
return 'err_generic';
}
/**
* Cancellation flow (f10) — the trust-first exit. Step 1 **discloses** the resolved policy tier, the
* refund % + fee %, and the concrete Toman amounts (refunded vs kept) **before** anything is submitted;
* the confirm button is gated behind an explicit acknowledgement. Step 2 restates the numbers and submits
* via `useCancelBooking` (which invalidates the booking + primes the refund cache), then routes to the
* refund status. Refunds are admin-approved — the copy makes clear the request is *submitted* and
* *processed by the team*, never self-issued.
*/
export default function CancelBookingPage() {
const params = useParams<{ id: string }>();
const router = useRouter();
const locale = useLocale();
const t = useTranslations('refunds');
const tc = useTranslations('common');
const rawId = Number(params.id);
const bookingId = Number.isInteger(rawId) && rawId > 0 ? rawId : undefined;
const { data: preview, isLoading, isError } = useCancellationPolicyPreview(bookingId);
const cancel = useCancelBooking();
const [step, setStep] = useState<0 | 1>(0);
const [acknowledged, setAcknowledged] = useState(false);
const [reasonCategory, setReasonCategory] = useState<CancelReasonCategory>('changed_mind');
const [reasonNotes, setReasonNotes] = useState('');
const bookingHref = `/${locale}${ROUTES.BOOKINGS}/${bookingId}`;
if (isLoading) return <AppLoading />;
if (isError || !preview || bookingId == null) {
return (
<Stack sx={{ gap: 2, maxWidth: 640, mx: 'auto', width: '100%', py: 2 }}>
<AppAlert severity="error" variant="outlined" sx={{ marginY: 0 }}>
<Stack sx={{ gap: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('error_title')}
</Typography>
<Typography variant="body2">{t('error_body')}</Typography>
</Stack>
</AppAlert>
</Stack>
);
}
if (!preview.cancellable) {
return (
<Stack sx={{ gap: 2, maxWidth: 640, mx: 'auto', width: '100%', py: 2 }}>
<AppAlert severity="info" variant="outlined" sx={{ marginY: 0 }}>
<Stack sx={{ gap: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('not_cancellable_title')}
</Typography>
<Typography variant="body2">{t('not_cancellable_body')}</Typography>
</Stack>
</AppAlert>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
<AppButton
variant="contained"
color="primary"
onClick={() => router.push(`/${locale}${bookingRefundStatusPath(bookingId)}`)}
sx={{ m: 0 }}
>
{t('view_refund_status')}
</AppButton>
<AppButton variant="text" color="inherit" onClick={() => router.push(bookingHref)} sx={{ m: 0 }}>
{t('back_to_booking')}
</AppButton>
</Stack>
</Stack>
);
}
const refundToman = `${formatIrrToToman(preview.refundAmountIrr, locale)} ${tc('currency_toman')}`;
const feeToman = `${formatIrrToToman(preview.feeAmountIrr, locale)} ${tc('currency_toman')}`;
const submit = () =>
cancel.mutate(
{
bookingId,
sessionIds: preview.refundableSessionIds,
reasonCategory,
reasonNotes: reasonNotes.trim() || undefined,
},
{ onSuccess: () => router.push(`/${locale}${bookingRefundStatusPath(bookingId)}`) },
);
return (
<Stack sx={{ gap: 3, maxWidth: 640, mx: 'auto', width: '100%', py: 2 }}>
<Typography variant="h5" component="h1" sx={{ fontWeight: 800 }}>
{t('cancel_title')}
</Typography>
<StepperHeader steps={[t('step_review'), t('step_confirm')]} activeStep={step} />
{step === 0 ? (
<>
<CancellationPolicyDisclosure preview={preview} />
<TextField
select
label={t('reason_field_label')}
value={reasonCategory}
onChange={(event) => setReasonCategory(event.target.value as CancelReasonCategory)}
fullWidth
>
{REASON_CATEGORIES.map((category) => (
<MenuItem key={category} value={category}>
{t(`reason_cat_${category}`)}
</MenuItem>
))}
</TextField>
<TextField
label={t('reason_notes_label')}
value={reasonNotes}
onChange={(event) => setReasonNotes(event.target.value)}
multiline
minRows={2}
fullWidth
/>
<FormControlLabel
control={<Checkbox checked={acknowledged} onChange={(event) => setAcknowledged(event.target.checked)} />}
label={t('acknowledge_label')}
/>
<Stack direction="row" sx={{ gap: 1, justifyContent: 'space-between', flexWrap: 'wrap' }}>
<AppButton variant="text" color="inherit" onClick={() => router.push(bookingHref)} sx={{ m: 0 }}>
{t('back_to_booking')}
</AppButton>
<AppButton
variant="contained"
color="primary"
disabled={!acknowledged}
onClick={() => setStep(1)}
sx={{ m: 0 }}
>
{t('continue_cta')}
</AppButton>
</Stack>
</>
) : (
<>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 800, mb: 1 }}>
{t('confirm_title')}
</Typography>
<Typography variant="body2">{t('confirm_restate', { refund: refundToman, fee: feeToman })}</Typography>
</Paper>
{cancel.isError && (
<AppAlert severity="error" variant="outlined" sx={{ marginY: 0 }}>
{t(cancelErrorKey(cancel.error))}
</AppAlert>
)}
<Stack direction="row" sx={{ gap: 1, justifyContent: 'space-between', flexWrap: 'wrap' }}>
<AppButton
variant="text"
color="inherit"
onClick={() => setStep(0)}
disabled={cancel.isPending}
sx={{ m: 0 }}
>
{tc('back')}
</AppButton>
<AppButton
variant="contained"
color="error"
onClick={submit}
disabled={cancel.isPending}
sx={{ m: 0 }}
>
{cancel.isPending ? t('submitting') : t('confirm_cta')}
</AppButton>
</Stack>
</>
)}
</Stack>
);
}
@@ -0,0 +1,215 @@
'use client';
import { useLocale, useTranslations } from 'next-intl';
import { useParams, useRouter } from 'next/navigation';
import { Divider, GlobalStyles, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, PriceBreakdown, StatusChip, type StatusKind } from '@/components';
import { ROUTES } from '@/constants';
import { ApiError } from '@/lib/api/errors';
import { formatShamsiDate, parseIrr } from '@/utils';
import { useInvoice } from '@/services/payment';
import type { MoadianStatus } from '@/services/payment/types';
/** The printable region — everything else is hidden by the print rules below. */
const PRINT_AREA_CLASS = 'invoice-print-area';
// مودیان registration is a backend concern — surfaced strictly read-only (contract exposes the state).
const MOADIAN_KIND: Record<MoadianStatus, StatusKind> = {
pending: 'pending',
submitted: 'info',
registered: 'verified',
failed: 'rejected',
};
/**
* The booking's commission invoice (b11 `GET invoices/{bookingId}`): header (`invoiceNumber`, Shamsi
* issue date), the reconciling lines with the **VAT-on-commission** line explicitly labelled (product
* rule: VAT is on Balinyaar's commission — the taxable supply — never the nurse's earnings), and the
* مودیان state read-only. Downloads the served `pdfUrl` when present; otherwise prints a clean receipt
* (`window.print()` + a print-scoped visibility rule). Every figure via the money util — no float math;
* the service line is the exact integer remainder of served amounts (gross commission VAT).
*/
export default function BookingInvoicePage() {
const t = useTranslations('payment');
const tc = useTranslations('common');
const locale = useLocale();
const router = useRouter();
const params = useParams<{ id: string }>();
const bookingId = Number(params.id);
const validId = Number.isInteger(bookingId) && bookingId > 0;
const { data: invoice, isLoading, error, refetch } = useInvoice(validId ? bookingId : undefined);
// A malformed id can never load — navigation, not a retry (a manual refetch() bypasses `enabled`).
if (!validId) {
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
<AppIcon icon="error" size={44} color="var(--bal-error)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('error_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('invalid_link_body')}
</Typography>
<AppButton variant="contained" onClick={() => router.push(`/${locale}${ROUTES.BOOKINGS}`)} sx={{ m: 0 }}>
{t('view_booking')}
</AppButton>
</Stack>
</Paper>
);
}
if (isLoading) {
return (
<Stack sx={{ gap: 2 }}>
<Skeleton variant="text" width="40%" height={36} />
<Skeleton variant="rounded" height={120} />
<Skeleton variant="rounded" height={200} />
</Stack>
);
}
if (!invoice) {
const notIssued = error instanceof ApiError && error.status === 404;
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
<AppIcon icon={notIssued ? 'document' : 'error'} size={44} color={notIssued ? 'var(--bal-warning)' : 'var(--bal-error)'} />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{notIssued ? t('invoice_not_issued_title') : t('error_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{notIssued ? t('invoice_not_issued_body') : t('error_body')}
</Typography>
{notIssued ? (
<AppButton variant="contained" onClick={() => router.push(`/${locale}${ROUTES.BOOKINGS}/${bookingId}`)} sx={{ m: 0 }}>
{t('view_booking')}
</AppButton>
) : (
<AppButton variant="contained" onClick={() => refetch()} sx={{ m: 0 }}>
{tc('retry')}
</AppButton>
)}
</Stack>
</Paper>
);
}
// The receipt must print on paper colors: the tokens are attribute-driven, so a dark-scheme user
// would otherwise print cream-on-dark. Flip to the light tokens for the print dialog and restore
// after it closes (afterprint) — no hard-coded colors, the same token system does the work.
const handlePrint = () => {
const root = document.documentElement;
const previous = root.getAttribute('data-mui-color-scheme');
if (previous === 'dark') {
const restore = () => {
root.setAttribute('data-mui-color-scheme', previous);
window.removeEventListener('afterprint', restore);
};
window.addEventListener('afterprint', restore);
root.setAttribute('data-mui-color-scheme', 'light');
}
window.print();
};
// Display rows from served amounts only, integer-safe: the service line is the exact remainder, so
// service + commission + VAT reconciles to the gross total by construction.
const serviceIrr = (
parseIrr(invoice.grossIrr) - parseIrr(invoice.platformCommissionIrr) - parseIrr(invoice.vatIrr)
).toString();
// maximumFractionDigits: the default (0) would silently round a fractional served rate (e.g. 9.5%).
const vatPercent = new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US', {
style: 'percent',
maximumFractionDigits: 2,
}).format(invoice.vatRate);
return (
<Stack sx={{ gap: 2 }}>
<GlobalStyles
styles={{
'@media print': {
'body *': { visibility: 'hidden' },
[`.${PRINT_AREA_CLASS}, .${PRINT_AREA_CLASS} *`]: { visibility: 'visible' },
[`.${PRINT_AREA_CLASS}`]: { position: 'absolute', top: 0, insetInlineStart: 0, width: '100%' },
},
}}
/>
<Paper
elevation={0}
className={PRINT_AREA_CLASS}
sx={{ p: 3, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
>
<Stack sx={{ gap: 2 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 2 }}>
<Typography variant="h6" component="h1">
{t('invoice_title')}
</Typography>
{/* The issuer line uses the product spelling «بالین‌یار» — fa `common.brand` currently reads
«بلینیار» (the auth wordmark); a fiscal document must match the product/docs spelling. */}
<Typography variant="subtitle2" sx={{ color: 'var(--bal-primary)', fontWeight: 700 }}>
{t('issuer_platform')}
</Typography>
</Stack>
<Divider />
<Stack sx={{ gap: 0.75 }}>
<MetaRow label={t('invoice_number_label')} value={invoice.invoiceNumber} ltr />
<MetaRow label={t('invoice_issued_at')} value={formatShamsiDate(invoice.issuedAt, locale)} />
</Stack>
<PriceBreakdown
rows={[
{ key: 'service_cost', label: t('row_service_cost'), amountIrr: serviceIrr },
{ key: 'commission', label: t('row_commission'), amountIrr: invoice.platformCommissionIrr },
{
key: 'vat',
label: `${t('invoice_vat_on_commission')} (${vatPercent})`,
amountIrr: invoice.vatIrr,
},
]}
totalLabel={t('row_total')}
totalAmountIrr={invoice.grossIrr}
/>
{invoice.moadianStatus ? (
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 2 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('moadian_label')}
</Typography>
<StatusChip status={MOADIAN_KIND[invoice.moadianStatus]} label={t(`moadian_${invoice.moadianStatus}`)} />
</Stack>
) : null}
</Stack>
</Paper>
<Stack sx={{ gap: 1 }}>
{invoice.pdfUrl ? (
<AppButton color="primary" variant="contained" startIcon="document" href={invoice.pdfUrl} openInNewTab sx={{ m: 0 }}>
{t('download_invoice')}
</AppButton>
) : (
<AppButton color="primary" variant="contained" startIcon="document" onClick={handlePrint} sx={{ m: 0 }}>
{t('print_invoice')}
</AppButton>
)}
<AppButton variant="text" onClick={() => router.push(`/${locale}${ROUTES.BOOKINGS}/${bookingId}`)} sx={{ m: 0 }}>
{t('view_booking')}
</AppButton>
</Stack>
</Stack>
);
}
function MetaRow({ label, value, ltr }: { label: string; value: string; ltr?: boolean }) {
return (
<Stack direction="row" sx={{ justifyContent: 'space-between', gap: 2 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{label}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }} dir={ltr ? 'ltr' : undefined}>
{value}
</Typography>
</Stack>
);
}
@@ -0,0 +1,126 @@
'use client';
import { useParams, useRouter } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { Stack, Typography } from '@mui/material';
import { BookingDetailView } from '@/components/booking';
import { BookingSupportEntry } from '@/components/messaging';
import RefundStatusCard from '@/components/RefundStatusCard';
import AppButton from '@/components/common/AppButton';
import { bookingCancelPath, bookingRefundStatusPath, bookingReviewPath } from '@/constants';
import { useBookingDetail } from '@/services/bookings';
import { useRefundStatus } from '@/services/refunds';
import { isBookingCancellable } from '@/services/refunds/types';
import { useMyReviewForBooking } from '@/services/reviews';
/**
* Customer booking detail (`/bookings/{id}`) — the read-only both-roles view in the **customer** shell:
* server-truth status timeline, session schedule, and money summary. Care instructions are gated to the
* assigned nurse, so the customer sees the "visible to your nurse only" affordance (the query never fires).
*
* f10 hangs the cancellation/refund entry off this screen: a **Cancel booking** CTA while the booking is
* cancellable, or the **refund status** section once it's cancelled — both page-only glue (the booking
* domain stays decoupled from refunds).
*/
export default function CustomerBookingDetailPage() {
const params = useParams<{ id: string }>();
const id = Number(params.id);
const bookingId = Number.isInteger(id) && id > 0 ? id : -1;
return (
<Stack sx={{ gap: 3 }}>
<BookingDetailView bookingId={bookingId} viewerRole="customer" />
{bookingId > 0 && <CustomerBookingActions bookingId={bookingId} />}
{bookingId > 0 && <LeaveReviewCta bookingId={bookingId} />}
{bookingId > 0 && <BookingSupportEntry bookingId={bookingId} role="customer" />}
</Stack>
);
}
/**
* The f13 leave-a-review entry — reads the already-cached booking detail (no extra fetch) and only offers the
* CTA once the booking is completed/closed. If the customer has already reviewed it, the CTA becomes a passive
* "under review" affordance (the review is `pending_moderation` and never shown publicly here). The my-review
* read is enabled only for a review-eligible booking, so an active booking triggers no reviews query.
*/
function LeaveReviewCta({ bookingId }: { bookingId: number }) {
const router = useRouter();
const locale = useLocale();
const t = useTranslations('reviews');
const { data: booking } = useBookingDetail(bookingId, 'customer');
const reviewable = booking?.status === 'completed' || booking?.status === 'closed';
const { data: myReview } = useMyReviewForBooking(bookingId, { enabled: reviewable });
if (!booking || !reviewable) return null;
const alreadyReviewed = Boolean(myReview && myReview.status !== 'none');
const underReview = myReview?.status === 'pending_moderation';
return (
<Stack sx={{ maxWidth: 640, mx: 'auto', width: '100%' }}>
<AppButton
variant={alreadyReviewed ? 'outlined' : 'contained'}
color="primary"
startIcon="star"
onClick={() => router.push(`/${locale}${bookingReviewPath(bookingId)}`)}
sx={{ m: 0 }}
>
{alreadyReviewed ? (underReview ? t('cta_under_review') : t('cta_view_review')) : t('cta_leave')}
</AppButton>
</Stack>
);
}
/**
* The customer's cancel/refund entry — reads the already-cached booking detail (same query key as
* `BookingDetailView`, so no extra fetch) to decide between the Cancel CTA and the refund section. The
* refund read is enabled only once the booking is cancelled, so an active booking triggers no refund query.
*/
function CustomerBookingActions({ bookingId }: { bookingId: number }) {
const router = useRouter();
const locale = useLocale();
const t = useTranslations('refunds');
const { data: booking } = useBookingDetail(bookingId, 'customer');
const isCancelled = booking?.status === 'cancelled';
const { data: refund } = useRefundStatus(bookingId, { enabled: isCancelled });
if (!booking) return null;
if (isBookingCancellable(booking.status)) {
return (
<Stack sx={{ maxWidth: 640, mx: 'auto', width: '100%' }}>
<AppButton
variant="outlined"
color="error"
startIcon="rejected"
onClick={() => router.push(`/${locale}${bookingCancelPath(bookingId)}`)}
sx={{ m: 0 }}
>
{t('cancel_booking_cta')}
</AppButton>
</Stack>
);
}
if (isCancelled && refund) {
return (
<Stack sx={{ gap: 1.5, maxWidth: 640, mx: 'auto', width: '100%' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('refund_section_title')}
</Typography>
<RefundStatusCard refund={refund} />
<AppButton
variant="text"
color="primary"
onClick={() => router.push(`/${locale}${bookingRefundStatusPath(bookingId)}`)}
sx={{ m: 0, alignSelf: 'flex-start' }}
>
{t('view_refund_status')}
</AppButton>
</Stack>
);
}
return null;
}
@@ -0,0 +1,75 @@
'use client';
import { useParams, useRouter } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { Stack, Typography } from '@mui/material';
import AppButton from '@/components/common/AppButton';
import AppAlert from '@/components/common/AppAlert';
import AppLoading from '@/components/common/AppLoading';
import RefundStatusCard from '@/components/RefundStatusCard';
import { ROUTES } from '@/constants';
import { useRefundStatus } from '@/services/refunds';
/**
* Customer refund status (f10) — read-only. Renders the three-step progress (pending → on-its-way →
* completed), the refunded amount, the honest per-channel ETA (BNPL's ~710-day window), and — where the
* backend serves it — the fee-leg split. `failed` shows a needs-attention / contact-support state, never a
* retry (retry is admin-only, DEFERRED to f15). Polling runs only while the refund is non-terminal (see
* `useRefundStatus`). An empty state renders when the booking has no refund (e.g. it wasn't cancelled).
*/
export default function RefundStatusPage() {
const params = useParams<{ id: string }>();
const router = useRouter();
const locale = useLocale();
const t = useTranslations('refunds');
const rawId = Number(params.id);
const bookingId = Number.isInteger(rawId) && rawId > 0 ? rawId : undefined;
const { data: refund, isLoading, isError } = useRefundStatus(bookingId);
return (
<Stack sx={{ gap: 3, maxWidth: 640, mx: 'auto', width: '100%', py: 2 }}>
<Typography variant="h5" component="h1" sx={{ fontWeight: 800 }}>
{t('status_title')}
</Typography>
{isLoading ? (
<AppLoading />
) : isError ? (
<AppAlert severity="error" variant="outlined" sx={{ marginY: 0 }}>
<Stack sx={{ gap: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('error_title')}
</Typography>
<Typography variant="body2">{t('error_body')}</Typography>
</Stack>
</AppAlert>
) : refund ? (
<>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('status_subtitle')}
</Typography>
<RefundStatusCard refund={refund} />
</>
) : (
<AppAlert severity="info" variant="outlined" sx={{ marginY: 0 }}>
<Stack sx={{ gap: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('no_refund_title')}
</Typography>
<Typography variant="body2">{t('no_refund_body')}</Typography>
</Stack>
</AppAlert>
)}
<AppButton
variant="text"
color="inherit"
onClick={() => router.push(`/${locale}${ROUTES.BOOKINGS}/${bookingId}`)}
sx={{ m: 0, alignSelf: 'flex-start' }}
>
{t('back_to_booking')}
</AppButton>
</Stack>
);
}
@@ -0,0 +1,201 @@
'use client';
import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useParams, useRouter } from 'next/navigation';
import { useSnackbar } from 'notistack';
import { Paper, Skeleton, Stack, TextField, Typography } from '@mui/material';
import { AppButton, RatingInput, ReviewTagSelector, StatusChip } from '@/components';
import type { StatusKind } from '@/components';
import { formatShamsiDate } from '@/utils';
import { useBookingDetail } from '@/services/bookings';
import { useReviewEligibility, useMyReviewForBooking, useCreateReview } from '@/services/reviews';
import { REVIEW_TAG_CODES, type ModerationStatus } from '@/services/reviews/types';
const REVIEW_BODY_MAX = 2000;
/** moderationStatus → StatusChip kind (published=success, pending=warning, rejected=error, hidden=neutral). */
const STATUS_KIND: Record<ModerationStatus, StatusKind> = {
pending_moderation: 'pending',
published: 'verified',
hidden: 'neutral',
rejected: 'rejected',
};
/**
* f13 — Leave a review («ثبت نظر»): the customer's one moderated review for a completed booking. The form is
* shown only when the server says the booking can be reviewed AND it is not already reviewed; on submit it
* flips to the persistent "under review" state (the review is `pending_moderation` and never appears publicly
* here). One review per booking (1:1) — a returning customer sees their review's state, never a second form.
*/
export default function LeaveReviewPage() {
const t = useTranslations('reviews');
const tc = useTranslations('common');
const locale = useLocale();
const router = useRouter();
const { enqueueSnackbar } = useSnackbar();
const params = useParams<{ id: string }>();
const rawId = Number(params.id);
const bookingId = Number.isInteger(rawId) && rawId > 0 ? rawId : -1;
const { data: booking } = useBookingDetail(bookingId, 'customer');
const eligibility = useReviewEligibility(bookingId);
const myReview = useMyReviewForBooking(bookingId);
const createReview = useCreateReview();
const [rating, setRating] = useState(0);
const [body, setBody] = useState('');
const [tagCodes, setTagCodes] = useState<string[]>([]);
const nurseName = booking?.nurseName?.trim();
const submit = () => {
if (rating < 1) return;
createReview.mutate(
{ bookingId, body: { rating, body: body.trim() || null, tagCodes } },
{ onError: () => enqueueSnackbar(t('error_submit'), { variant: 'error' }) },
);
};
// ── Already reviewed → the persistent under-review / published state (never a second form) ───────────────
const existing = myReview.data;
const submittedThisSession = createReview.isSuccess;
if ((existing && existing.status !== 'none') || submittedThisSession) {
const status: ModerationStatus =
existing && existing.status !== 'none' ? existing.status : 'pending_moderation';
const shownRating = existing && existing.status !== 'none' ? (existing.rating ?? rating) : rating;
const shownBody = existing && existing.status !== 'none' ? existing.body : body.trim() || null;
const shownTags = existing && existing.status !== 'none' ? existing.tagCodes : tagCodes;
return (
<Stack sx={{ gap: 3, maxWidth: 560, mx: 'auto', width: '100%' }}>
<PageHeading title={t('my_review_title')} subtitle={nurseName ? t('for_nurse', { name: nurseName }) : undefined} />
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1.5 }}>
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<StatusChip status={STATUS_KIND[status]} label={t(`status_${status}`)} />
{existing?.createdAt ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{formatShamsiDate(existing.createdAt, locale)}
</Typography>
) : null}
</Stack>
{status === 'pending_moderation' ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('under_review_body')}
</Typography>
) : null}
<RatingInput value={shownRating} readOnly size={22} ariaLabel={t('rating_label')} />
{shownBody ? <Typography variant="body2">{shownBody}</Typography> : null}
{shownTags.length > 0 ? (
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
{shownTags.map((code) => (
<StatusChip key={code} status="info" label={t.has(`tag_${code}`) ? t(`tag_${code}`) : code} />
))}
</Stack>
) : null}
</Stack>
</Paper>
<AppButton variant="outlined" color="primary" onClick={() => router.back()} sx={{ m: 0, alignSelf: 'flex-start' }}>
{tc('back')}
</AppButton>
</Stack>
);
}
if (eligibility.isLoading || myReview.isLoading) {
return (
<Stack sx={{ gap: 2, maxWidth: 560, mx: 'auto', width: '100%' }}>
<Skeleton variant="text" width="50%" height={36} />
<Skeleton variant="rounded" height={56} />
<Skeleton variant="rounded" height={120} />
<Skeleton variant="rounded" height={72} />
</Stack>
);
}
// ── Not eligible → a clear, non-leaking reason (no form) ─────────────────────────────────────────────────
if (!eligibility.data?.canReview) {
const reason = eligibility.data?.reason ?? 'not_completed';
return (
<Stack sx={{ gap: 3, maxWidth: 560, mx: 'auto', width: '100%' }}>
<PageHeading title={t('not_eligible_title')} />
<Paper elevation={0} sx={{ p: 3, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t(`reason_${reason}`)}
</Typography>
</Paper>
<AppButton variant="outlined" color="primary" onClick={() => router.back()} sx={{ m: 0, alignSelf: 'flex-start' }}>
{tc('back')}
</AppButton>
</Stack>
);
}
// ── Eligible → the review form ───────────────────────────────────────────────────────────────────────────
return (
<Stack sx={{ gap: 3, maxWidth: 560, mx: 'auto', width: '100%' }}>
<PageHeading title={t('title')} subtitle={nurseName ? t('for_nurse', { name: nurseName }) : t('subtitle')} />
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('rating_label')}
</Typography>
<RatingInput value={rating} onChange={setRating} ariaLabel={t('rating_label')} />
</Stack>
<TextField
label={t('body_label')}
placeholder={t('body_placeholder')}
value={body}
onChange={(e) => setBody(e.target.value.slice(0, REVIEW_BODY_MAX))}
multiline
minRows={3}
fullWidth
/>
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('tags_label')}
</Typography>
<ReviewTagSelector
codes={REVIEW_TAG_CODES}
selected={tagCodes}
onChange={setTagCodes}
labelFor={(code) => (t.has(`tag_${code}`) ? t(`tag_${code}`) : code)}
disabled={createReview.isPending}
/>
</Stack>
<Stack direction="row" sx={{ gap: 1, justifyContent: 'flex-end' }}>
<AppButton variant="text" color="primary" onClick={() => router.back()} sx={{ m: 0 }} disabled={createReview.isPending}>
{tc('cancel')}
</AppButton>
<AppButton
variant="contained"
color="primary"
onClick={submit}
disabled={rating < 1 || createReview.isPending}
startIcon="star"
sx={{ m: 0 }}
>
{createReview.isPending ? t('submitting') : t('submit')}
</AppButton>
</Stack>
</Stack>
);
}
function PageHeading({ title, subtitle }: { title: string; subtitle?: string }) {
return (
<Stack sx={{ gap: 0.5 }}>
<Typography variant="h5" component="h1">
{title}
</Typography>
{subtitle ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{subtitle}
</Typography>
) : null}
</Stack>
);
}
@@ -0,0 +1,222 @@
'use client';
import { FunctionComponent, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Checkbox, FormControlLabel, Paper, Stack, TextField, Typography } from '@mui/material';
import { AppButton, AppIcon, PhoneNumberField } from '@/components';
import { digitsOnly, formatIrrToToman } from '@/utils';
import { useCheckEligibility } from '@/services/bnpl';
import { NATIONAL_ID_LENGTH, NATIONAL_ID_PATTERN } from '@/services/bnpl/constants';
import type { BnplEligibilityResult, ProviderCode } from '@/services/bnpl/types';
interface EligibilityStepProps {
bookingRequestId: number;
providerCode: ProviderCode;
/** Mobile prefilled from the session (may be empty if unknown). */
sessionMobile: string;
/** A prior approval to re-show on back-navigation from D4 (so the approved panel survives, not the form). */
initialResult?: BnplEligibilityResult | null;
onApproved: (result: BnplEligibilityResult) => void;
onPayWithCard: () => void;
}
/**
* D3 · اعتبارسنجی — the provider credit check. کد ملی (client-side format only — the real check is the
* provider's), موبایل (prefilled from the session, read-only), and a consent checkbox that **gates** the
* submit. On approval → the credit ceiling + «تایید و ادامه» → D4. On decline / ceiling-exceeded → the
* declined panel + a card fall-back (never a dead end). The verdict is surfaced, never pre-judged.
*/
const EligibilityStep: FunctionComponent<EligibilityStepProps> = ({
bookingRequestId,
providerCode,
sessionMobile,
initialResult,
onApproved,
onPayWithCard,
}) => {
const t = useTranslations('bnpl');
const tc = useTranslations('common');
const locale = useLocale();
const [nationalId, setNationalId] = useState('');
const [consent, setConsent] = useState(false);
const [submitted, setSubmitted] = useState(false);
const check = useCheckEligibility();
// A fresh check wins; otherwise re-show a prior approval carried back from D4.
const result = check.data ?? initialResult ?? undefined;
const nationalIdValid = NATIONAL_ID_PATTERN.test(nationalId);
const nationalIdError = submitted && !nationalIdValid;
const providerName = t(`provider_${providerCode}`);
const handleSubmit = () => {
setSubmitted(true);
if (!nationalIdValid || !consent) return;
check.mutate({ bookingRequestId, providerCode, nationalId, mobile: sessionMobile, consent });
};
// Approved — show the ceiling + advance.
if (result?.isEligible) {
return (
<Stack sx={{ gap: 2 }}>
<Paper
elevation={0}
sx={{
p: 2.5,
borderRadius: 2,
border: '1px solid',
borderColor: 'var(--bal-success)',
backgroundColor: 'var(--bal-primary-soft)',
textAlign: 'center',
}}
>
<Stack sx={{ gap: 0.5, alignItems: 'center' }}>
<AppIcon icon="verified" size={36} color="var(--bal-success)" />
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: 'var(--bal-success)' }}>
{t('approved_title')}
</Typography>
{result.creditCeilingIrr ? (
<>
<Typography variant="caption" sx={{ color: 'text.secondary', mt: 0.5 }}>
{t('credit_ceiling_label')}
</Typography>
<Typography variant="h6" sx={{ fontWeight: 800 }}>
{formatIrrToToman(result.creditCeilingIrr, locale)} {tc('currency_toman')}
</Typography>
</>
) : null}
</Stack>
</Paper>
<AppButton color="secondary" variant="contained" size="large" onClick={() => onApproved(result)} sx={{ m: 0 }}>
{t('approve_continue')}
</AppButton>
</Stack>
);
}
// Declined (not_eligible / ceiling_exceeded) — a clear panel + the card fall-back.
if (result && !result.isEligible) {
const ceiling = result.eligibilityStatus === 'ceiling_exceeded';
return (
<DeclinedPanel
title={ceiling ? t('declined_ceiling_title') : t('declined_not_eligible_title')}
body={ceiling ? t('declined_ceiling_body') : t('declined_not_eligible_body')}
cardLabel={t('pay_with_card')}
onPayWithCard={onPayWithCard}
/>
);
}
// Error / timeout — retry or fall back to card.
if (check.isError) {
return (
<DeclinedPanel
title={t('eligibility_title')}
body={t('eligibility_error')}
cardLabel={t('pay_with_card')}
onPayWithCard={onPayWithCard}
onRetry={handleSubmit}
retryLabel={tc('retry')}
/>
);
}
return (
<Stack sx={{ gap: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('eligibility_title')}
</Typography>
<TextField
label={t('national_id_label')}
placeholder={t('national_id_placeholder')}
value={nationalId}
onChange={(e) => setNationalId(digitsOnly(e.target.value).slice(0, NATIONAL_ID_LENGTH))}
error={nationalIdError}
helperText={nationalIdError ? t('national_id_invalid') : undefined}
slotProps={{ htmlInput: { dir: 'ltr', inputMode: 'numeric', maxLength: NATIONAL_ID_LENGTH, style: { textAlign: 'start' } } }}
fullWidth
/>
<PhoneNumberField
label={t('mobile_label')}
value={sessionMobile}
onChange={() => undefined}
disabled
fullWidth
/>
<FormControlLabel
control={<Checkbox checked={consent} onChange={(e) => setConsent(e.target.checked)} color="secondary" />}
label={
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('consent_label', { provider: providerName })}
</Typography>
}
sx={{ alignItems: 'flex-start', m: 0 }}
/>
<Stack sx={{ gap: 1 }}>
<AppButton
color="secondary"
variant="contained"
size="large"
disabled={!consent || check.isPending}
onClick={handleSubmit}
sx={{ m: 0 }}
>
{t('check_eligibility')}
</AppButton>
<AppButton variant="text" color="primary" onClick={onPayWithCard} sx={{ m: 0 }}>
{t('pay_with_card')}
</AppButton>
</Stack>
</Stack>
);
};
function DeclinedPanel({
title,
body,
cardLabel,
onPayWithCard,
onRetry,
retryLabel,
}: {
title: string;
body: string;
cardLabel: string;
onPayWithCard: () => void;
onRetry?: () => void;
retryLabel?: string;
}) {
return (
<Stack sx={{ gap: 2 }}>
<Paper
elevation={0}
sx={{ p: 3, borderRadius: 2, border: '1px solid', borderColor: 'var(--bal-error)', textAlign: 'center' }}
>
<Stack sx={{ gap: 0.5, alignItems: 'center' }}>
<AppIcon icon="rejected" size={36} color="var(--bal-error)" />
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{title}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{body}
</Typography>
</Stack>
</Paper>
<Stack sx={{ gap: 1 }}>
{onRetry && retryLabel ? (
<AppButton variant="outlined" color="secondary" onClick={onRetry} sx={{ m: 0 }}>
{retryLabel}
</AppButton>
) : null}
<AppButton color="primary" variant="contained" size="large" onClick={onPayWithCard} sx={{ m: 0 }}>
{cardLabel}
</AppButton>
</Stack>
</Stack>
);
}
export default EligibilityStep;
@@ -0,0 +1,206 @@
'use client';
import { FunctionComponent } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Box, ButtonBase, Paper, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon } from '@/components';
import { formatIrrToToman } from '@/utils';
import type { BnplOptions, BnplProvider, ProviderCode } from '@/services/bnpl/types';
interface MethodStepProps {
options: BnplOptions;
selectedProvider: ProviderCode | null;
onSelectProvider: (code: ProviderCode) => void;
onContinue: () => void;
onPayWithCard: () => void;
}
/** Two-letter provider glyph for the logo stand-in (real logos land with the provider assets). */
const PROVIDER_GLYPH: Record<ProviderCode, string> = {
digipay: 'DG',
snapppay: 'SP',
balinyaar: 'ب',
tara: 'TA',
torobpay: 'TP',
};
/**
* D1 · روش پرداخت — the branch off C6. Shows the payable amount, the full-card option (returns to the f9
* card flow — never rebuilt here), and the installment providers loaded **from the contract/mock** (never
* hardcoded). Primary action «ادامه با {provider}». Empty provider set → only the card option.
*/
const MethodStep: FunctionComponent<MethodStepProps> = ({
options,
selectedProvider,
onSelectProvider,
onContinue,
onPayWithCard,
}) => {
const t = useTranslations('bnpl');
const tc = useTranslations('common');
const locale = useLocale();
const hasProviders = options.providers.length > 0;
return (
<Stack sx={{ gap: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('method_title')}
</Typography>
<Paper
elevation={0}
sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider', textAlign: 'center' }}
>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('payable_amount')}
</Typography>
<Typography variant="h6" sx={{ fontWeight: 800, mt: 0.5 }}>
{formatIrrToToman(options.orderAmountIrr, locale)} {tc('currency_toman')}
</Typography>
</Paper>
{/* Full-card option — selecting it continues the f9 card flow (C6), which this phase does not rebuild. */}
<ButtonBase
onClick={onPayWithCard}
sx={{
display: 'block',
width: '100%',
textAlign: 'start',
borderRadius: 2,
p: 1.75,
border: '1px solid',
borderColor: 'divider',
}}
>
<Stack direction="row" sx={{ alignItems: 'center', gap: 1.5 }}>
<AppIcon icon="payment" size={24} color="var(--bal-primary)" />
<Stack sx={{ flex: 1, gap: 0.25 }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{t('method_card')}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('method_card_hint')}
</Typography>
</Stack>
</Stack>
</ButtonBase>
{hasProviders ? (
<>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: 'var(--bal-secondary-dark)' }}>
{t('installments_heading')}
</Typography>
{/* Ownership disclosure at the point of choice: the provider finances & owns the repayment. */}
<Typography variant="caption" sx={{ color: 'text.secondary', lineHeight: 1.8 }}>
{t('ownership_note')}
</Typography>
<Stack sx={{ gap: 1 }}>
{options.providers.map((provider) => (
<ProviderOption
key={provider.providerCode}
provider={provider}
selected={selectedProvider === provider.providerCode}
onSelect={() => onSelectProvider(provider.providerCode)}
/>
))}
</Stack>
<AppButton
color="secondary"
variant="contained"
size="large"
disabled={selectedProvider == null}
onClick={onContinue}
sx={{ m: 0 }}
>
{selectedProvider
? t('continue_with', { provider: t(`provider_${selectedProvider}`) })
: t('continue')}
</AppButton>
</>
) : (
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 2, border: '1px dashed', borderColor: 'divider' }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{t('no_providers_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
{t('no_providers_body')}
</Typography>
</Paper>
)}
</Stack>
);
};
function ProviderOption({
provider,
selected,
onSelect,
}: {
provider: BnplProvider;
selected: boolean;
onSelect: () => void;
}) {
const t = useTranslations('bnpl');
return (
<ButtonBase
data-provider={provider.providerCode}
data-selected={selected}
aria-pressed={selected}
onClick={onSelect}
sx={{
display: 'block',
width: '100%',
textAlign: 'start',
borderRadius: 2,
p: 1.5,
border: '1px solid',
borderColor: selected ? 'var(--bal-secondary)' : 'divider',
borderWidth: selected ? 2 : 1,
backgroundColor: selected ? 'var(--bal-secondary-soft)' : 'transparent',
}}
>
<Stack direction="row" sx={{ alignItems: 'center', gap: 1.5 }}>
<Box
sx={{
width: 40,
height: 28,
borderRadius: 1,
flex: 'none',
display: 'grid',
placeItems: 'center',
fontWeight: 800,
fontSize: 11,
color: 'var(--bal-secondary-dark)',
backgroundColor: 'var(--bal-secondary-soft)',
}}
>
{PROVIDER_GLYPH[provider.providerCode]}
</Box>
<Stack sx={{ flex: 1, gap: 0.25 }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{t(`provider_${provider.providerCode}`)}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t(`provider_tagline_${provider.providerCode}`)}
</Typography>
</Stack>
{/* Decorative radio indicator — the whole card is the ButtonBase; a real <input> here would nest
interactive content inside a <button> (invalid HTML) and warn on checked-without-onChange. */}
<Box
aria-hidden
sx={{
width: 18,
height: 18,
borderRadius: '50%',
flex: 'none',
border: '2px solid',
borderColor: selected ? 'var(--bal-secondary)' : 'var(--bal-divider)',
backgroundColor: selected ? 'var(--bal-secondary)' : 'transparent',
boxShadow: selected ? 'inset 0 0 0 3px var(--bal-bg-paper)' : 'none',
}}
/>
</Stack>
</ButtonBase>
);
}
export default MethodStep;
@@ -0,0 +1,108 @@
'use client';
import { FunctionComponent } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Paper, Stack, Typography } from '@mui/material';
import { AppButton, BnplPlanCard } from '@/components';
import { formatIrrToToman } from '@/utils';
import type { BnplPlanOption, ProviderCode } from '@/services/bnpl/types';
interface PlanStepProps {
providerCode: ProviderCode;
plans: BnplPlanOption[];
selectedPlanId: string | null;
onSelectPlan: (planId: string) => void;
onContinue: () => void;
onBack: () => void;
}
/**
* D2 · انتخاب طرح اقساط — the plan selector for the chosen provider. Shows the total amount and the plan
* options the contract returned (monthly amount + down-payment %) as a single-select terracotta card group.
* Every amount comes through the money util from served IRR strings — the client computes nothing about
* money. Empty plans → back to D1.
*/
const PlanStep: FunctionComponent<PlanStepProps> = ({
providerCode,
plans,
selectedPlanId,
onSelectPlan,
onContinue,
onBack,
}) => {
const t = useTranslations('bnpl');
const tc = useTranslations('common');
const locale = useLocale();
if (plans.length === 0) {
return (
<Stack sx={{ gap: 2 }}>
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 2, border: '1px dashed', borderColor: 'divider' }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{t('no_plans_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
{t('no_plans_body')}
</Typography>
</Paper>
<AppButton variant="outlined" color="primary" onClick={onBack} sx={{ m: 0 }}>
{t('back_to_providers')}
</AppButton>
</Stack>
);
}
// The plan total is a per-plan served figure (interest-free plans = order gross; fee plans add the fee).
// Use the selected plan's total, falling back to the first plan's for the header before any selection.
const shownPlan = plans.find((p) => p.planId === selectedPlanId) ?? plans[0];
return (
<Stack sx={{ gap: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('plan_title', { provider: t(`provider_${providerCode}`) })}
</Typography>
<Paper
elevation={0}
sx={{ p: 1.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}
>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('total_amount')}
</Typography>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>
{formatIrrToToman(shownPlan.totalIrr, locale)} {tc('currency_toman')}
</Typography>
</Stack>
</Paper>
<Stack sx={{ gap: 1 }}>
{plans.map((plan) => (
<BnplPlanCard
key={plan.planId}
plan={plan}
selected={selectedPlanId === plan.planId}
onSelect={onSelectPlan}
/>
))}
</Stack>
<Stack sx={{ gap: 1 }}>
<AppButton
color="secondary"
variant="contained"
size="large"
disabled={selectedPlanId == null}
onClick={onContinue}
sx={{ m: 0 }}
>
{t('continue')}
</AppButton>
<AppButton variant="text" color="primary" onClick={onBack} sx={{ m: 0 }}>
{tc('back')}
</AppButton>
</Stack>
</Stack>
);
};
export default PlanStep;
@@ -0,0 +1,174 @@
'use client';
import { FunctionComponent, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Checkbox, CircularProgress, FormControlLabel, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, InstallmentScheduleRow } from '@/components';
import AppAlert from '@/components/common/AppAlert';
import { ApiError } from '@/lib/api/errors';
import { useBnplSchedule, useIssueBnplToken } from '@/services/bnpl';
import type { IssueBnplTokenResult, ProviderCode } from '@/services/bnpl/types';
interface ScheduleStepProps {
bookingRequestId: number;
providerCode: ProviderCode;
planId: string;
onBack: () => void;
/** The provider handoff — the page follows `redirectUrl`. */
onIssued: (result: IssueBnplTokenResult) => void;
/** A `409` (already paid / in progress / window lapsed) — the page converges by reading the order. */
onConverged: () => void;
}
/**
* D4 · تایید طرح و قرارداد — the repayment schedule + contract acceptance. Renders the **served** repayment
* rows (پیش‌پرداخت today + قسط ۱…N with Shamsi due dates + amounts), the ownership-truth note (the
* agreement is customer ↔ provider; Balinyaar is paid in full), and a contract-acceptance checkbox that
* **gates** the final action. «تایید نهایی و پرداخت پیش‌پرداخت» issues the provider token and hands off
* (the page follows the redirect); on success the booking confirms exactly as the card path.
*/
const ScheduleStep: FunctionComponent<ScheduleStepProps> = ({
bookingRequestId,
providerCode,
planId,
onBack,
onIssued,
onConverged,
}) => {
const t = useTranslations('bnpl');
const tc = useTranslations('common');
const { data: schedule, isLoading, isError, refetch } = useBnplSchedule(bookingRequestId, providerCode, planId);
const issue = useIssueBnplToken();
const [accepted, setAccepted] = useState(false);
// One idempotency key per handoff attempt, reused across retries of that attempt (mirrors C6).
const attemptKeyRef = useRef<string | null>(null);
const providerName = t(`provider_${providerCode}`);
const busy = issue.isPending || issue.isSuccess;
const handleConfirm = () => {
attemptKeyRef.current ??= crypto.randomUUID();
issue.mutate(
{ bookingRequestId, providerCode, planId, idempotencyKey: attemptKeyRef.current },
{
onSuccess: (result) => onIssued(result),
onError: (error) => {
if (error instanceof ApiError && error.status === 409) onConverged();
},
},
);
};
// Handoff in progress — the provider redirect is being followed.
if (busy) {
return (
<Paper elevation={0} sx={{ p: 4, borderRadius: 2, border: '1px solid', borderColor: 'divider', textAlign: 'center' }}>
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
<CircularProgress color="secondary" size="2.5rem" />
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('redirecting', { provider: providerName })}
</Typography>
</Stack>
</Paper>
);
}
if (isLoading) return <ScheduleSkeleton />;
if (isError || !schedule) {
return (
<Stack sx={{ gap: 2 }}>
<AppAlert severity="error" variant="outlined" sx={{ marginY: 0 }}>
{t('error_body')}
</AppAlert>
<AppButton variant="outlined" color="secondary" onClick={() => refetch()} sx={{ m: 0 }}>
{tc('retry')}
</AppButton>
</Stack>
);
}
const inlineError =
issue.error && !(issue.error instanceof ApiError && issue.error.status === 409) ? t('settle_failed_body') : null;
return (
<Stack sx={{ gap: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('schedule_title')}
</Typography>
<Stack sx={{ gap: 1 }}>
{schedule.rows.map((row) => (
<InstallmentScheduleRow key={`${row.kind}-${row.sequence}`} row={row} />
))}
</Stack>
{/* The ownership truth: the installment agreement is customer ↔ provider; Balinyaar is paid in full. */}
<Paper
elevation={0}
sx={{
p: 1.75,
borderRadius: 2,
border: '1px solid',
borderColor: 'var(--bal-secondary)',
backgroundColor: 'var(--bal-secondary-soft)',
}}
>
<Stack direction="row" sx={{ gap: 1, alignItems: 'flex-start' }}>
<AppIcon icon="info" size={18} color="var(--bal-secondary-dark)" />
<Typography variant="caption" sx={{ color: 'var(--bal-secondary-dark)', lineHeight: 1.9 }}>
{t('contract_note', { provider: providerName })}
</Typography>
</Stack>
</Paper>
<FormControlLabel
control={<Checkbox checked={accepted} onChange={(e) => setAccepted(e.target.checked)} color="secondary" />}
label={
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('contract_consent')}
</Typography>
}
sx={{ alignItems: 'flex-start', m: 0 }}
/>
{inlineError ? (
<AppAlert severity="error" variant="outlined" sx={{ marginY: 0 }}>
{inlineError}
</AppAlert>
) : null}
<Stack sx={{ gap: 1 }}>
<AppButton
color="secondary"
variant="contained"
size="large"
disabled={!accepted}
onClick={handleConfirm}
sx={{ m: 0, py: 1.25 }}
>
{t('pay_down_payment')}
</AppButton>
<AppButton variant="text" color="primary" onClick={onBack} sx={{ m: 0 }}>
{tc('back')}
</AppButton>
</Stack>
</Stack>
);
};
function ScheduleSkeleton() {
return (
<Stack sx={{ gap: 1.5 }}>
<Skeleton variant="text" width="40%" height={28} />
{[0, 1, 2, 3].map((i) => (
<Skeleton key={i} variant="rounded" height={56} />
))}
<Skeleton variant="rounded" height={64} />
<Skeleton variant="rounded" height={48} />
</Stack>
);
}
export default ScheduleStep;
@@ -0,0 +1,73 @@
'use client';
import { Suspense } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation';
import { Paper, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, AppLoading } from '@/components';
import { ROUTES } from '@/constants';
import {
BNPL_QUERY_OUTCOME,
BNPL_QUERY_PLAN,
BNPL_QUERY_PROVIDER,
BNPL_QUERY_REQUEST_ID,
BNPL_QUERY_TRANSACTION_ID,
} from '@/services/bnpl/constants';
import type { BnplHandoffOutcome, ProviderCode } from '@/services/bnpl/types';
/**
* Dev provider-handoff harness — a **test harness, not a product feature**. It stands in for the BNPL
* provider so the initiate → redirect → return round-trip is exercisable without a real provider: the
* mock's `redirectUrl` points here, and the pay/cancel buttons drive both outcome branches of the return
* surface (a real provider redirects back after the customer completes or abandons the agreement). On the
* real path the `redirectUrl` is the provider's absolute URL and this page is never reached.
*/
export default function BnplGatewayPage() {
return (
<Suspense fallback={<AppLoading />}>
<BnplGatewayScreen />
</Suspense>
);
}
function BnplGatewayScreen() {
const t = useTranslations('bnpl');
const locale = useLocale();
const router = useRouter();
const params = useSearchParams();
const requestId = params.get(BNPL_QUERY_REQUEST_ID) ?? '';
const transactionId = params.get(BNPL_QUERY_TRANSACTION_ID) ?? '';
const provider = (params.get(BNPL_QUERY_PROVIDER) ?? '') as ProviderCode | '';
const providerName = provider ? t(`provider_${provider}`) : t('installments_heading');
const returnWith = (outcome: BnplHandoffOutcome) => {
const query = new URLSearchParams({
[BNPL_QUERY_REQUEST_ID]: requestId,
[BNPL_QUERY_TRANSACTION_ID]: transactionId,
[BNPL_QUERY_PROVIDER]: provider,
[BNPL_QUERY_PLAN]: params.get(BNPL_QUERY_PLAN) ?? '',
[BNPL_QUERY_OUTCOME]: outcome,
});
router.replace(`/${locale}${ROUTES.CHECKOUT_BNPL_RETURN}?${query.toString()}`);
};
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
<AppIcon icon="installments" size={44} color="var(--bal-secondary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('handoff_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('handoff_body', { provider: providerName })}
</Typography>
<AppButton color="secondary" variant="contained" size="large" onClick={() => returnWith('success')} sx={{ m: 0 }}>
{t('handoff_pay_success')}
</AppButton>
<AppButton variant="text" color="error" onClick={() => returnWith('failure')} sx={{ m: 0 }}>
{t('handoff_pay_fail')}
</AppButton>
</Stack>
</Paper>
);
}
@@ -0,0 +1,222 @@
'use client';
import { Suspense, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation';
import { Paper, Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, AppLoading, StepperHeader } from '@/components';
import { ROUTES } from '@/constants';
import { useAuth } from '@/context/auth';
import { useBnplOptions } from '@/services/bnpl';
import { BNPL_QUERY_REQUEST_ID, BNPL_QUERY_TRANSACTION_ID } from '@/services/bnpl/constants';
import { CHECKOUT_QUERY_REQUEST_ID } from '@/services/payment/constants';
import type { BnplEligibilityResult, IssueBnplTokenResult, ProviderCode } from '@/services/bnpl/types';
import MethodStep from './MethodStep';
import PlanStep from './PlanStep';
import EligibilityStep from './EligibilityStep';
import ScheduleStep from './ScheduleStep';
type WizardStep = 'provider' | 'plan' | 'eligibility' | 'schedule';
const STEP_ORDER: WizardStep[] = ['provider', 'plan', 'eligibility', 'schedule'];
/**
* BNPL installment checkout (D1D4) — the alternate branch off C6. A single stateful wizard: D1 method /
* provider → D2 plan → D3 eligibility → D4 schedule + contract, then the provider handoff. On a cleared
* down-payment the return surface routes to the **reused f9 confirmation** (the booking confirms exactly
* as the card path). Reached with `?request_id=`; `useSearchParams` needs a Suspense boundary.
*/
export default function BnplCheckoutPage() {
return (
<Suspense fallback={<AppLoading />}>
<BnplCheckoutScreen />
</Suspense>
);
}
function BnplCheckoutScreen() {
const t = useTranslations('bnpl');
const tb = useTranslations('booking');
const tc = useTranslations('common');
const tp = useTranslations('payment');
const locale = useLocale();
const router = useRouter();
const params = useSearchParams();
const [{ currentUser }] = useAuth();
const requestId = Number(params.get(BNPL_QUERY_REQUEST_ID));
const validId = Number.isInteger(requestId) && requestId > 0;
const { data: options, isLoading, isError, refetch } = useBnplOptions(validId ? requestId : undefined);
const [step, setStep] = useState<WizardStep>('provider');
const [providerCode, setProviderCode] = useState<ProviderCode | null>(null);
const [planId, setPlanId] = useState<string | null>(null);
const [eligibility, setEligibility] = useState<BnplEligibilityResult | null>(null);
const toCard = () => router.replace(`/${locale}${ROUTES.CHECKOUT}?${CHECKOUT_QUERY_REQUEST_ID}=${requestId}`);
const toBookings = () => router.replace(`/${locale}${ROUTES.BOOKINGS}`);
const toRequest = () => router.replace(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`);
if (!validId) {
return (
<MessageCard icon="error" tone="var(--bal-error)" title={t('error_title')} ctaLabel={tb('bd_my_bookings')} onCta={toBookings} />
);
}
if (isError) {
return <MessageCard icon="error" tone="var(--bal-error)" title={t('error_title')} body={t('error_body')} ctaLabel={tc('retry')} onCta={() => refetch()} />;
}
if (isLoading || !options) return <WizardSkeleton />;
// Only an accepted, awaiting-payment request is payable — converge/explain otherwise (mirrors C6).
if (options.requestStatus === 'converted') {
return (
<MessageCard
icon="verified"
tone="var(--bal-success)"
title={tp('already_paid_title')}
body={tp('already_paid_body')}
ctaLabel={tb('converted_cta')}
onCta={toRequest}
/>
);
}
if (options.requestStatus !== 'accepted_awaiting_payment') {
const expired = options.requestStatus === 'payment_deadline_expired';
return (
<MessageCard
icon="pending"
tone="var(--bal-warning)"
title={expired ? tp('window_expired_title') : tp('not_payable_title')}
body={expired ? tp('window_expired_body') : undefined}
ctaLabel={t('pay_with_card')}
onCta={toCard}
/>
);
}
const returnUrl = (extra?: Record<string, string>) => {
const query = new URLSearchParams({ [BNPL_QUERY_REQUEST_ID]: String(requestId), ...extra });
return `/${locale}${ROUTES.CHECKOUT_BNPL_RETURN}?${query.toString()}`;
};
const onIssued = (result: IssueBnplTokenResult) => {
if (!result.redirectUrl) {
// Nothing to hand off to — read the order directly on the return surface.
router.push(returnUrl({ [BNPL_QUERY_TRANSACTION_ID]: String(result.bnplTransactionId) }));
return;
}
if (/^https?:\/\//i.test(result.redirectUrl)) {
// The real provider page — a full navigation outside the app router.
window.location.assign(result.redirectUrl);
return;
}
router.push(`/${locale}${result.redirectUrl}`);
};
const activeProvider = options.providers.find((p) => p.providerCode === providerCode);
return (
<Stack sx={{ gap: 2 }}>
<Stack sx={{ gap: 0.25, alignItems: 'center', textAlign: 'center' }}>
<Typography variant="h6" component="h1">
{t('title')}
</Typography>
</Stack>
<StepperHeader
activeStep={STEP_ORDER.indexOf(step)}
steps={[t('step_provider'), t('step_plan'), t('step_eligibility'), t('step_schedule')]}
/>
{step === 'provider' ? (
<MethodStep
options={options}
selectedProvider={providerCode}
onSelectProvider={setProviderCode}
onContinue={() => setStep('plan')}
onPayWithCard={toCard}
/>
) : null}
{step === 'plan' && providerCode && activeProvider ? (
<PlanStep
providerCode={providerCode}
plans={activeProvider.plans}
selectedPlanId={planId}
onSelectPlan={setPlanId}
onContinue={() => setStep('eligibility')}
onBack={() => setStep('provider')}
/>
) : null}
{step === 'eligibility' && providerCode ? (
<EligibilityStep
bookingRequestId={requestId}
providerCode={providerCode}
sessionMobile={currentUser?.phone ?? ''}
initialResult={eligibility}
onApproved={(result) => {
setEligibility(result);
setStep('schedule');
}}
onPayWithCard={toCard}
/>
) : null}
{step === 'schedule' && providerCode && planId ? (
<ScheduleStep
bookingRequestId={requestId}
providerCode={providerCode}
planId={planId}
onBack={() => setStep('eligibility')}
onIssued={onIssued}
onConverged={() => router.replace(returnUrl())}
/>
) : null}
</Stack>
);
}
function MessageCard({
icon,
tone,
title,
body,
ctaLabel,
onCta,
}: {
icon: string;
tone: string;
title: string;
body?: string;
ctaLabel: string;
onCta: () => void;
}) {
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<AppIcon icon={icon} size={44} color={tone} />
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1, mb: body ? 0.5 : 2 }}>
{title}
</Typography>
{body ? (
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
{body}
</Typography>
) : null}
<AppButton variant="contained" color="primary" onClick={onCta} sx={{ m: 0 }}>
{ctaLabel}
</AppButton>
</Paper>
);
}
function WizardSkeleton() {
return (
<Stack sx={{ gap: 2 }}>
<Skeleton variant="text" width="50%" height={32} sx={{ mx: 'auto' }} />
<Skeleton variant="rounded" height={48} />
<Skeleton variant="rounded" height={72} />
<Skeleton variant="rounded" height={64} />
<Skeleton variant="rounded" height={64} />
<Skeleton variant="rounded" height={48} />
</Stack>
);
}
@@ -0,0 +1,187 @@
'use client';
import { Suspense, useEffect, useRef, type ReactNode } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation';
import { useQueryClient } from '@tanstack/react-query';
import { CircularProgress, Paper, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, AppLoading } from '@/components';
import { ROUTES } from '@/constants';
import { useAcceptBnplSchedule, useBnplOrder } from '@/services/bnpl';
import { invalidateAfterBnplSettlement } from '@/services/bnpl/invalidations';
import { isBnplSettlementSuccess } from '@/services/bnpl/types';
import {
BNPL_QUERY_OUTCOME,
BNPL_QUERY_PROVIDER,
BNPL_QUERY_REQUEST_ID,
BNPL_QUERY_TRANSACTION_ID,
CHECKOUT_METHOD_BNPL,
CHECKOUT_QUERY_METHOD,
} from '@/services/bnpl/constants';
import {
CHECKOUT_QUERY_BOOKING_ID,
CHECKOUT_QUERY_REQUEST_ID,
} from '@/services/payment/constants';
/**
* Return-from-provider surface — drives the tail of the BNPL checkout: report the return
* (`useAcceptBnplSchedule`; the settle trigger in the mock, an order read on the real path), then a brief
* settle-pending state backed by the bounded order poll until terminal. On settlement: hand off to the
* **reused f9 confirmation** marked «paid via installments» (the booking confirmed exactly as the card
* path); on decline: a retry (a fresh D4 = a new attempt) or the card fall-back; on window-lapse: back to
* the request.
*/
export default function BnplReturnPage() {
return (
<Suspense fallback={<AppLoading />}>
<BnplReturnScreen />
</Suspense>
);
}
function BnplReturnScreen() {
const t = useTranslations('bnpl');
const tp = useTranslations('payment');
const locale = useLocale();
const router = useRouter();
const params = useSearchParams();
const queryClient = useQueryClient();
const requestId = Number(params.get(BNPL_QUERY_REQUEST_ID));
const validId = Number.isInteger(requestId) && requestId > 0;
const transactionIdParam = params.get(BNPL_QUERY_TRANSACTION_ID);
const transactionId = transactionIdParam ? Number(transactionIdParam) : null;
const provider = params.get(BNPL_QUERY_PROVIDER) ?? '';
const outcome = params.get(BNPL_QUERY_OUTCOME) === 'failure' ? ('failure' as const) : ('success' as const);
const accept = useAcceptBnplSchedule();
const { mutate: acceptMutate } = accept;
// Fire the settle report exactly once per mount — a refresh replays it (idempotent convergence).
const firedRef = useRef(false);
useEffect(() => {
if (firedRef.current || !validId) return;
firedRef.current = true;
acceptMutate({ bookingRequestId: requestId, bnplTransactionId: transactionId, outcome });
}, [acceptMutate, validId, requestId, transactionId, outcome]);
const settled = accept.isSuccess || accept.isError;
const acceptSawSuccess = accept.data ? isBnplSettlementSuccess(accept.data) : false;
// Poll only for the late-settle case: when the accept result is ALREADY a settlement success (the mock
// down-payment-cleared path), the navigation effect hands off immediately — reading the order would be a
// needless fetch. Poll only when accept resolved without a settlement (real provider callback still in flight).
const orderQuery = useBnplOrder(validId ? requestId : undefined, { enabled: settled && !acceptSawSuccess });
const order = settled ? orderQuery.data : undefined;
const succeeded = acceptSawSuccess || order?.status === 'settled';
const windowExpired = accept.data?.requestStatus === 'payment_deadline_expired';
const failed = !succeeded && !windowExpired && (accept.data?.status === 'failed' || order?.status === 'failed');
// Hand off to the confirmation exactly once. The accept mutation already invalidated on immediate
// success; a success that arrived later through the poll invalidates here instead (never twice).
const navigatedRef = useRef(false);
const bookingId = accept.data?.bookingId ?? order?.bookingId ?? null;
useEffect(() => {
if (!succeeded || navigatedRef.current) return;
navigatedRef.current = true;
if (!acceptSawSuccess) {
invalidateAfterBnplSettlement(queryClient, requestId, bookingId);
}
const query = new URLSearchParams({
[CHECKOUT_QUERY_REQUEST_ID]: String(requestId),
[CHECKOUT_QUERY_METHOD]: CHECKOUT_METHOD_BNPL,
});
if (bookingId != null) query.set(CHECKOUT_QUERY_BOOKING_ID, String(bookingId));
if (provider) query.set(BNPL_QUERY_PROVIDER, provider);
router.replace(`/${locale}${ROUTES.CHECKOUT_CONFIRMATION}?${query.toString()}`);
}, [succeeded, acceptSawSuccess, bookingId, queryClient, requestId, provider, router, locale]);
if (!validId) {
return (
<StateCard icon="error" tone="var(--bal-error)" title={t('error_title')}>
<AppButton variant="contained" onClick={() => router.replace(`/${locale}${ROUTES.BOOKINGS}`)} sx={{ m: 0 }}>
{t('pay_with_card')}
</AppButton>
</StateCard>
);
}
if (windowExpired) {
// The payment window lapsed during the handoff — card payment is impossible now, so route to the
// request (not the card checkout). Reuse the f9 window-expired copy + the matching back-to-request CTA.
return (
<StateCard icon="pending" tone="var(--bal-warning)" title={tp('window_expired_title')} body={tp('window_expired_body')}>
<AppButton
variant="contained"
onClick={() => router.replace(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`)}
sx={{ m: 0 }}
>
{tp('back_to_request')}
</AppButton>
</StateCard>
);
}
if (failed) {
return (
<StateCard icon="error" tone="var(--bal-error)" title={t('settle_failed_title')} body={t('settle_failed_body')}>
<AppButton
color="secondary"
variant="contained"
size="large"
onClick={() => router.replace(`/${locale}${ROUTES.CHECKOUT_BNPL}?${BNPL_QUERY_REQUEST_ID}=${requestId}`)}
sx={{ m: 0 }}
>
{t('retry_installments')}
</AppButton>
<AppButton
variant="text"
onClick={() => router.replace(`/${locale}${ROUTES.CHECKOUT}?${CHECKOUT_QUERY_REQUEST_ID}=${requestId}`)}
sx={{ m: 0 }}
>
{t('pay_with_card')}
</AppButton>
</StateCard>
);
}
// Settle-pending (and the brief succeeded → confirmation hand-off): a calm waiting state.
return (
<StateCard icon="installments" tone="var(--bal-secondary)" title={t('settling_title')} body={t('settling_body')}>
<CircularProgress color="secondary" size="2.5rem" />
<AppButton variant="text" disabled={orderQuery.isFetching} onClick={() => orderQuery.refetch()} sx={{ m: 0 }}>
{t('check_again')}
</AppButton>
</StateCard>
);
}
function StateCard({
icon,
tone,
title,
body,
children,
}: {
icon: string;
tone: string;
title: string;
body?: string;
children?: ReactNode;
}) {
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
<AppIcon icon={icon} size={44} color={tone} />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{title}
</Typography>
{body ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{body}
</Typography>
) : null}
{children}
</Stack>
</Paper>
);
}
@@ -0,0 +1,115 @@
'use client';
import { Suspense } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation';
import { Paper, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, AppLoading } from '@/components';
import { bookingInvoicePath, ROUTES } from '@/constants';
import { formatIrrToToman } from '@/utils';
import { useCheckoutSummary } from '@/services/payment';
import { CHECKOUT_QUERY_BOOKING_ID, CHECKOUT_QUERY_REQUEST_ID } from '@/services/payment/constants';
import {
BNPL_QUERY_PROVIDER,
CHECKOUT_METHOD_BNPL,
CHECKOUT_QUERY_METHOD,
} from '@/services/bnpl/constants';
/**
* Post-payment confirmation — the booking is now **confirmed** (flipped by cache invalidation on the
* return surface, never a blanket refetch). Links back to the f8 booking detail («مشاهده رزرو») and to
* the invoice («دانلود فاکتور»). Reused by both the f9 card flow and the f11 BNPL branch: when reached
* with `?method=bnpl` it also renders a «پرداخت‌شده با اقساط» line (a settled BNPL order is, to
* Balinyaar, a card payment net-of-fee — there is no separate BNPL confirmation). Without a `booking_id`
* (REQ-017/024 unmet on the real path) the deep-links fall back to the bookings list.
*/
export default function CheckoutConfirmationPage() {
return (
<Suspense fallback={<AppLoading />}>
<ConfirmationScreen />
</Suspense>
);
}
function ConfirmationScreen() {
const t = useTranslations('payment');
const tc = useTranslations('common');
const tBnpl = useTranslations('bnpl');
const locale = useLocale();
const router = useRouter();
const params = useSearchParams();
const requestId = Number(params.get(CHECKOUT_QUERY_REQUEST_ID));
const bookingIdParam = params.get(CHECKOUT_QUERY_BOOKING_ID);
const bookingId = bookingIdParam ? Number(bookingIdParam) : null;
const isBnpl = params.get(CHECKOUT_QUERY_METHOD) === CHECKOUT_METHOD_BNPL;
const bnplProvider = params.get(BNPL_QUERY_PROVIDER) ?? '';
const { data: summary } = useCheckoutSummary(
Number.isInteger(requestId) && requestId > 0 ? requestId : undefined,
);
return (
<Stack sx={{ gap: 3, alignItems: 'center', textAlign: 'center' }}>
<AppIcon icon="verified" size={64} color="var(--bal-success)" />
<Stack sx={{ gap: 0.5 }}>
<Typography variant="h6" component="h1">
{t('confirm_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('confirm_subtitle')}
</Typography>
</Stack>
{summary ? (
<Paper
elevation={0}
sx={{ p: 2.5, borderRadius: 2, border: '1px solid', borderColor: 'divider', width: '100%' }}
>
<Stack sx={{ gap: 0.5, alignItems: 'center' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('total_paid_label')}
</Typography>
<Typography variant="h6" sx={{ color: 'var(--bal-secondary)' }}>
{formatIrrToToman(summary.totalIrr, locale)} {tc('currency_toman')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{summary.variantLabel} · {summary.nurseName}
</Typography>
{isBnpl ? (
<Typography variant="caption" sx={{ color: 'var(--bal-secondary)', fontWeight: 600, mt: 0.5 }}>
{tBnpl('paid_via_installments', {
provider: bnplProvider ? tBnpl(`provider_${bnplProvider}`) : tBnpl('installments_heading'),
})}
</Typography>
) : null}
</Stack>
</Paper>
) : null}
<Stack sx={{ gap: 1, width: '100%' }}>
<AppButton
color="primary"
variant="contained"
size="large"
onClick={() =>
router.push(`/${locale}${bookingId != null ? `${ROUTES.BOOKINGS}/${bookingId}` : ROUTES.BOOKINGS}`)
}
sx={{ m: 0 }}
>
{t('view_booking')}
</AppButton>
{bookingId != null ? (
<AppButton
variant="outlined"
color="primary"
startIcon="document"
onClick={() => router.push(`/${locale}${bookingInvoicePath(bookingId)}`)}
sx={{ m: 0 }}
>
{t('download_invoice')}
</AppButton>
) : null}
</Stack>
</Stack>
);
}
@@ -0,0 +1,76 @@
'use client';
import { Suspense } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation';
import { Box, Paper, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, AppLoading } from '@/components';
import { ROUTES } from '@/constants';
import {
CHECKOUT_QUERY_OUTCOME,
CHECKOUT_QUERY_REQUEST_ID,
CHECKOUT_QUERY_TRANSACTION_ID,
} from '@/services/payment/constants';
import type { GatewayReturnOutcome } from '@/services/payment/types';
/**
* Dev mock-gateway page — a **test harness, not a product feature**. It stands in for the PSP so the
* initiate → redirect → return round-trip is exercisable without a real gateway: the payment mock's
* `redirectUrl` points here, and the success/failure buttons drive both outcome branches of the return
* surface (a real PSP redirects back after the cardholder pays or cancels). On the real path the
* `redirectUrl` is the PSP's absolute URL and this page is never reached.
*/
export default function MockGatewayPage() {
return (
<Suspense fallback={<AppLoading />}>
<MockGatewayScreen />
</Suspense>
);
}
function MockGatewayScreen() {
const t = useTranslations('payment');
const locale = useLocale();
const router = useRouter();
const params = useSearchParams();
const requestId = params.get(CHECKOUT_QUERY_REQUEST_ID) ?? '';
const transactionId = params.get(CHECKOUT_QUERY_TRANSACTION_ID) ?? '';
const returnWith = (outcome: GatewayReturnOutcome) => {
const query = new URLSearchParams({
[CHECKOUT_QUERY_REQUEST_ID]: requestId,
[CHECKOUT_QUERY_TRANSACTION_ID]: transactionId,
[CHECKOUT_QUERY_OUTCOME]: outcome,
});
router.replace(`/${locale}${ROUTES.CHECKOUT_RETURN}?${query.toString()}`);
};
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
<AppIcon icon="payment" size={44} color="var(--bal-secondary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('gateway_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('gateway_hint')}
</Typography>
{transactionId ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{/* dir scoped to the code only — the label is Persian and must keep the RTL base direction. */}
{t('gateway_reference_label')}:{' '}
<Box component="span" dir="ltr">
#{transactionId}
</Box>
</Typography>
) : null}
<AppButton color="secondary" variant="contained" size="large" onClick={() => returnWith('success')} sx={{ m: 0 }}>
{t('gateway_pay_success')}
</AppButton>
<AppButton variant="text" color="error" onClick={() => returnWith('failure')} sx={{ m: 0 }}>
{t('gateway_pay_fail')}
</AppButton>
</Stack>
</Paper>
);
}
@@ -0,0 +1,302 @@
'use client';
import { Suspense, useRef } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation';
import { Paper, Skeleton, Stack, Typography } from '@mui/material';
import {
AppButton,
AppIcon,
AppLoading,
CountdownTimer,
EscrowNotice,
PriceBreakdown,
StatusChip,
} from '@/components';
import AppAlert from '@/components/common/AppAlert';
import { ROUTES } from '@/constants';
import { ApiError } from '@/lib/api/errors';
import { formatShamsiDate } from '@/utils';
import { useCheckoutSummary, useInitiatePayment } from '@/services/payment';
import {
BNPL_ENABLED,
CHECKOUT_QUERY_REQUEST_ID,
CHECKOUT_QUERY_TRANSACTION_ID,
} from '@/services/payment/constants';
import type { CheckoutSummaryDto } from '@/services/payment/types';
/**
* C6 — خلاصه و پرداخت (summary & pay). The acceptance badge, the served & reconciling
* service-cost / commission / VAT / total breakdown, the load-bearing escrow trust notice, and the
* «ادامه پرداخت ←» CTA that initiates the card payment and follows the gateway redirect. Reached from
* C5's accept CTA with `?request_id=`. `useSearchParams` needs a Suspense boundary.
*/
export default function CheckoutPage() {
return (
<Suspense fallback={<AppLoading />}>
<CheckoutScreen />
</Suspense>
);
}
function CheckoutScreen() {
const t = useTranslations('payment');
const tb = useTranslations('booking');
const tc = useTranslations('common');
const locale = useLocale();
const router = useRouter();
const params = useSearchParams();
const requestId = Number(params.get(CHECKOUT_QUERY_REQUEST_ID));
const validId = Number.isInteger(requestId) && requestId > 0;
const { data: summary, isLoading, isError, refetch } = useCheckoutSummary(validId ? requestId : undefined);
const initiate = useInitiatePayment();
// One idempotency key per payment ATTEMPT: created lazily on the first tap and reused across retries
// of the same attempt (double-tap, transient network error). A new attempt — after a failed outcome the
// user comes back to a fresh mount of this screen — gets a new key.
const attemptKeyRef = useRef<string | null>(null);
// A malformed/missing request_id can never load — navigation, not a retry that would fire
// `checkout_summary/undefined` (a manual refetch() bypasses the query's `enabled` gate).
if (!validId) {
return (
<MessageCard
icon="error"
tone="var(--bal-error)"
title={t('error_title')}
body={t('invalid_link_body')}
ctaLabel={tb('bd_my_bookings')}
onCta={() => router.replace(`/${locale}${ROUTES.BOOKINGS}`)}
/>
);
}
if (isError) {
return (
<MessageCard
icon="error"
tone="var(--bal-error)"
title={t('error_title')}
body={t('error_body')}
ctaLabel={tc('retry')}
onCta={() => refetch()}
/>
);
}
if (isLoading || !summary) return <CheckoutSkeleton />;
const returnUrl = (extra?: Record<string, string>) => {
const query = new URLSearchParams({ [CHECKOUT_QUERY_REQUEST_ID]: String(requestId), ...extra });
return `/${locale}${ROUTES.CHECKOUT_RETURN}?${query.toString()}`;
};
// Anything other than "awaiting payment" cannot show a pay CTA — converge or explain instead.
if (summary.requestStatus === 'converted') {
return (
<MessageCard
icon="verified"
tone="var(--bal-success)"
title={t('already_paid_title')}
body={t('already_paid_body')}
ctaLabel={tb('converted_cta')}
onCta={() => router.replace(returnUrl())}
/>
);
}
if (summary.requestStatus !== 'accepted_awaiting_payment') {
const expired = summary.requestStatus === 'payment_deadline_expired';
return (
<MessageCard
icon="pending"
tone="var(--bal-warning)"
title={expired ? t('window_expired_title') : t('not_payable_title')}
body={expired ? t('window_expired_body') : undefined}
ctaLabel={t('back_to_request')}
onCta={() => router.replace(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`)}
/>
);
}
const handlePay = () => {
attemptKeyRef.current ??= crypto.randomUUID();
initiate.mutate(
{ bookingRequestId: requestId, idempotencyKey: attemptKeyRef.current },
{
onSuccess: (result) => {
if (!result.redirectUrl) {
// No gateway hop to make — read the outcome directly.
router.push(returnUrl({ [CHECKOUT_QUERY_TRANSACTION_ID]: String(result.transactionId) }));
return;
}
if (/^https?:\/\//i.test(result.redirectUrl)) {
// The real PSP page — a full navigation, outside the app router.
window.location.assign(result.redirectUrl);
return;
}
router.push(`/${locale}${result.redirectUrl}`);
},
onError: (error) => {
if (error instanceof ApiError && error.status === 409) {
// "Already paid / already in progress / window lapsed" — benign convergence, never a toast:
// the return surface reads the actual outcome and routes accordingly.
router.replace(returnUrl());
}
},
},
);
};
const busy = initiate.isPending || initiate.isSuccess;
// ApiError.message is a raw server/network string, never localized — always show the i18n copy.
const inlineError =
initiate.error && !(initiate.error instanceof ApiError && initiate.error.status === 409)
? t('initiate_failed')
: null;
return (
<Stack sx={{ gap: 3 }}>
<Stack sx={{ gap: 1, alignItems: 'center', textAlign: 'center' }}>
<StatusChip status="verified" label={tb('accepted_badge')} />
<Typography variant="h6" component="h1">
{t('title_checkout')}
</Typography>
</Stack>
<EngagementSummary summary={summary} locale={locale} />
{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}
/>
<EscrowNotice />
{inlineError ? (
<AppAlert severity="error" variant="outlined" sx={{ marginY: 0 }}>
{inlineError}
</AppAlert>
) : null}
<Stack sx={{ gap: 1 }}>
<AppButton
color="secondary"
variant="contained"
size="large"
disabled={busy}
onClick={handlePay}
sx={{ m: 0, py: 1.25 }}
>
{initiate.isPending ? t('state_initiating') : initiate.isSuccess ? t('state_redirecting') : t('cta_pay')}
</AppButton>
{/* The f11 BNPL branch (D1): «پرداخت اقساطی» → the installment wizard, reached with `?request_id=`. */}
{BNPL_ENABLED ? (
<AppButton
variant="outlined"
color="secondary"
startIcon="installments"
onClick={() => router.push(`/${locale}${ROUTES.CHECKOUT_BNPL}?${CHECKOUT_QUERY_REQUEST_ID}=${requestId}`)}
sx={{ m: 0 }}
>
{t('bnpl_option')}
</AppButton>
) : null}
</Stack>
</Stack>
);
}
/** Nurse/service/schedule mini-summary — page-only composition (C6 needs no address or price-per-unit). */
function EngagementSummary({ summary, locale }: { summary: CheckoutSummaryDto; locale: string }) {
const start = new Date(`${summary.requestedDate}T${summary.requestedTimeStart}`);
const end = new Date(`${summary.requestedDate}T${summary.requestedTimeEnd}`);
const timeFmt = new Intl.DateTimeFormat(locale === 'fa' ? 'fa-IR' : 'en-US', {
hour: '2-digit',
minute: '2-digit',
});
return (
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
<Stack sx={{ gap: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{summary.variantLabel}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{summary.nurseName} · {summary.patientName}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{formatShamsiDate(start, locale)} · {timeFmt.format(start)} {timeFmt.format(end)}
</Typography>
</Stack>
</Paper>
);
}
function MessageCard({
icon,
tone,
title,
body,
ctaLabel,
onCta,
}: {
icon: string;
tone: string;
title: string;
body?: string;
ctaLabel: string;
onCta: () => void;
}) {
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<AppIcon icon={icon} size={44} color={tone} />
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1, mb: body ? 0.5 : 2 }}>
{title}
</Typography>
{body ? (
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
{body}
</Typography>
) : null}
<AppButton variant="contained" color="primary" onClick={onCta} sx={{ m: 0 }}>
{ctaLabel}
</AppButton>
</Paper>
);
}
function CheckoutSkeleton() {
return (
<Stack sx={{ gap: 3 }}>
<Stack sx={{ gap: 1, alignItems: 'center' }}>
<Skeleton variant="rounded" width={140} height={24} />
<Skeleton variant="text" width="50%" height={32} />
</Stack>
<Skeleton variant="rounded" height={96} />
<Skeleton variant="rounded" height={64} />
<Skeleton variant="rounded" height={160} />
<Skeleton variant="rounded" height={56} />
<Skeleton variant="rounded" height={48} />
</Stack>
);
}
@@ -0,0 +1,180 @@
'use client';
import { Suspense, useEffect, useRef, type ReactNode } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation';
import { useQueryClient } from '@tanstack/react-query';
import { CircularProgress, Paper, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, AppLoading, PaymentStatusBadge } from '@/components';
import { ROUTES } from '@/constants';
import { useConfirmGatewayReturn, usePaymentOutcome } from '@/services/payment';
import { invalidateAfterPaymentSuccess } from '@/services/payment/invalidations';
import {
CHECKOUT_QUERY_BOOKING_ID,
CHECKOUT_QUERY_OUTCOME,
CHECKOUT_QUERY_REQUEST_ID,
CHECKOUT_QUERY_TRANSACTION_ID,
} from '@/services/payment/constants';
/**
* Return-from-gateway surface — drives the tail of the checkout state machine: report the return
* (`useConfirmGatewayReturn`; the capture trigger in the mock, an outcome read on the real path), then a
* **pending-callback** state backed by the backoff poll ("PSP received ≠ cash in bank" — pending is
* normal, reflected calmly) until a terminal outcome: succeeded → invalidate the booking/request caches
* and hand off to the confirmation screen; failed → a retry affordance (a fresh C6 mount = a new attempt
* with a NEW idempotency key); window lapsed → back to the request's terminal card.
*/
export default function CheckoutReturnPage() {
return (
<Suspense fallback={<AppLoading />}>
<ReturnScreen />
</Suspense>
);
}
function ReturnScreen() {
const t = useTranslations('payment');
const locale = useLocale();
const router = useRouter();
const params = useSearchParams();
const queryClient = useQueryClient();
const requestId = Number(params.get(CHECKOUT_QUERY_REQUEST_ID));
const validId = Number.isInteger(requestId) && requestId > 0;
const transactionIdParam = params.get(CHECKOUT_QUERY_TRANSACTION_ID);
const transactionId = transactionIdParam ? Number(transactionIdParam) : null;
const gatewayOutcome = params.get(CHECKOUT_QUERY_OUTCOME) === 'failure' ? ('failure' as const) : ('success' as const);
const confirm = useConfirmGatewayReturn();
const { mutate: confirmMutate } = confirm;
// Fire the return report exactly once per mount — a browser refresh on this page replays it, which the
// backend/mock treat as a duplicate (idempotent convergence, per the webhook-dedup rule).
const confirmFiredRef = useRef(false);
useEffect(() => {
if (confirmFiredRef.current || !validId) return;
confirmFiredRef.current = true;
confirmMutate({ bookingRequestId: requestId, transactionId, outcome: gatewayOutcome });
}, [confirmMutate, validId, requestId, transactionId, gatewayOutcome]);
// The poll takes over once the return report settles (its result is primed into the outcome key).
const confirmSettled = confirm.isSuccess || confirm.isError;
const outcomeQuery = usePaymentOutcome(validId ? requestId : undefined, {
enabled: confirmSettled,
});
// Trust the outcome cache only after THIS mount's report settled — a previous attempt's failed outcome
// survives in the cache and would otherwise flash a false "payment failed" (with a live retry CTA)
// while the current attempt's capture is still in flight.
const outcome = confirmSettled ? outcomeQuery.data : undefined;
const succeeded = outcome?.transactionStatus === 'succeeded' || outcome?.requestStatus === 'converted';
const windowExpired = outcome?.requestStatus === 'payment_deadline_expired';
const failed = !succeeded && !windowExpired && outcome?.transactionStatus === 'failed';
// Hand off to the confirmation exactly once. The confirm mutation already invalidated on an immediate
// success; a success that arrived later through the poll invalidates here instead (never twice).
const navigatedRef = useRef(false);
const confirmSawSuccess = confirm.data?.transactionStatus === 'succeeded';
useEffect(() => {
if (!succeeded || navigatedRef.current || !outcome) return;
navigatedRef.current = true;
if (!confirmSawSuccess) {
invalidateAfterPaymentSuccess(queryClient, outcome.bookingRequestId, outcome.bookingId);
}
const query = new URLSearchParams({ [CHECKOUT_QUERY_REQUEST_ID]: String(outcome.bookingRequestId) });
if (outcome.bookingId != null) query.set(CHECKOUT_QUERY_BOOKING_ID, String(outcome.bookingId));
router.replace(`/${locale}${ROUTES.CHECKOUT_CONFIRMATION}?${query.toString()}`);
}, [succeeded, outcome, confirmSawSuccess, queryClient, router, locale]);
if (!validId) {
return (
<StateCard icon="error" tone="var(--bal-error)" title={t('error_title')}>
<AppButton variant="contained" onClick={() => router.replace(`/${locale}${ROUTES.BOOKINGS}`)} sx={{ m: 0 }}>
{t('view_booking')}
</AppButton>
</StateCard>
);
}
if (windowExpired) {
return (
<StateCard icon="pending" tone="var(--bal-warning)" title={t('window_expired_title')} body={t('window_expired_body')}>
<AppButton
variant="contained"
onClick={() => router.replace(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`)}
sx={{ m: 0 }}
>
{t('back_to_request')}
</AppButton>
</StateCard>
);
}
if (failed) {
return (
<StateCard icon="error" tone="var(--bal-error)" title={t('state_failed_title')} body={t('state_failed_hint')}>
<PaymentStatusBadge status="failed" />
<AppButton
color="secondary"
variant="contained"
size="large"
onClick={() =>
router.replace(`/${locale}${ROUTES.CHECKOUT}?${CHECKOUT_QUERY_REQUEST_ID}=${requestId}`)
}
sx={{ m: 0 }}
>
{t('retry_payment')}
</AppButton>
<AppButton
variant="text"
onClick={() => router.replace(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`)}
sx={{ m: 0 }}
>
{t('back_to_request')}
</AppButton>
</StateCard>
);
}
// Pending-callback (and the brief succeeded → confirmation hand-off): a calm waiting state.
return (
<StateCard icon="payment" tone="var(--bal-secondary)" title={t('state_pending_title')} body={t('state_pending_hint')}>
<CircularProgress color="primary" size="2.5rem" />
<PaymentStatusBadge status="pending" />
{/* Manual re-check — covers the bounded poll giving up on a very slow callback. */}
<AppButton variant="text" disabled={outcomeQuery.isFetching} onClick={() => outcomeQuery.refetch()} sx={{ m: 0 }}>
{t('check_again')}
</AppButton>
</StateCard>
);
}
function StateCard({
icon,
tone,
title,
body,
children,
}: {
icon: string;
tone: string;
title: string;
body?: string;
children?: ReactNode;
}) {
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
<AppIcon icon={icon} size={44} color={tone} />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{title}
</Typography>
{body ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{body}
</Typography>
) : null}
{children}
</Stack>
</Paper>
);
}
@@ -1,8 +1,104 @@
import { getTranslations } from 'next-intl/server';
import { PlaceholderScreen } from '@/components';
'use client';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, StatusChip } from '@/components';
import { BOOKING_STATUS_KIND } from '@/components/booking/statusKind';
import { ROUTES } from '@/constants';
import { formatIrrToToman, formatShamsiDate } from '@/utils';
import { useBookingList } from '@/services/bookings';
import type { BookingListItemDto } from '@/services/bookings/types';
export default async function BookingsPage() {
const t = await getTranslations('nav');
const tShell = await getTranslations('shell');
return <PlaceholderScreen icon="bookings" title={t('bookings')} description={tShell('placeholder_body')} />;
/**
* Customer رزروها — the "My bookings" list. Reads `useBookingList('customer')`; each row opens the
* booking detail (`/bookings/{id}`). This is the customer entry to the f8 booking-detail surface (the C5
* `converted` state also lands here). Amounts render in Toman via the money util.
*/
export default function CustomerBookingsPage() {
const t = useTranslations('booking');
const { data, isLoading, isError } = useBookingList('customer');
const items = data?.items ?? [];
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Box>
<Typography variant="h5" component="h1">
{t('list_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('list_subtitle')}
</Typography>
</Box>
{isLoading ? (
<Stack sx={{ gap: 2 }}>
{[0, 1].map((key) => (
<Skeleton key={key} variant="rounded" height={120} />
))}
</Stack>
) : isError ? (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('list_error')}
</Typography>
</Paper>
) : items.length === 0 ? (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
<AppIcon icon="bookings" size={40} color="var(--bal-text-secondary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1 }}>
{t('list_empty_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
{t('list_empty_body')}
</Typography>
</Paper>
) : (
<Stack sx={{ gap: 2 }}>
{items.map((item) => (
<BookingRow key={item.id} item={item} />
))}
</Stack>
)}
</Box>
);
}
function BookingRow({ item }: { item: BookingListItemDto }) {
const t = useTranslations('booking');
const tc = useTranslations('common');
const locale = useLocale();
const router = useRouter();
return (
<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: 'flex-start', gap: 1.5, flexWrap: 'wrap' }}>
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{item.counterpartyName}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{formatShamsiDate(item.scheduledDate, locale)} · {t('session_count', { count: item.sessionCount })}
</Typography>
</Stack>
<StatusChip status={BOOKING_STATUS_KIND[item.status]} label={t(`bstatus_${item.status}`)} />
</Stack>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{t('list_total')}: {formatIrrToToman(item.amountIrr, locale)} {tc('currency_toman')}
</Typography>
<AppButton
variant="outlined"
color="primary"
endIcon="bookings"
onClick={() => router.push(`/${locale}${ROUTES.BOOKINGS}/${item.id}`)}
sx={{ m: 0 }}
>
{t('view_booking')}
</AppButton>
</Stack>
</Stack>
</Paper>
);
}
@@ -0,0 +1,295 @@
'use client';
import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useParams, useRouter } from 'next/navigation';
import {
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Paper,
Skeleton,
Stack,
Typography,
} from '@mui/material';
import { AppButton, AppIcon, BookingRequestSummaryCard, CountdownTimer, StatusChip, StepperHeader } from '@/components';
import { ROUTES } from '@/constants';
import { useBookingRequest, useCancelBookingRequest } from '@/services/bookingRequests';
import type { BookingRequestDto } from '@/services/bookingRequests/types';
/**
* C5 — Awaiting nurse acceptance (در انتظار تایید پرستار). Keyed by the request id, it **polls** the
* request (`useBookingRequest`, stopping at a terminal status) so the accept / reject / expire transition
* surfaces without a manual refresh. It renders the shared summary card, the 3-step tracker, and a
* countdown driven by the **server-frozen** deadline: the response window while pending, then the 30-min
* payment window once accepted (with the hand-off to checkout). Terminal states show their own card.
*/
export default function BookingRequestStatusPage() {
const t = useTranslations('booking');
const locale = useLocale();
const router = useRouter();
const params = useParams<{ id: string }>();
const id = Number(params.id);
const { data: request, isLoading, isError, refetch } = useBookingRequest(
Number.isInteger(id) && id > 0 ? id : undefined,
'customer',
);
const cancelRequest = useCancelBookingRequest();
const [confirmCancel, setConfirmCancel] = useState(false);
if (isLoading) return <StatusSkeleton />;
if (isError || !request) {
return (
<TerminalCard
icon="error"
tone="var(--bal-error)"
title={t('error_title')}
body={t('error_body')}
ctaLabel={t('retry')}
onCta={() => refetch()}
/>
);
}
const goToSearch = () => router.push(`/${locale}${ROUTES.SEARCH}`);
const addressLabel = customerAddressLabel(request, locale, t('address_whole_city'));
const summary = (
<BookingRequestSummaryCard
nurseName={request.nurseName}
nurseRating={request.nurseRating}
patientName={request.patientName}
variantLabel={request.variantLabel}
variantPrice={request.variantPrice}
variantPriceUnit={request.variantPriceUnit}
addressLabel={addressLabel}
requestedDate={request.requestedDate}
requestedTimeStart={request.requestedTimeStart}
requestedTimeEnd={request.requestedTimeEnd}
/>
);
// Terminal states — each is its own card with a re-request path back into discovery (or booking).
if (request.status === 'rejected_by_nurse') {
return (
<Stack sx={{ gap: 3 }}>
{summary}
<TerminalCard
icon="rejected"
tone="var(--bal-error)"
title={t('rejected_title')}
body={request.nurseRejectionReason ? `${t('rejected_reason_label')}: ${request.nurseRejectionReason}` : undefined}
ctaLabel={t('terminal_rerequest')}
onCta={goToSearch}
/>
</Stack>
);
}
if (request.status === 'expired_no_response') {
return (
<Stack sx={{ gap: 3 }}>
{summary}
<TerminalCard icon="pending" tone="var(--bal-warning)" title={t('expired_title')} ctaLabel={t('terminal_rerequest')} onCta={goToSearch} />
</Stack>
);
}
if (request.status === 'payment_deadline_expired') {
return (
<Stack sx={{ gap: 3 }}>
{summary}
<TerminalCard icon="pending" tone="var(--bal-warning)" title={t('payment_expired_title')} ctaLabel={t('terminal_rerequest')} onCta={goToSearch} />
</Stack>
);
}
if (request.status === 'cancelled_by_customer') {
return (
<Stack sx={{ gap: 3 }}>
{summary}
<TerminalCard icon="rejected" tone="var(--bal-text-secondary)" title={t('cancelled_title')} ctaLabel={t('terminal_rerequest')} onCta={goToSearch} />
</Stack>
);
}
if (request.status === 'converted') {
return (
<Stack sx={{ gap: 3 }}>
{summary}
<TerminalCard
icon="verified"
tone="var(--bal-success)"
title={t('converted_title')}
ctaLabel={t('converted_cta')}
// Deep-link the booking when the id is known (client-augmented, REQ-017); list fallback otherwise.
onCta={() =>
router.push(
`/${locale}${request.bookingId != null ? `${ROUTES.BOOKINGS}/${request.bookingId}` : ROUTES.BOOKINGS}`,
)
}
/>
</Stack>
);
}
const accepted = request.status === 'accepted_awaiting_payment';
const activeStep = accepted ? 2 : 1;
return (
<Stack sx={{ gap: 3 }}>
<Stack sx={{ gap: 0.5, alignItems: 'center', textAlign: 'center' }}>
<AppIcon icon="pending" size={40} color="var(--bal-primary)" />
<Typography variant="h6" component="h1">
{t('awaiting_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('awaiting_subtitle')}
</Typography>
</Stack>
<StepperHeader
steps={[t('step_submitted'), t('step_awaiting'), t('step_payment')]}
activeStep={activeStep}
/>
{summary}
{accepted ? (
<Paper
elevation={0}
sx={{
p: 2.5,
borderRadius: 2,
border: '1px solid',
borderColor: 'divider',
borderInlineStartWidth: 4,
borderInlineStartColor: 'var(--bal-secondary)',
}}
>
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
<StatusChip status="verified" label={t('accepted_badge')} />
<Typography variant="body2" sx={{ color: 'text.secondary', textAlign: 'center' }}>
{t('accepted_body')}
</Typography>
{request.paymentDeadlineAt ? (
<CountdownTimer
deadlineIso={request.paymentDeadlineAt}
label={t('payment_countdown_label')}
elapsedText={t('payment_elapsed')}
urgent
onElapsed={() => refetch()}
/>
) : null}
<AppButton
color="secondary"
variant="contained"
size="large"
endIcon="payment"
onClick={() => router.push(`/${locale}${ROUTES.CHECKOUT}?request_id=${request.id}`)}
sx={{ m: 0, py: 1.25 }}
>
{t('continue_payment')}
</AppButton>
</Stack>
</Paper>
) : (
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
<CountdownTimer
deadlineIso={request.nurseResponseDeadlineAt}
label={t('response_countdown_label')}
elapsedText={t('response_elapsed')}
onElapsed={() => refetch()}
/>
</Paper>
)}
<AppButton
variant="text"
color="error"
disabled={cancelRequest.isPending}
onClick={() => setConfirmCancel(true)}
sx={{ m: 0, alignSelf: 'center' }}
>
{cancelRequest.isPending ? t('cancelling') : t('cancel_request')}
</AppButton>
<Dialog open={confirmCancel} onClose={() => setConfirmCancel(false)}>
<DialogTitle>{t('cancel_confirm_title')}</DialogTitle>
<DialogContent>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('cancel_confirm_body')}
</Typography>
</DialogContent>
<DialogActions>
<AppButton variant="text" onClick={() => setConfirmCancel(false)}>
{t('cancel_request')}
</AppButton>
<AppButton
color="error"
variant="contained"
onClick={() => {
setConfirmCancel(false);
cancelRequest.mutate(request.id);
}}
>
{t('cancel_confirm_yes')}
</AppButton>
</DialogActions>
</Dialog>
</Stack>
);
}
/** "title · city · district" (or "· whole city"), locale-aware — the customer view carries the full address. */
function customerAddressLabel(request: BookingRequestDto, locale: string, wholeCityLabel: string): string {
const city = locale === 'en' ? request.cityNameEn : request.cityNameFa;
const district =
request.districtId == null ? wholeCityLabel : locale === 'en' ? request.districtNameEn : request.districtNameFa;
return `${request.addressTitle} · ${city} · ${district}`;
}
function TerminalCard({
icon,
tone,
title,
body,
ctaLabel,
onCta,
}: {
icon: string;
tone: string;
title: string;
body?: string;
ctaLabel: string;
onCta: () => void;
}) {
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<AppIcon icon={icon} size={44} color={tone} />
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1, mb: body ? 0.5 : 2 }}>
{title}
</Typography>
{body ? (
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
{body}
</Typography>
) : null}
<AppButton variant="contained" color="primary" onClick={onCta} sx={{ m: 0 }}>
{ctaLabel}
</AppButton>
</Paper>
);
}
function StatusSkeleton() {
return (
<Stack sx={{ gap: 3 }}>
<Stack sx={{ gap: 1, alignItems: 'center' }}>
<Skeleton variant="circular" width={44} height={44} />
<Skeleton variant="text" width="60%" height={28} />
</Stack>
<Skeleton variant="rounded" height={72} />
<Skeleton variant="rounded" height={160} />
<Skeleton variant="rounded" height={96} />
</Stack>
);
}
@@ -0,0 +1,545 @@
'use client';
import { Suspense, useMemo, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation';
import {
Box,
MenuItem,
Paper,
Skeleton,
Stack,
TextField,
ToggleButton,
ToggleButtonGroup,
Typography,
} from '@mui/material';
import { AppButton, AppIcon, AppLoading, PriceDisplay } from '@/components';
import { AddressMapPicker } from '@/components/geography';
import { ROUTES } from '@/constants';
import { ApiError } from '@/lib/api/errors';
import { cityCentroid } from '@/services/geography/constants';
import { usePatients } from '@/services/patients';
import { useAddresses } from '@/services/addresses';
import { useNurseProfile } from '@/services/search';
import { useCreateBookingRequest } from '@/services/bookingRequests';
import { CUSTOMER_NOTES_MAX_LENGTH } from '@/services/bookingRequests/constants';
import type {
BookingRequestDisplayContext,
RequiredCaregiverGender,
} from '@/services/bookingRequests/types';
const GENDER_OPTIONS: RequiredCaregiverGender[] = ['female', 'male', 'any'];
/**
* C4 — Booking-request form (فرم درخواست). The destination of the C3 "درخواست رزرو" CTA (it carries the
* `nurse_id`, an optional `variant_id`, and the same-gender `required_gender` intent from search). The
* family picks a patient (f2), one of the nurse's service variants (f4/search profile), a saved address
* (f3), a future date + time window, the **first-class caregiver-gender** preference, and stage-1 notes,
* then sends the request → lands on C5. Money-free: no price breakdown, no booking row (that's f9/b9).
* `useSearchParams` requires a Suspense boundary.
*/
export default function BookingRequestFormPage() {
return (
<Suspense fallback={<AppLoading />}>
<BookingRequestForm />
</Suspense>
);
}
function BookingRequestForm() {
const t = useTranslations('booking');
const tAddress = useTranslations('address');
const locale = useLocale();
const router = useRouter();
const query = useSearchParams();
const nurseId = Number(query.get('nurse_id'));
const hasNurse = Number.isInteger(nurseId) && nurseId > 0;
const variantIdParam = Number(query.get('variant_id')) || null;
const genderParam = query.get('required_gender');
const profileQuery = useNurseProfile(hasNurse ? nurseId : undefined);
const patientsQuery = usePatients();
const addressesQuery = useAddresses();
const createRequest = useCreateBookingRequest();
const profile = profileQuery.data;
const patients = useMemo(() => patientsQuery.data?.items ?? [], [patientsQuery.data]);
const addresses = useMemo(() => addressesQuery.data?.items ?? [], [addressesQuery.data]);
const services = useMemo(() => profile?.services ?? [], [profile]);
const [patientId, setPatientId] = useState<number | ''>('');
const [variantSel, setVariantSel] = useState<number | ''>(variantIdParam ?? '');
const [addressSel, setAddressSel] = useState<number | ''>('');
const [gender, setGender] = useState<RequiredCaregiverGender | ''>(
genderParam === 'male' || genderParam === 'female' ? genderParam : '',
);
const [date, setDate] = useState('');
const [timeStart, setTimeStart] = useState('09:00');
const [timeEnd, setTimeEnd] = useState('13:00');
const [notes, setNotes] = useState('');
const [attempted, setAttempted] = useState(false);
const [pastDateError, setPastDateError] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
// Effective selection = the user's explicit choice, else a sensible default derived from the loaded
// data. Computed during render (no setState-in-effect): the variant defaults to the carried one / the
// first offered, the address to the primary / first.
const firstVariantId: number | '' = services.length > 0 ? services[0].variantId : '';
const variantId = variantSel !== '' ? variantSel : firstVariantId;
const primaryAddressId: number | '' =
addresses.length > 0 ? (addresses.find((address) => address.isPrimary)?.id ?? addresses[0].id) : '';
const addressId = addressSel !== '' ? addressSel : primaryAddressId;
const selectedVariant = useMemo(
() => services.find((service) => service.variantId === variantId),
[services, variantId],
);
const selectedAddress = useMemo(
() => addresses.find((address) => address.id === addressId),
[addresses, addressId],
);
const selectedPatient = useMemo(
() => patients.find((patient) => patient.id === patientId),
[patients, patientId],
);
// A concrete gender that contradicts the (single) nurse's gender is a same-gender mismatch (400) —
// block it inline before the round-trip; the server re-validates and is authoritative.
const genderMismatch =
gender !== '' && gender !== 'any' && profile != null && gender !== profile.nurseGender;
const requiredChosen =
patientId !== '' && variantId !== '' && addressId !== '' && gender !== '' && date !== '' && timeStart !== '' && timeEnd !== '';
const regionLabel = (): string => {
if (!selectedAddress) return '';
const city = locale === 'en' ? selectedAddress.cityNameEn : selectedAddress.cityNameFa;
const district =
selectedAddress.districtId == null
? t('address_whole_city')
: locale === 'en'
? selectedAddress.districtNameEn
: selectedAddress.districtNameFa;
return `${selectedAddress.title} · ${city} · ${district}`;
};
const handleSubmit = () => {
setAttempted(true);
setFormError(null);
if (!requiredChosen) return;
if (timeEnd <= timeStart) return;
// Future date+time guard (local wall-clock, matching the wire's date + time fields). Evaluated in the
// handler (not render) so the render path stays pure; the result drives the inline date error.
if (Date.parse(`${date}T${timeStart}`) < Date.now()) {
setPastDateError(true);
return;
}
setPastDateError(false);
if (genderMismatch) return;
const context: BookingRequestDisplayContext | undefined =
profile && selectedVariant && selectedAddress && selectedPatient
? {
nurseName: profile.nurseName,
nurseRating: profile.averageRating,
nurseTotalReviews: profile.totalReviews,
patientName: selectedPatient.displayName,
variantLabel: selectedVariant.displayName,
variantPriceUnit: selectedVariant.priceUnit,
variantPrice: selectedVariant.priceIrr,
addressTitle: selectedAddress.title,
cityId: selectedAddress.cityId,
cityNameFa: selectedAddress.cityNameFa,
cityNameEn: selectedAddress.cityNameEn,
districtId: selectedAddress.districtId,
districtNameFa: selectedAddress.districtNameFa,
districtNameEn: selectedAddress.districtNameEn,
addressLine: selectedAddress.addressLine,
postalCode: selectedAddress.postalCode,
recipientName: selectedAddress.recipientName,
recipientPhone: selectedAddress.recipientPhone,
}
: undefined;
createRequest.mutate(
{
payload: {
nurseId,
variantId: variantId as number,
patientId: patientId as number,
customerAddressId: addressId as number,
requestedDate: date,
requestedTimeStart: `${timeStart}:00`,
requestedTimeEnd: `${timeEnd}:00`,
requiredCaregiverGender: gender as RequiredCaregiverGender,
customerNotes: notes.trim() || null,
},
context,
},
{
onSuccess: (dto) => {
router.push(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${dto.id}`);
},
onError: (error) => setFormError(mapCreateError(error, t)),
},
);
};
if (!hasNurse) {
return (
<EmptyState
icon="search"
title={t('missing_nurse_title')}
body={t('missing_nurse_body')}
ctaLabel={t('missing_nurse_cta')}
onCta={() => router.push(`/${locale}${ROUTES.SEARCH}`)}
/>
);
}
if (profileQuery.isLoading) return <FormSkeleton />;
const timeError = attempted && timeStart !== '' && timeEnd !== '' && timeEnd <= timeStart;
const pastError = pastDateError;
return (
<Stack sx={{ gap: 3 }}>
<Box>
<Typography variant="h5" component="h1">
{t('request_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('form_subtitle')}
</Typography>
</Box>
{/* Patient */}
{patients.length === 0 ? (
<FieldEmpty
label={t('patient_label')}
message={t('patient_empty')}
ctaLabel={t('patient_add_cta')}
onCta={() => router.push(`/${locale}${ROUTES.PATIENTS}`)}
/>
) : (
<TextField
select
label={t('patient_label')}
value={patientId}
error={attempted && patientId === ''}
helperText={attempted && patientId === '' ? t('error_patient_required') : undefined}
onChange={(event) => setPatientId(Number(event.target.value))}
fullWidth
>
<MenuItem value="" disabled>
{t('patient_placeholder')}
</MenuItem>
{patients.map((patient) => (
<MenuItem key={patient.id} value={patient.id}>
{patient.displayName}
</MenuItem>
))}
</TextField>
)}
{/* Service variant */}
{services.length === 0 ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('service_empty')}
</Typography>
) : (
<TextField
select
label={t('service_label')}
value={variantId}
error={attempted && variantId === ''}
helperText={attempted && variantId === '' ? t('error_service_required') : undefined}
onChange={(event) => setVariantSel(Number(event.target.value))}
fullWidth
>
<MenuItem value="" disabled>
{t('service_placeholder')}
</MenuItem>
{services.map((service) => (
<MenuItem key={service.variantId} value={service.variantId}>
{service.displayName}
</MenuItem>
))}
</TextField>
)}
{selectedVariant ? (
<Box sx={{ mt: -1.5 }}>
<PriceDisplay
price={selectedVariant.priceIrr}
priceUnit={selectedVariant.priceUnit}
sessionCount={selectedVariant.sessionCount}
/>
</Box>
) : null}
{/* Address */}
{addresses.length === 0 ? (
<FieldEmpty
label={t('address_label')}
message={t('address_empty')}
ctaLabel={t('address_add_cta')}
onCta={() => router.push(`/${locale}${ROUTES.ADDRESSES}`)}
/>
) : (
<Stack sx={{ gap: 1 }}>
<TextField
select
label={t('address_label')}
value={addressId}
error={attempted && addressId === ''}
helperText={attempted && addressId === '' ? t('error_address_required') : undefined}
onChange={(event) => setAddressSel(Number(event.target.value))}
fullWidth
>
<MenuItem value="" disabled>
{t('address_placeholder')}
</MenuItem>
{addresses.map((address) => (
<MenuItem key={address.id} value={address.id}>
{address.title} · {locale === 'en' ? address.cityNameEn : address.cityNameFa}
</MenuItem>
))}
</TextField>
{selectedAddress ? (
<Paper elevation={0} sx={{ p: 1.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 1 }}>
{regionLabel()}
{selectedAddress.addressLine ? `${selectedAddress.addressLine}` : ''}
</Typography>
{selectedAddress.latitude != null && selectedAddress.longitude != null ? (
// Read-only preview of the address's stored pin (the pin itself is set in the f3 book).
<Box sx={{ pointerEvents: 'none' }}>
<AddressMapPicker
value={{ latitude: selectedAddress.latitude, longitude: selectedAddress.longitude }}
onChange={() => undefined}
center={cityCentroid(selectedAddress.cityId)}
helperText={regionLabel()}
latLabel={tAddress('map_lat')}
lngLabel={tAddress('map_lng')}
/>
</Box>
) : null}
</Paper>
) : null}
</Stack>
)}
{/* Date + time */}
<Stack direction={{ xs: 'column', sm: 'row' }} sx={{ gap: 2 }}>
<TextField
type="date"
label={t('date_label')}
value={date}
error={(attempted && date === '') || pastError}
helperText={pastError ? t('error_past_date') : attempted && date === '' ? t('error_date_required') : undefined}
onChange={(event) => {
setDate(event.target.value);
if (pastDateError) setPastDateError(false);
}}
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
<TextField
type="time"
label={t('time_start_label')}
value={timeStart}
onChange={(event) => {
setTimeStart(event.target.value);
if (pastDateError) setPastDateError(false);
}}
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
<TextField
type="time"
label={t('time_end_label')}
value={timeEnd}
error={timeError}
helperText={timeError ? t('error_time_range') : undefined}
onChange={(event) => setTimeEnd(event.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
</Stack>
{/* Caregiver gender — first-class, three-way, never silently defaulted */}
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('gender_label')}
</Typography>
<ToggleButtonGroup
exclusive
color="primary"
value={gender || null}
onChange={(_event, next: RequiredCaregiverGender | null) => {
if (next) setGender(next);
}}
sx={{
'& .MuiToggleButton-root': {
flex: 1,
py: 1.25,
fontWeight: 600,
borderColor: attempted && gender === '' ? 'var(--bal-error)' : undefined,
},
}}
>
{GENDER_OPTIONS.map((option) => (
<ToggleButton key={option} value={option} data-gender={option}>
{t(`gender_${option}`)}
</ToggleButton>
))}
</ToggleButtonGroup>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('gender_hint')}
</Typography>
{attempted && gender === '' ? (
<Typography variant="caption" sx={{ color: 'var(--bal-error)' }}>
{t('error_gender_required')}
</Typography>
) : null}
{genderMismatch ? (
<Typography variant="caption" sx={{ color: 'var(--bal-error)' }}>
{t('error_gender_mismatch')}
</Typography>
) : null}
</Stack>
{/* Stage-1 notes */}
<TextField
label={t('notes_label')}
placeholder={t('notes_placeholder')}
value={notes}
onChange={(event) => setNotes(event.target.value.slice(0, CUSTOMER_NOTES_MAX_LENGTH))}
multiline
minRows={3}
fullWidth
helperText={t('notes_hint')}
/>
<Typography variant="caption" sx={{ color: 'text.secondary', textAlign: 'end', mt: -2 }}>
{t('notes_counter', { count: notes.length, max: CUSTOMER_NOTES_MAX_LENGTH })}
</Typography>
{formError ? (
<Typography variant="body2" sx={{ color: 'var(--bal-error)' }}>
{formError}
</Typography>
) : null}
<AppButton
color="primary"
variant="contained"
size="large"
startIcon="requests"
disabled={!requiredChosen || genderMismatch || createRequest.isPending}
onClick={handleSubmit}
sx={{ m: 0, py: 1.5 }}
>
{createRequest.isPending ? t('submitting') : t('submit')}
</AppButton>
</Stack>
);
}
/** Map a create `ApiError` (domain 400/404 codes) to a translated, user-facing message. */
function mapCreateError(error: unknown, t: (key: string) => string): string {
if (error instanceof ApiError) {
switch (error.code) {
case 'gender_required':
return t('error_gender_required');
case 'invalid_time_range':
return t('error_time_range');
case 'past_date':
return t('error_past_date');
case 'notes_too_long':
return t('error_notes_long');
case 'gender_mismatch':
return t('error_gender_mismatch');
case 'inactive_variant':
case 'not_bookable':
return t('error_not_bookable');
case 'not_found':
return t('error_tenancy');
default:
break;
}
if (error.status === 404) return t('error_tenancy');
}
return t('error_generic');
}
function FieldEmpty({
label,
message,
ctaLabel,
onCta,
}: {
label: string;
message: string;
ctaLabel: string;
onCta: () => void;
}) {
return (
<Stack sx={{ gap: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{label}
</Typography>
<Paper elevation={0} sx={{ p: 2, border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{message}
</Typography>
<AppButton variant="outlined" color="primary" startIcon="add" onClick={onCta} sx={{ m: 0 }}>
{ctaLabel}
</AppButton>
</Stack>
</Paper>
</Stack>
);
}
function EmptyState({
icon,
title,
body,
ctaLabel,
onCta,
}: {
icon: string;
title: string;
body: string;
ctaLabel: string;
onCta: () => void;
}) {
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
<AppIcon icon={icon} size={40} color="var(--bal-text-secondary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1, mb: 0.5 }}>
{title}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
{body}
</Typography>
<AppButton variant="contained" color="primary" onClick={onCta} sx={{ m: 0 }}>
{ctaLabel}
</AppButton>
</Paper>
);
}
function FormSkeleton() {
return (
<Stack sx={{ gap: 2.5 }}>
<Skeleton variant="text" width="50%" height={36} />
{[0, 1, 2, 3].map((key) => (
<Skeleton key={key} variant="rounded" height={56} />
))}
<Skeleton variant="rounded" height={96} />
</Stack>
);
}
@@ -0,0 +1,7 @@
'use client';
import { NotificationCenter } from '@/components/notifications';
/** /notifications — the customer notification center (f14). The bell deep-links here. */
export default function CustomerNotificationsPage() {
return <NotificationCenter role="customer" />;
}
@@ -0,0 +1,546 @@
'use client';
import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useParams, useRouter } from 'next/navigation';
import { useSnackbar } from 'notistack';
import {
Checkbox,
FormControlLabel,
IconButton,
Paper,
Skeleton,
Stack,
Tab,
Tabs,
TextField,
Typography,
} from '@mui/material';
import { AppButton, AppIcon, PatientHeader, VisitNoteCard } from '@/components';
import { ROUTES } from '@/constants';
import { formatShamsiDate } from '@/utils';
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 type {
CareRecordTab,
CareTask,
FamilyCareRecord,
Medication,
RoutineItem,
} 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).
*/
export default function PatientRecordPage() {
const t = useTranslations('records');
// Shared enum labels are reused from the onboarding/patients namespaces — never re-keyed.
const to = useTranslations('onboarding');
const tp = useTranslations('patients');
const params = useParams<{ id: string }>();
const rawId = Number(params.id);
const patientId = Number.isInteger(rawId) && rawId > 0 ? rawId : -1;
const access = useRecordAccess(patientId);
const canView = access.data?.canView ?? false;
const patient = usePatient(patientId, { enabled: canView });
const [tab, setTab] = useState<CareRecordTab>('medications');
if (access.isLoading) return <RecordSkeleton />;
// Access-denied — a clear, non-leaking card (never any clinical data).
if (access.data && !access.data.canView) {
return (
<Stack sx={{ gap: 3, maxWidth: 640, mx: 'auto', width: '100%' }}>
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
<AppIcon icon="lock" size={40} color="var(--bal-text-secondary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1.5, mb: 0.5 }}>
{t('access_denied_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('access_denied_body')}
</Typography>
</Paper>
<BackToPatients />
</Stack>
);
}
if (patient.isLoading) return <RecordSkeleton />;
if (patient.isError || !patient.data) {
return (
<Stack sx={{ gap: 3, maxWidth: 640, mx: 'auto', width: '100%' }}>
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 0.5 }}>
{t('not_found_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('not_found_body')}
</Typography>
</Paper>
<BackToPatients />
</Stack>
);
}
const p = patient.data;
const age = birthDateToAge(p.birthDate);
return (
<Stack sx={{ gap: 3, maxWidth: 640, mx: 'auto', width: '100%' }}>
<PatientHeader
displayName={p.displayName}
relationLabel={p.relation ? to(`relation_${p.relation}`) : undefined}
genderLabel={to(`gender_${p.gender}`)}
ageLabel={age == null ? undefined : tp('age_years', { age })}
conditionLabels={p.conditions.map((code) => to(`condition_${code}`))}
noConditionsLabel={tp('conditions_none')}
/>
<Paper
elevation={0}
sx={{ p: 2, borderRadius: 2, backgroundColor: 'var(--bal-primary-soft)', display: 'flex', gap: 1, alignItems: 'center' }}
>
<AppIcon icon="family" size={22} color="var(--bal-primary)" />
<Typography variant="body2" sx={{ color: 'var(--bal-primary)', fontWeight: 600 }}>
{t('ownership_banner')}
</Typography>
</Paper>
<Tabs
value={tab}
onChange={(_, next: CareRecordTab) => setTab(next)}
variant="scrollable"
scrollButtons="auto"
allowScrollButtonsMobile
sx={{ borderBottom: 1, borderColor: 'divider' }}
>
{CARE_RECORD_TABS.map((value) => (
<Tab key={value} value={value} label={t(`tab_${value}`)} sx={{ textTransform: 'none', fontWeight: 700 }} />
))}
</Tabs>
{tab === 'history' ? (
<HistoryTab patientId={patientId} />
) : (
<EditableTabs patientId={patientId} tab={tab} canEdit={access.data?.canEdit ?? false} />
)}
</Stack>
);
}
/** The three customer-editable tabs (medications/routine/tasks) share one record read + one save mutation. */
function EditableTabs({ patientId, tab, canEdit }: { patientId: number; tab: CareRecordTab; canEdit: boolean }) {
const t = useTranslations('records');
const { enqueueSnackbar } = useSnackbar();
const record = usePatientCareRecord(patientId);
const update = useUpdateCareRecord(patientId);
if (record.isLoading) {
return (
<Stack sx={{ gap: 1.5 }}>
<Skeleton variant="rounded" height={72} />
<Skeleton variant="rounded" height={72} />
</Stack>
);
}
if (record.isError || !record.data) {
return (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('load_error')}
</Typography>
);
}
const data = record.data;
const save = (patch: Partial<Pick<FamilyCareRecord, 'medications' | 'routine' | 'tasks'>>, onDone: () => void) => {
update.mutate(patch, {
onSuccess: () => {
enqueueSnackbar(t('saved'), { variant: 'success' });
onDone();
},
onError: () => enqueueSnackbar(t('save_error'), { variant: 'error' }),
});
};
if (tab === 'medications') {
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 <TasksTab data={data.tasks} canEdit={canEdit} saving={update.isPending} onSave={(tasks, done) => save({ tasks }, done)} />;
}
// ── Medications ───────────────────────────────────────────────────────────────────────────────────────────
function MedicationsTab({
data,
canEdit,
saving,
onSave,
}: {
data: Medication[];
canEdit: boolean;
saving: boolean;
onSave: (medications: Medication[], onDone: () => void) => void;
}) {
const t = useTranslations('records');
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState<Medication[]>(data);
const startEdit = () => {
setDraft(data.map((m) => ({ ...m })));
setEditing(true);
};
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>
);
}
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}
/>
</Stack>
);
}
// ── Routine ───────────────────────────────────────────────────────────────────────────────────────────────
function RoutineTab({
data,
canEdit,
saving,
onSave,
}: {
data: RoutineItem[];
canEdit: boolean;
saving: boolean;
onSave: (routine: RoutineItem[], onDone: () => void) => void;
}) {
const t = useTranslations('records');
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState<RoutineItem[]>(data);
const startEdit = () => {
setDraft(data.map((r) => ({ ...r })));
setEditing(true);
};
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>
);
}
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} />
</Stack>
);
}
// ── Tasks ─────────────────────────────────────────────────────────────────────────────────────────────────
function TasksTab({
data,
canEdit,
saving,
onSave,
}: {
data: CareTask[];
canEdit: boolean;
saving: boolean;
onSave: (tasks: CareTask[], onDone: () => void) => void;
}) {
const t = useTranslations('records');
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState<CareTask[]>(data);
const startEdit = () => {
setDraft(data.map((task) => ({ ...task })));
setEditing(true);
};
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>
);
}
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>
);
}
// ── History (سوابق) — patient-scoped, read-only, paged ────────────────────────────────────────────────────
function HistoryTab({ patientId }: { patientId: number }) {
const t = useTranslations('records');
const locale = useLocale();
const [page, setPage] = useState(1);
const history = usePatientHistory(patientId, page);
if (history.isLoading) {
return (
<Stack sx={{ gap: 1.5 }}>
<Skeleton variant="rounded" height={96} />
<Skeleton variant="rounded" height={96} />
</Stack>
);
}
if (history.isError || !history.data) {
return (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('load_error')}
</Typography>
);
}
const { items, total, pageSize } = history.data;
if (items.length === 0) {
return (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('history_empty')}
</Typography>
);
}
const totalPages = Math.max(1, Math.ceil(total / pageSize));
return (
<Stack sx={{ gap: 1.5 }}>
{items.map((note) => (
<VisitNoteCard key={note.id} note={note} dateLabel={formatShamsiDate(note.recordedAt, locale)} authorFallback={t('author_fallback')} />
))}
{totalPages > 1 ? (
<Stack direction="row" sx={{ gap: 1, justifyContent: 'center', alignItems: 'center' }}>
<AppButton variant="text" color="primary" disabled={page <= 1 || history.isFetching} onClick={() => setPage((n) => Math.max(1, n - 1))} sx={{ m: 0 }}>
{t('prev')}
</AppButton>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('page_of', { page, total: totalPages })}
</Typography>
<AppButton variant="text" color="primary" disabled={page >= totalPages || history.isFetching} onClick={() => setPage((n) => n + 1)} sx={{ m: 0 }}>
{t('next')}
</AppButton>
</Stack>
) : null}
</Stack>
);
}
// ── 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={{ m: 0, 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={{ m: 0, 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} sx={{ m: 0 }}>
{tc('cancel')}
</AppButton>
<AppButton variant="contained" color="primary" onClick={onSave} disabled={saving || !canSave} sx={{ m: 0 }}>
{saving ? tc('saving') : tc('save')}
</AppButton>
</Stack>
</Stack>
);
}
function BackToPatients() {
const t = useTranslations('records');
const router = useRouter();
const locale = useLocale();
return (
<AppButton
variant="outlined"
color="primary"
onClick={() => router.push(`/${locale}${ROUTES.PATIENTS}`)}
sx={{ m: 0, alignSelf: 'flex-start' }}
>
{t('back_to_patients')}
</AppButton>
);
}
function RecordSkeleton() {
return (
<Stack sx={{ gap: 3, maxWidth: 640, mx: 'auto', width: '100%' }}>
<Stack sx={{ gap: 1 }}>
<Skeleton variant="text" width="50%" height={32} />
<Skeleton variant="text" width="30%" />
</Stack>
<Skeleton variant="rounded" height={56} />
<Skeleton variant="rounded" height={48} />
<Skeleton variant="rounded" height={96} />
</Stack>
);
}
@@ -1,6 +1,7 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { useSnackbar } from 'notistack';
import {
Box,
@@ -14,6 +15,7 @@ import {
Typography,
} from '@mui/material';
import { AppButton, AppIcon, 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';
@@ -27,6 +29,8 @@ export default function PatientsPage() {
const t = useTranslations('patients');
const to = useTranslations('onboarding');
const tc = useTranslations('common');
const router = useRouter();
const locale = useLocale();
const { enqueueSnackbar } = useSnackbar();
const { data, isLoading } = usePatients();
@@ -141,6 +145,8 @@ export default function PatientsPage() {
ageLabel={age == null ? undefined : t('age_years', { age })}
conditionLabels={patient.conditions.map((code) => to(`condition_${code}`))}
noConditionsLabel={t('conditions_none')}
onOpen={() => router.push(`/${locale}${patientRecordPath(patient.id)}`)}
openLabel={t('open_record', { name: patient.displayName })}
onEdit={() => openEdit(patient)}
onArchive={() => setArchiveTarget(patient)}
editLabel={t('edit')}
@@ -0,0 +1,328 @@
'use client';
import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useParams, useRouter, useSearchParams } from 'next/navigation';
import { Avatar, Box, Chip, Paper, Skeleton, Stack, Tab, Tabs, Typography } from '@mui/material';
import { AppButton, AppIcon, RatingInput, ServicePriceRow, TrustBadge } from '@/components';
import { ROUTES } from '@/constants';
import { ApiError } from '@/lib/api/errors';
import { formatShamsiDate } from '@/utils';
import { useNurseProfile } from '@/services/search';
import type { NurseProfile } from '@/services/search/types';
import { useNurseReviews } from '@/services/reviews';
import type { ReviewListItem } from '@/services/reviews/types';
type ProfileTab = 'services' | 'reviews';
/**
* C3 — Nurse profile (پروفایل پرستار): identity + trust badges (✓ تاییدشده, نظام پرستاری), attribute
* chips, and a **tabbed** body — «خدمات» (the priced services list) and «نظرات» (the f13 published-reviews
* tab: aggregate rating + count + an infinite list). Only `published` reviews are ever requested/rendered.
* The primary CTA "درخواست رزرو" hands the selected nurse + variant + `required_caregiver_gender` to f7.
*/
export default function NurseProfilePage() {
const t = useTranslations('search');
const router = useRouter();
const locale = useLocale();
const routeParams = useParams<{ nurseId: string }>();
const query = useSearchParams();
const nurseId = Number(routeParams.nurseId);
const { data: profile, isLoading, isError, error, refetch } = useNurseProfile(
Number.isInteger(nurseId) && nurseId > 0 ? nurseId : undefined,
);
const [tab, setTab] = useState<ProfileTab>('services');
if (isLoading) return <ProfileSkeleton />;
if (isError) {
const notFound = error instanceof ApiError && error.status === 404;
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1 }}>
{notFound ? t('profile_not_found_title') : t('profile_error_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
{notFound ? t('profile_not_found_body') : t('profile_error_body')}
</Typography>
<AppButton
variant="outlined"
color="primary"
onClick={() => (notFound ? router.push(`/${locale}${ROUTES.SEARCH}`) : refetch())}
sx={{ m: 0 }}
>
{notFound ? t('profile_not_found_cta') : t('retry')}
</AppButton>
</Paper>
);
}
if (!profile) return null;
const requestBooking = () => {
const carriedVariant = query.get('variant_id');
const variantId = carriedVariant ?? String(profile.services[0]?.variantId ?? '');
const params = new URLSearchParams();
params.set('nurse_id', String(profile.nurseId));
if (variantId) params.set('variant_id', variantId);
// The same-gender intent chosen on C1, carried BEFORE booking (becomes required_caregiver_gender in f7/b8).
const requiredGender = query.get('required_gender');
if (requiredGender) params.set('required_gender', requiredGender);
const cityId = query.get('city_id');
if (cityId) params.set('city_id', cityId);
const categoryId = query.get('service_category_id');
if (categoryId) params.set('service_category_id', categoryId);
const date = query.get('date');
if (date) params.set('date', date);
router.push(`/${locale}${ROUTES.BOOKING_REQUEST}?${params.toString()}`);
};
return (
<Stack sx={{ gap: 3 }}>
<ProfileHeader profile={profile} />
<AttributeChips profile={profile} />
<Tabs value={tab} onChange={(_, next: ProfileTab) => setTab(next)} sx={{ borderBottom: 1, borderColor: 'divider' }}>
<Tab value="services" label={t('tab_services')} sx={{ textTransform: 'none', fontWeight: 700 }} />
<Tab value="reviews" label={t('tab_reviews')} sx={{ textTransform: 'none', fontWeight: 700 }} />
</Tabs>
{tab === 'services' ? <ServicesSection profile={profile} /> : <ReviewsPanel nurseId={profile.nurseId} />}
<AppButton
color="primary"
variant="contained"
size="large"
onClick={requestBooking}
startIcon="bookings"
sx={{ m: 0, py: 1.5 }}
>
{t('request_booking')}
</AppButton>
</Stack>
);
}
function ProfileHeader({ profile }: { profile: NurseProfile }) {
const t = useTranslations('search');
const locale = useLocale();
const name = profile.nurseName.trim() || t('unnamed_nurse');
const rating = new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US', {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
}).format(profile.averageRating);
return (
<Stack sx={{ gap: 1.5 }}>
<Stack direction="row" sx={{ gap: 2, alignItems: 'center' }}>
<Avatar
src={profile.avatarUrl ?? undefined}
sx={{ width: 72, height: 72, bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 700, fontSize: 28 }}
>
{name.charAt(0)}
</Avatar>
<Stack sx={{ gap: 0.5 }}>
<Typography variant="h5" component="h1">
{name}
</Typography>
<Stack direction="row" sx={{ gap: 0.5, alignItems: 'center' }}>
<AppIcon icon="star" size={18} color="var(--bal-warning)" />
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{rating}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('reviews_count', { count: profile.totalReviews })}
</Typography>
</Stack>
</Stack>
</Stack>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
<TrustBadge state="verified" />
{profile.inoMembership ? (
<Chip
icon={<AppIcon icon="license" size={16} color="var(--bal-primary)" />}
label={t('badge_ino')}
sx={{ backgroundColor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 700 }}
/>
) : null}
</Stack>
{profile.bio ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{profile.bio}
</Typography>
) : null}
</Stack>
);
}
function AttributeChips({ profile }: { profile: NurseProfile }) {
const t = useTranslations('search');
const locale = useLocale();
const chips: string[] = [];
if (profile.yearsExperience != null && profile.yearsExperience > 0) {
const years = new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US').format(profile.yearsExperience);
chips.push(t('years_experience', { years }));
}
for (const code of profile.attributeChips) {
chips.push(t.has(`specialty_${code}`) ? t(`specialty_${code}`) : code);
}
if (chips.length === 0) return null;
return (
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
{chips.map((label) => (
<Chip key={label} label={label} variant="outlined" />
))}
</Stack>
);
}
function ServicesSection({ profile }: { profile: NurseProfile }) {
const t = useTranslations('search');
return (
<Stack sx={{ gap: 1 }}>
{profile.services.length === 0 ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('services_empty')}
</Typography>
) : (
<Box>
{profile.services.map((service) => (
<ServicePriceRow
key={service.variantId}
displayName={service.displayName}
priceIrr={service.priceIrr}
priceUnit={service.priceUnit}
sessionCount={service.sessionCount}
/>
))}
</Box>
)}
</Stack>
);
}
/**
* The f13 reviews tab — the aggregate rating + count and an infinite list of **published** reviews. Never
* requests or renders `pending_moderation`/`hidden`/`rejected` content; the aggregate is the server's
* recomputed value, not a client sum.
*/
function ReviewsPanel({ nurseId }: { nurseId: number }) {
const t = useTranslations('reviews');
const locale = useLocale();
const { data, isLoading, isError, refetch, fetchNextPage, hasNextPage, isFetchingNextPage } = useNurseReviews(nurseId);
if (isLoading) {
return (
<Stack sx={{ gap: 1.5 }}>
<Skeleton variant="text" width="40%" />
<Skeleton variant="rounded" height={88} />
<Skeleton variant="rounded" height={88} />
</Stack>
);
}
if (isError) {
return (
<Paper elevation={0} sx={{ p: 3, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 1.5 }}>
{t('load_error')}
</Typography>
<AppButton variant="outlined" color="primary" onClick={() => refetch()} sx={{ m: 0 }}>
{t('retry')}
</AppButton>
</Paper>
);
}
const aggregate = data?.pages[0]?.aggregate;
const items = data?.pages.flatMap((page) => page.reviews.items) ?? [];
const publishedCount = aggregate?.publishedCount ?? 0;
if (publishedCount === 0) {
return (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('reviews_empty')}
</Typography>
);
}
const average = new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US', {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
}).format(aggregate?.averageRating ?? 0);
return (
<Stack sx={{ gap: 2 }}>
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<RatingInput value={Math.round(aggregate?.averageRating ?? 0)} readOnly size={20} ariaLabel={t('rating_label')} />
<Typography variant="h6" component="p" sx={{ fontWeight: 700 }}>
{average}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('count', { count: publishedCount })}
</Typography>
</Stack>
<Stack sx={{ gap: 1.5 }}>
{items.map((review) => (
<ReviewCard key={review.id} review={review} />
))}
</Stack>
{hasNextPage ? (
<AppButton
variant="text"
color="primary"
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
sx={{ m: 0, alignSelf: 'center' }}
>
{isFetchingNextPage ? t('loading') : t('load_more')}
</AppButton>
) : null}
</Stack>
);
}
function ReviewCard({ review }: { review: ReviewListItem }) {
const t = useTranslations('reviews');
const locale = useLocale();
return (
<Paper elevation={0} sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', mb: 0.5, flexWrap: 'wrap' }}>
<RatingInput value={review.rating} readOnly size={16} ariaLabel={t('rating_label')} />
<Typography variant="caption" sx={{ color: 'text.secondary', marginInlineStart: 'auto' }}>
{t('author_masked')} · {formatShamsiDate(review.createdAt, locale)}
</Typography>
</Stack>
{review.body ? <Typography variant="body2">{review.body}</Typography> : null}
{review.tagCodes.length > 0 ? (
<Stack direction="row" sx={{ gap: 0.5, flexWrap: 'wrap', mt: 1 }}>
{review.tagCodes.map((code) => (
<Chip key={code} size="small" variant="outlined" label={t.has(`tag_${code}`) ? t(`tag_${code}`) : code} />
))}
</Stack>
) : null}
</Paper>
);
}
function ProfileSkeleton() {
return (
<Stack sx={{ gap: 3 }}>
<Stack direction="row" sx={{ gap: 2, alignItems: 'center' }}>
<Skeleton variant="circular" width={72} height={72} />
<Stack sx={{ gap: 1, flexGrow: 1 }}>
<Skeleton variant="text" width="60%" height={32} />
<Skeleton variant="text" width="40%" />
</Stack>
</Stack>
<Skeleton variant="rounded" height={72} />
<Skeleton variant="rounded" height={120} />
<Skeleton variant="rounded" height={96} />
</Stack>
);
}
@@ -1,35 +1,220 @@
'use client';
import { Suspense } from 'react';
import { useSearchParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { AppLoading, PlaceholderScreen } from '@/components';
import { Suspense, type FunctionComponent, type ReactNode } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import {
Box,
InputAdornment,
Paper,
Skeleton,
Stack,
TextField,
ToggleButton,
ToggleButtonGroup,
Typography,
} from '@mui/material';
import { AppButton, AppIcon, AppLoading, CategoryTile } from '@/components';
import CascadingRegionSelect from '@/components/geography/CascadingRegionSelect';
import { ROUTES } from '@/constants';
import { useServiceCategories } from '@/services/catalog';
import { pickCatalogName } from '@/services/catalog/names';
import { useNurseSearch } from '@/services/search';
import { filtersToSearchParams } from '@/services/search/filterParams';
import type { NurseGender } from '@/services/search/types';
import { useSearchFilters } from './useSearchFilters';
/**
* Search landing — **DEFERRED → frontend-phase-6-b7**. The A5 Home search bar and category tiles
* navigate here carrying a `q` / `category_id`; f6 builds the actual results, filters, and nurse
* cards. This placeholder just acknowledges the intent so the Home CTAs don't dead-end. `useSearchParams`
* needs a Suspense boundary under static rendering.
* C1 — Search & filter (جستجو و فیلتر): the discovery entry screen. Pick a care category (reusing the
* f4 catalog grid), a city (reusing the f3 cascading region picker; district optional = whole city),
* the **prominent same-gender facet**, and an optional Toman price range; a live result count drives the
* "مشاهده N پرستار" CTA into C2. Availability (date) is intent-only at MVP — it is carried to booking,
* never used to hard-filter results. `useSearchParams` needs a Suspense boundary under static rendering.
*/
export default function SearchPage() {
return (
<Suspense fallback={<AppLoading />}>
<SearchDeferred />
<SearchFilterScreen />
</Suspense>
);
}
function SearchDeferred() {
const GENDER_OPTIONS: readonly (NurseGender | 'any')[] = ['female', 'male', 'any'];
function SearchFilterScreen() {
const t = useTranslations('search');
const router = useRouter();
const locale = useLocale();
const params = useSearchParams();
const query = params.get('q');
const categoryId = params.get('category_id');
const echo = query ? t('query_echo', { query }) : categoryId ? t('category_echo') : undefined;
const initialCategoryRaw = Number(params.get('category_id'));
const initialCategoryId = Number.isInteger(initialCategoryRaw) && initialCategoryRaw > 0 ? initialCategoryRaw : undefined;
const controller = useSearchFilters(initialCategoryId);
const { data, isFetching } = useNurseSearch(controller.filters);
const count = data?.total;
const goToResults = () => {
const query = filtersToSearchParams(controller.filters);
if (controller.dateIntent) query.set('date', controller.dateIntent);
router.push(`/${locale}${ROUTES.SEARCH_RESULTS}?${query.toString()}`);
};
const ctaLabel = !controller.isReady
? t('cta_choose_category_city')
: isFetching || count == null
? t('cta_loading')
: t('cta_view_results', { count });
return (
<PlaceholderScreen
icon="search"
title={t('title')}
description={[t('deferred'), echo].filter(Boolean).join(' ')}
/>
<Stack sx={{ gap: 3 }}>
<Box>
<Typography variant="h5" component="h1">
{t('title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('subtitle')}
</Typography>
</Box>
<CategorySelect selectedId={controller.categoryId} onSelect={controller.setCategoryId} />
<FilterSection title={t('section_location')}>
<CascadingRegionSelect value={controller.region} onChange={controller.setRegion} includeDistrict />
</FilterSection>
<FilterSection title={t('section_gender')} hint={t('gender_hint')}>
<ToggleButtonGroup
exclusive
fullWidth
color="primary"
value={controller.gender ?? 'any'}
onChange={(_event, value: NurseGender | 'any' | null) => {
if (value != null) controller.setGender(value === 'any' ? undefined : value);
}}
>
{GENDER_OPTIONS.map((option) => (
<ToggleButton key={option} value={option} sx={{ fontWeight: 700 }}>
{t(`gender_${option}`)}
</ToggleButton>
))}
</ToggleButtonGroup>
</FilterSection>
<FilterSection title={t('section_date')} hint={t('date_hint')}>
<TextField
type="date"
fullWidth
value={controller.dateIntent}
onChange={(event) => controller.setDateIntent(event.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
/>
</FilterSection>
<FilterSection title={t('section_price')} hint={t('price_hint')}>
<Stack direction="row" sx={{ gap: 2 }}>
<PriceField
label={t('price_min')}
value={controller.priceMinToman}
onChange={controller.setPriceMinToman}
adornment={t('toman')}
/>
<PriceField
label={t('price_max')}
value={controller.priceMaxToman}
onChange={controller.setPriceMaxToman}
adornment={t('toman')}
/>
</Stack>
</FilterSection>
<AppButton
color="primary"
variant="contained"
size="large"
disabled={!controller.isReady}
onClick={goToResults}
startIcon="search"
sx={{ m: 0, py: 1.5 }}
>
{ctaLabel}
</AppButton>
</Stack>
);
}
const FilterSection: FunctionComponent<{ title: string; hint?: string; children: ReactNode }> = ({
title,
hint,
children,
}) => (
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{title}
</Typography>
{hint ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{hint}
</Typography>
) : null}
{children}
</Stack>
);
const PriceField: FunctionComponent<{
label: string;
value: string;
onChange: (value: string) => void;
adornment: string;
}> = ({ label, value, onChange, adornment }) => (
<TextField
label={label}
value={value}
onChange={(event) => onChange(event.target.value)}
inputMode="numeric"
fullWidth
slotProps={{
input: { endAdornment: <InputAdornment position="end">{adornment}</InputAdornment> },
}}
/>
);
/** The reused f4 category grid (data-driven from the cached catalog reference data), with selection. */
const CategorySelect: FunctionComponent<{ selectedId: number | null; onSelect: (id: number) => void }> = ({
selectedId,
onSelect,
}) => {
const t = useTranslations('search');
const locale = useLocale();
const { data, isLoading, isError } = useServiceCategories();
const categories = data?.items ?? [];
return (
<FilterSection title={t('section_category')}>
{isLoading ? (
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'repeat(2, 1fr)', sm: 'repeat(3, 1fr)' }, gap: 1.5 }}>
{[0, 1, 2, 3].map((key) => (
<Skeleton key={key} variant="rounded" height={116} sx={{ borderRadius: 2 }} />
))}
</Box>
) : isError ? (
<Paper elevation={0} sx={{ p: 3, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('categories_error')}
</Typography>
</Paper>
) : (
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'repeat(2, 1fr)', sm: 'repeat(3, 1fr)' }, gap: 1.5 }}>
{categories.map((category) => (
<CategoryTile
key={category.id}
label={pickCatalogName(category, locale)}
iconKey={category.iconKey}
selected={category.id === selectedId}
onClick={() => onSelect(category.id)}
/>
))}
</Box>
)}
</FilterSection>
);
};
@@ -0,0 +1,136 @@
'use client';
import { Suspense, useCallback, useMemo, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { Box, MenuItem, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material';
import { AppButton, AppIcon, AppLoading, NurseResultCard } from '@/components';
import { ROUTES } from '@/constants';
import { useNurseSearch } from '@/services/search';
import { searchParamsToFilters } from '@/services/search/filterParams';
import { SEARCH_PAGE_SIZE } from '@/services/search/constants';
import type { NurseSearchResult } from '@/services/search/types';
/**
* 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),
* so returning to a prior filter URL is a cache hit with zero network calls (`useNurseSearch` +
* `keepPreviousData`). Renders all four states (loading skeletons / empty "relax filters" / error-retry
* / populated). Tapping a card opens C3, carrying the nurse + variant + gender intent.
*/
export default function SearchResultsPage() {
return (
<Suspense fallback={<AppLoading />}>
<ResultsScreen />
</Suspense>
);
}
function ResultsScreen() {
const t = useTranslations('search');
const locale = useLocale();
const router = useRouter();
const params = useSearchParams();
const [pageSize, setPageSize] = useState(SEARCH_PAGE_SIZE);
// The URL is the source of truth for the filter set; grow only the page size for "load more".
const filters = useMemo(() => ({ ...searchParamsToFilters(params), pageSize }), [params, pageSize]);
const dateIntent = params.get('date') ?? undefined;
const { data, isLoading, isError, isFetching, refetch } = useNurseSearch(filters);
const items = data?.items ?? [];
const total = data?.total ?? 0;
const hasMore = items.length < total;
const openProfile = useCallback(
(nurse: NurseSearchResult) => {
const query = new URLSearchParams();
query.set('variant_id', String(nurse.variantId));
query.set('service_category_id', String(filters.serviceCategoryId));
query.set('city_id', String(filters.cityId));
if (filters.nurseGender) query.set('required_gender', filters.nurseGender);
if (dateIntent) query.set('date', dateIntent);
router.push(`/${locale}${ROUTES.SEARCH_NURSE}/${nurse.nurseId}?${query.toString()}`);
},
[router, locale, filters.serviceCategoryId, filters.cityId, filters.nurseGender, dateIntent],
);
const backToFilters = () => router.push(`/${locale}${ROUTES.SEARCH}`);
return (
<Stack sx={{ gap: 2 }}>
<Stack direction="row" sx={{ gap: 2, alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap' }}>
<Typography variant="h6" component="h1">
{isLoading ? t('results_loading_title') : t('results_count', { count: total })}
</Typography>
{/* Rating is the only MVP sort; rendered as a control with a single option. Other sorts DEFERRED. */}
<TextField select size="small" label={t('sort_label')} value="rating" sx={{ minWidth: 160 }}>
<MenuItem value="rating">{t('sort_rating')}</MenuItem>
</TextField>
</Stack>
{isLoading ? (
<Stack sx={{ gap: 1.5 }}>
{[0, 1, 2, 3].map((key) => (
<Skeleton key={key} variant="rounded" height={112} sx={{ borderRadius: 2 }} />
))}
</Stack>
) : isError ? (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
{t('results_error')}
</Typography>
<AppButton variant="outlined" color="primary" onClick={() => refetch()} sx={{ m: 0 }}>
{t('retry')}
</AppButton>
</Paper>
) : items.length === 0 ? (
<EmptyState onRelax={backToFilters} />
) : (
<Stack sx={{ gap: 1.5 }}>
{items.map((nurse) => (
<NurseResultCard key={`${nurse.nurseId}-${nurse.variantId}`} nurse={nurse} onSelect={openProfile} />
))}
{hasMore ? (
<AppButton
variant="outlined"
color="primary"
onClick={() => setPageSize((size) => size + SEARCH_PAGE_SIZE)}
disabled={isFetching}
sx={{ m: 0, alignSelf: 'center' }}
>
{t('load_more')}
</AppButton>
) : null}
</Stack>
)}
</Stack>
);
}
/** The "no nurses match → relax your filters" state with concrete, product-aligned suggestions. */
function EmptyState({ onRelax }: { onRelax: () => void }) {
const t = useTranslations('search');
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
<AppIcon icon="search" size={40} color="var(--bal-text-secondary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1 }}>
{t('empty_title')}
</Typography>
<Stack sx={{ gap: 0.5, mt: 1, mb: 2 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('empty_suggest_gender')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('empty_suggest_district')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('empty_suggest_city')}
</Typography>
</Stack>
<AppButton variant="contained" color="primary" onClick={onRelax} startIcon="tune" sx={{ m: 0 }}>
{t('empty_cta')}
</AppButton>
</Paper>
);
}
@@ -0,0 +1,68 @@
import { useMemo, useState } from 'react';
import { toEnglishDigits, tomanToRial } from '@/utils';
import { useDebouncedValue } from '@/services/search';
import { SEARCH_FILTER_DEBOUNCE_MS, SEARCH_PAGE_SIZE } from '@/services/search/constants';
import type { NurseGender, NurseSearchFilters } from '@/services/search/types';
import type { CascadingRegionValue } from '@/components/geography/CascadingRegionSelect';
const EMPTY_REGION: CascadingRegionValue = { provinceId: null, cityId: null, districtId: null };
/** Toman input → IRR-Rial digit-string at the field boundary; undefined for blank/invalid input. */
function tomanInputToIrr(toman: string): string | undefined {
const digits = toEnglishDigits(toman).trim();
if (!/^\d+$/.test(digits)) return undefined;
return tomanToRial(digits);
}
/**
* The C1 filter controller — fast-changing UI state kept **colocated** (not in a high context provider,
* phase §5). Holds the category, cascading region, same-gender facet, and Toman price inputs, and
* derives the canonical `NurseSearchFilters` that becomes the live-count query key and the C2 URL. The
* price inputs are **debounced** so typing doesn't fan out one search per keystroke before the value
* joins the query key. `districtId = null` (whole city) is carried as an omitted filter, never a bogus id.
*/
export function useSearchFilters(initialCategoryId?: number) {
const [categoryId, setCategoryId] = useState<number | null>(initialCategoryId ?? null);
const [region, setRegion] = useState<CascadingRegionValue>(EMPTY_REGION);
const [gender, setGender] = useState<NurseGender | undefined>(undefined);
const [priceMinToman, setPriceMinToman] = useState('');
const [priceMaxToman, setPriceMaxToman] = useState('');
const [dateIntent, setDateIntent] = useState('');
const debouncedMin = useDebouncedValue(priceMinToman, SEARCH_FILTER_DEBOUNCE_MS);
const debouncedMax = useDebouncedValue(priceMaxToman, SEARCH_FILTER_DEBOUNCE_MS);
const filters: NurseSearchFilters = useMemo(
() => ({
serviceCategoryId: categoryId ?? 0,
cityId: region.cityId ?? 0,
districtId: region.districtId ?? undefined,
nurseGender: gender,
priceMin: tomanInputToIrr(debouncedMin),
priceMax: tomanInputToIrr(debouncedMax),
sort: 'rating',
page: 1,
pageSize: SEARCH_PAGE_SIZE,
}),
[categoryId, region.cityId, region.districtId, gender, debouncedMin, debouncedMax],
);
const isReady = filters.serviceCategoryId > 0 && filters.cityId > 0;
return {
categoryId,
setCategoryId,
region,
setRegion,
gender,
setGender,
priceMinToman,
setPriceMinToman,
priceMaxToman,
setPriceMaxToman,
dateIntent,
setDateIntent,
filters,
isReady,
};
}
@@ -0,0 +1,11 @@
'use client';
import { useParams } from 'next/navigation';
import { TicketThreadScreen } from '@/components/messaging';
/** /support/tickets/[id] — the customer ticket thread (f14). */
export default function CustomerTicketThreadPage() {
const params = useParams<{ id: string }>();
const id = Number(params.id);
const ticketId = Number.isInteger(id) && id > 0 ? id : -1;
return <TicketThreadScreen role="customer" ticketId={ticketId} />;
}
@@ -0,0 +1,7 @@
'use client';
import { TicketInboxScreen } from '@/components/messaging';
/** /support/tickets — the customer "My Tickets" inbox (f14). Shares the screen with the nurse shell. */
export default function CustomerTicketsPage() {
return <TicketInboxScreen role="customer" />;
}
@@ -0,0 +1,138 @@
'use client';
import { FunctionComponent } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, InstallmentScheduleRow, PlaceholderScreen } from '@/components';
import { formatIrrToToman, formatShamsiDate } from '@/utils';
import { useWalletInstallments } from '@/services/bnpl';
import type { WalletInstallmentPlan } from '@/services/bnpl/types';
/**
* D5 · پیگیری اقساط — the Wallet view of active installment plans. It reads `useWalletInstallments` and
* renders **provider-reported** status: an outstanding-balance card (terracotta), the next-installment
* date + a provider hand-off «پرداخت زودهنگام» (early-pay is a *provider* action, never a Balinyaar
* transaction), the per-installment due list with status chips, and the ownership note (Balinyaar displays,
* it does not manage, this schedule). Self-contained under the Wallet route so f12 nurse-earnings content
* can land beside it later.
*/
const WalletInstallments: FunctionComponent = () => {
const t = useTranslations('bnpl');
const tc = useTranslations('common');
const { data: plans, isLoading, isError, refetch } = useWalletInstallments();
return (
<Stack sx={{ gap: 2, maxWidth: 560, mx: 'auto', width: '100%' }}>
<Typography variant="h6" component="h1">
{t('wallet_title')}
</Typography>
{isLoading ? (
<Stack sx={{ gap: 1.5 }}>
<Skeleton variant="rounded" height={128} />
<Skeleton variant="rounded" height={56} />
<Skeleton variant="rounded" height={56} />
</Stack>
) : isError ? (
<Paper elevation={0} sx={{ p: 3, borderRadius: 2, border: '1px solid', borderColor: 'divider', textAlign: 'center' }}>
<Stack sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon="warning" size={36} color="var(--bal-warning)" />
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('wallet_error_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('wallet_error_body')}
</Typography>
<AppButton variant="outlined" color="secondary" onClick={() => refetch()} sx={{ m: 0, mt: 1 }}>
{tc('retry')}
</AppButton>
</Stack>
</Paper>
) : !plans || plans.length === 0 ? (
<PlaceholderScreen icon="installments" title={t('wallet_empty_title')} description={t('wallet_empty_body')} />
) : (
<Stack sx={{ gap: 3 }}>
{plans.map((plan) => (
<InstallmentPlanSection key={plan.bnplTransactionId} plan={plan} />
))}
</Stack>
)}
</Stack>
);
};
function InstallmentPlanSection({ plan }: { plan: WalletInstallmentPlan }) {
const t = useTranslations('bnpl');
const tc = useTranslations('common');
const locale = useLocale();
const providerName = t(`provider_${plan.providerCode}`);
const handleEarlyPay = () => {
// Early-pay is a PROVIDER action — hand off to the provider, never a Balinyaar payment.
if (plan.earlyPayUrl) window.open(plan.earlyPayUrl, '_blank', 'noopener,noreferrer');
};
return (
<Stack sx={{ gap: 1.5 }}>
{/* Outstanding-balance card — terracotta financial accent; contrast text is scheme-stable. */}
<Paper
elevation={0}
sx={{ p: 2.25, borderRadius: 3, backgroundColor: 'var(--bal-secondary)', color: 'var(--bal-secondary-contrast)' }}
>
<Typography variant="caption" sx={{ opacity: 0.85 }}>
{t('outstanding_balance')}
</Typography>
<Typography variant="h5" sx={{ fontWeight: 800, mt: 0.25 }}>
{formatIrrToToman(plan.outstandingBalanceIrr, locale)} {tc('currency_toman')}
</Typography>
<Typography variant="caption" sx={{ opacity: 0.85, display: 'block', mt: 0.5 }}>
{plan.serviceLabel}
</Typography>
{plan.nextDueDate ? (
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'flex-end', mt: 1.5, gap: 1.5 }}>
<Box>
<Typography variant="caption" sx={{ opacity: 0.85 }}>
{t('next_installment')} · {formatShamsiDate(`${plan.nextDueDate}T00:00:00`, locale)}
</Typography>
{plan.nextAmountIrr ? (
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>
{formatIrrToToman(plan.nextAmountIrr, locale)} {tc('currency_toman')}
</Typography>
) : null}
</Box>
{plan.earlyPayUrl ? (
<AppButton
color="primary"
variant="contained"
size="small"
onClick={handleEarlyPay}
sx={{ m: 0, flex: 'none' }}
>
{t('early_pay')}
</AppButton>
) : null}
</Stack>
) : null}
</Paper>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('due_dates')}
</Typography>
<Stack sx={{ gap: 1 }}>
{plan.installments.map((row) => (
<InstallmentScheduleRow key={`${row.kind}-${row.sequence}`} row={row} showStatus />
))}
</Stack>
{/* Ownership note: Balinyaar displays, it does not manage, this provider-owned schedule. */}
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'flex-start', px: 0.5 }}>
<AppIcon icon="info" size={16} color="var(--bal-text-secondary)" />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('provider_owned_note', { provider: providerName })}
</Typography>
</Stack>
</Stack>
);
}
export default WalletInstallments;
@@ -1,8 +1,10 @@
import { getTranslations } from 'next-intl/server';
import { PlaceholderScreen } from '@/components';
import WalletInstallments from './WalletInstallments';
export default async function WalletPage() {
const t = await getTranslations('nav');
const tShell = await getTranslations('shell');
return <PlaceholderScreen icon="wallet" title={t('wallet')} description={tShell('placeholder_body')} />;
/**
* /wallet — the customer Wallet tab. Today it hosts the f11 D5 installment-status section (provider-reported,
* self-contained so the f12 nurse-earnings Wallet content can land beside it later). The section is a client
* component (TanStack Query); this page is the thin route shell.
*/
export default function WalletPage() {
return <WalletInstallments />;
}
@@ -0,0 +1,162 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import { Box, MenuItem, Skeleton, Stack, TextField } from '@mui/material';
import { AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager, ConfirmDialog, SupportAlertCard } from '@/components/admin';
import { useAdminCapabilities } 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';
const STATUSES: readonly SupportAlertStatus[] = ['open', 'assigned', 'resolved'];
const TYPES: readonly SupportAlertType[] = [
'low_rating',
'evv_no_show',
'evv_location_mismatch',
'verification_expired',
'shared_sim',
'payment_anomaly',
'fraud_signal',
'nurse_clawback',
'emergency',
];
/**
* 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() {
const t = useTranslations('admin');
const caps = useAdminCapabilities();
const { enqueueSnackbar } = useSnackbar();
const [authState] = useAuth();
const meId = authState.currentUser?.id ?? 1;
const [status, setStatus] = useState<SupportAlertStatus | ''>('open');
const [type, setType] = useState<SupportAlertType | ''>('');
const [page, setPage] = useState(1);
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 onAssignSelf = (alert: SupportAlert) => {
assign.mutate(
{ alertId: alert.id, ownerUserId: meId },
{ onSuccess: () => enqueueSnackbar(t('alert_assigned'), { variant: 'success' }) },
);
};
const onResolveConfirm = (note?: string) => {
if (!resolving) return;
resolve.mutate(
{ alertId: resolving.id, note: note ?? '' },
{
onSuccess: () => {
enqueueSnackbar(t('alert_resolved'), { variant: 'success' });
setResolving(null);
},
},
);
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader
title={t('alert_title')}
subtitle={t('alert_subtitle')}
actions={
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
<TextField
select
size="small"
label={t('alert_col_status')}
value={status}
onChange={(e) => {
setStatus(e.target.value as SupportAlertStatus | '');
setPage(1);
}}
sx={{ minWidth: 140 }}
>
<MenuItem value="">{t('filter_all')}</MenuItem>
{STATUSES.map((s) => (
<MenuItem key={s} value={s}>
{t(`astatus_${s}`)}
</MenuItem>
))}
</TextField>
<TextField
select
size="small"
label={t('alert_col_type')}
value={type}
onChange={(e) => {
setType(e.target.value as SupportAlertType | '');
setPage(1);
}}
sx={{ minWidth: 180 }}
>
<MenuItem value="">{t('filter_all')}</MenuItem>
{TYPES.map((ty) => (
<MenuItem key={ty} value={ty}>
{t(`atype_${ty}`)}
</MenuItem>
))}
</TextField>
</Stack>
}
/>
{alerts.isLoading ? (
<Stack sx={{ gap: 2 }}>{[0, 1, 2].map((k) => <Skeleton key={k} variant="rounded" height={110} />)}</Stack>
) : alerts.isError ? (
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => alerts.refetch()} />
) : items.length === 0 ? (
<AdminEmptyState icon="alerts" title={t('alert_empty')} />
) : (
<Stack sx={{ gap: 2 }}>
{items.map((alert) => (
<SupportAlertCard
key={alert.id}
alert={alert}
canAct={caps.canManageAlerts}
onAssignSelf={onAssignSelf}
onResolve={setResolving}
/>
))}
</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 })}
/>
<ConfirmDialog
open={resolving != null}
title={t('alert_resolve_title')}
confirmLabel={t('alert_resolve')}
cancelLabel={t('cancel')}
onConfirm={onResolveConfirm}
onClose={() => setResolving(null)}
loading={resolve.isPending}
requireReason
reasonLabel={t('note_label')}
reasonPlaceholder={t('alert_resolve_note_ph')}
/>
</Box>
);
}
@@ -0,0 +1,112 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { Box, Skeleton, Stack, TextField } from '@mui/material';
import { AppButton } from '@/components';
import { AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager, AuditLogRow } from '@/components/admin';
import { AUDIT_PAGE_SIZE } from '@/services/admin/constants';
import type { AuditFilters } from '@/services/admin/types';
import { useAuditLogs } from '@/services/admin';
const EMPTY: AuditFilters = {};
/**
* 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
* filter draft is committed to the query only on Apply, so typing never refetches; the applied filters +
* page are the cache key, so switching filters/pages never refetches data already held.
*/
export default function AdminAuditPage() {
const t = useTranslations('admin');
const [draft, setDraft] = useState<AuditFilters>(EMPTY);
const [applied, setApplied] = useState<AuditFilters>(EMPTY);
const [page, setPage] = useState(1);
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 apply = () => {
setApplied(draft);
setPage(1);
};
const clear = () => {
setDraft(EMPTY);
setApplied(EMPTY);
setPage(1);
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader title={t('audit_title')} subtitle={t('audit_subtitle')} />
<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('audit_col_entity')}
placeholder={t('audit_entity_type_ph')}
value={draft.entityType ?? ''}
onChange={(e) => setDraft((d) => ({ ...d, entityType: e.target.value || undefined }))}
sx={{ minWidth: 200 }}
/>
<TextField
size="small"
label="ID"
placeholder={t('audit_entity_id_ph')}
value={draft.entityId ?? ''}
onChange={(e) => setDraft((d) => ({ ...d, entityId: e.target.value || undefined }))}
sx={{ minWidth: 120 }}
/>
<TextField
size="small"
type="date"
label={t('audit_from')}
value={draft.from ?? ''}
onChange={(e) => setDraft((d) => ({ ...d, from: e.target.value || undefined }))}
slotProps={{ inputLabel: { shrink: true } }}
/>
<TextField
size="small"
type="date"
label={t('audit_to')}
value={draft.to ?? ''}
onChange={(e) => setDraft((d) => ({ ...d, to: e.target.value || undefined }))}
slotProps={{ inputLabel: { shrink: true } }}
/>
<AppButton variant="contained" color="primary" onClick={apply} sx={{ m: 0 }}>
{t('apply')}
</AppButton>
<AppButton variant="text" color="inherit" onClick={clear} sx={{ m: 0 }}>
{t('clear')}
</AppButton>
</Stack>
{audit.isLoading ? (
<Stack sx={{ gap: 1 }}>{[0, 1, 2, 3].map((k) => <Skeleton key={k} variant="rounded" height={56} />)}</Stack>
) : audit.isError ? (
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => audit.refetch()} />
) : items.length === 0 ? (
<AdminEmptyState icon="audit" title={t('audit_empty')} />
) : (
<Stack sx={{ gap: 1 }}>
{items.map((entry) => (
<AuditLogRow key={entry.id} entry={entry} />
))}
</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 })}
/>
</Box>
);
}
@@ -0,0 +1,218 @@
'use client';
import { useMemo, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import {
Box,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Drawer,
FormControlLabel,
Skeleton,
Stack,
Switch,
TextField,
Typography,
} from '@mui/material';
import { AppButton, AppIcon } from '@/components';
import { AdminEmptyState, AdminErrorState, AdminPageHeader, ConfigRow } from '@/components/admin';
import { formatShamsiDateTime } from '@/utils';
import { useAdminCapabilities } 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';
const GROUP_ORDER = ['fees', 'deadlines', 'evv', 'bnpl', 'cancellation', 'other'] as const;
type GroupKey = (typeof GROUP_ORDER)[number];
/** Validate a candidate value against a config's `data_type` (+ the 01 rate rule). Returns an i18n key or null. */
function validate(config: PlatformConfig, value: string): string | null {
const trimmed = value.trim();
if (trimmed.length === 0) return 'cfg_empty_error';
if (config.dataType === 'int') {
if (!/^-?\d+$/.test(trimmed)) return 'cfg_int_error';
}
if (config.dataType === 'int' || config.dataType === 'decimal') {
const n = Number(trimmed);
if (Number.isNaN(n)) return 'cfg_int_error';
if (RATE_CONFIG_KEYS.includes(config.key) && (n < 0 || n > 1)) return 'cfg_range_error';
}
if (config.dataType === 'json') {
try {
JSON.parse(trimmed);
} catch {
return 'cfg_json_error';
}
}
return null;
}
/**
* Platform config editor (f15) — every `platform_configs` row grouped by concern, each with a typed input
* by `data_type` and boundary validation (a rate is 01). Saving is audited server-side and takes effect
* immediately without re-pricing already-computed rows — the save dialog says so. The change-history drawer
* proves the value in effect at any past moment. The client never re-parses config beyond rendering by
* `data_type` (phase §5).
*/
export default function AdminConfigPage() {
const t = useTranslations('admin');
const caps = useAdminCapabilities();
const configs = usePlatformConfigs(1);
const [editing, setEditing] = useState<PlatformConfig | null>(null);
const [historyKey, setHistoryKey] = useState<string | null>(null);
const grouped = useMemo(() => {
const items = configs.data?.items ?? [];
const byKey = new Map(Object.entries(CONFIG_GROUPS).flatMap(([g, keys]) => keys.map((k) => [k, g as GroupKey])));
const result: Record<GroupKey, PlatformConfig[]> = { fees: [], deadlines: [], evv: [], bnpl: [], cancellation: [], other: [] };
for (const c of items) result[byKey.get(c.key) ?? 'other'].push(c);
return result;
}, [configs.data]);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader title={t('cfg_title')} subtitle={t('cfg_subtitle')} />
{configs.isLoading ? (
<Stack sx={{ gap: 2 }}>{[0, 1, 2].map((k) => <Skeleton key={k} variant="rounded" height={96} />)}</Stack>
) : configs.isError ? (
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => configs.refetch()} />
) : (configs.data?.items.length ?? 0) === 0 ? (
<AdminEmptyState icon="config" title={t('cfg_title')} />
) : (
GROUP_ORDER.filter((g) => grouped[g].length > 0).map((g) => (
<Stack key={g} sx={{ gap: 1.5 }}>
<Typography variant="overline" sx={{ color: 'text.secondary', fontWeight: 700 }}>
{t(`cfg_group_${g}`)}
</Typography>
{grouped[g].map((config) => (
<ConfigRow
key={config.key}
config={config}
canEdit={caps.canConfig}
onEdit={setEditing}
onHistory={(c) => setHistoryKey(c.key)}
/>
))}
</Stack>
))
)}
{editing ? <ConfigEditDialog config={editing} onClose={() => setEditing(null)} /> : null}
<ConfigHistoryDrawer configKey={historyKey} onClose={() => setHistoryKey(null)} />
</Box>
);
}
/** The typed, validated, audited edit dialog for one config row. */
function ConfigEditDialog({ config, onClose }: { config: PlatformConfig; onClose: () => void }) {
const t = useTranslations('admin');
const { enqueueSnackbar } = useSnackbar();
const update = useUpdatePlatformConfig();
const [value, setValue] = useState(config.value);
const errorKey = validate(config, value);
const isBool = config.dataType === 'bool';
const onSave = () => {
if (errorKey) return;
update.mutate(
{ key: config.key, value: isBool ? value : value.trim() },
{
onSuccess: () => {
enqueueSnackbar(t('cfg_saved'), { variant: 'success' });
onClose();
},
},
);
};
return (
<Dialog open onClose={update.isPending ? undefined : onClose} fullWidth maxWidth="sm">
<DialogTitle sx={{ fontWeight: 800, fontFamily: 'monospace' }}>{config.key}</DialogTitle>
<DialogContent>
{config.description ? (
<DialogContentText sx={{ mb: 2 }}>{config.description}</DialogContentText>
) : null}
{isBool ? (
<FormControlLabel
control={<Switch checked={value === 'true'} onChange={(e) => setValue(e.target.checked ? 'true' : 'false')} />}
label={t(`dtype_bool`)}
/>
) : (
<TextField
fullWidth
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')}
error={!!errorKey}
helperText={errorKey ? t(errorKey) : undefined}
slotProps={{ input: { sx: config.dataType === 'json' ? { fontFamily: 'monospace' } : undefined } }}
/>
)}
<DialogContentText sx={{ mt: 2, color: 'var(--bal-warning)', fontWeight: 600 }}>
{t('cfg_save_confirm_body')}
</DialogContentText>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<AppButton variant="text" color="inherit" onClick={onClose} disabled={update.isPending} sx={{ m: 0 }}>
{t('cancel')}
</AppButton>
<AppButton variant="contained" color="primary" onClick={onSave} disabled={!!errorKey || update.isPending} sx={{ m: 0 }}>
{update.isPending ? t('saving') : t('save')}
</AppButton>
</DialogActions>
</Dialog>
);
}
/** The change-history drawer for one config key. */
function ConfigHistoryDrawer({ configKey, onClose }: { configKey: string | null; onClose: () => void }) {
const t = useTranslations('admin');
const locale = useLocale();
const history = useConfigChangeHistory(configKey, 1, 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';
return (
<Drawer anchor={anchor} open={configKey != null} onClose={onClose} 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={{ m: 0, minWidth: 0 }}>
<AppIcon icon="close" />
</AppButton>
</Stack>
{history.isLoading ? (
<Stack sx={{ gap: 1 }}>{[0, 1].map((k) => <Skeleton key={k} variant="rounded" height={64} />)}</Stack>
) : (history.data?.items.length ?? 0) === 0 ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('cfg_history_empty')}
</Typography>
) : (
<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>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{formatShamsiDateTime(change.occurredAt, locale)}
{change.actorUserId != null ? ` · #${change.actorUserId}` : ''}
</Typography>
</Box>
))}
</Stack>
)}
</Drawer>
);
}
@@ -0,0 +1,180 @@
'use client';
import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import {
Box,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControlLabel,
MenuItem,
Skeleton,
Stack,
Switch,
TextField,
} from '@mui/material';
import { AppButton } from '@/components';
import { AdminDataTable, AdminEmptyState, AdminErrorState, AdminPageHeader, type AdminTableColumn } from '@/components/admin';
import { formatShamsiDate } from '@/utils';
import { useAdminCapabilities } from '@/hooks';
import type { Holiday, HolidayInput, HolidayType } from '@/services/admin/types';
import { useHolidays, useUpsertHoliday } from '@/services/admin';
const HOLIDAY_TYPES: readonly HolidayType[] = ['official', 'religious', 'national'];
/**
* Iranian-holiday calendar manager (f15). Lists `iranian_holidays`, each with its Shamsi date, name, type,
* and an `is_bank_closed` flag — the flag that shifts payout scheduling (the copy surfaces that consequence).
* The client only maintains the calendar the **server** uses for the next-business-day shift; it never
* computes the shift itself (phase §5).
*/
export default function AdminHolidaysPage() {
const t = useTranslations('admin');
const locale = useLocale();
const caps = useAdminCapabilities();
const holidays = useHolidays({}, 1);
const [editing, setEditing] = useState<Holiday | 'new' | null>(null);
const columns: AdminTableColumn<Holiday>[] = [
{ key: 'date', header: t('hol_col_date'), render: (h) => formatShamsiDate(h.holidayDate, locale) },
{ key: 'name', header: t('hol_col_name'), render: (h) => h.nameFa },
{ key: 'type', header: t('hol_col_type'), render: (h) => <Chip size="small" variant="outlined" label={t(`htype_${h.type}`)} /> },
{
key: 'bank',
header: t('hol_col_bank'),
render: (h) => (
<Chip
size="small"
label={h.isBankClosed ? t('yes') : t('no')}
sx={{
bgcolor: h.isBankClosed ? 'var(--bal-warning)' : 'var(--bal-divider)',
color: h.isBankClosed ? 'var(--bal-warning-contrast)' : 'var(--bal-text-secondary)',
fontWeight: 700,
}}
/>
),
},
...(caps.canConfig
? [
{
key: 'actions',
header: '',
render: (h: Holiday) => (
<AppButton variant="text" color="primary" startIcon="edit" onClick={() => setEditing(h)} sx={{ m: 0 }}>
{t('cfg_edit')}
</AppButton>
),
} as AdminTableColumn<Holiday>,
]
: []),
];
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader
title={t('hol_title')}
subtitle={t('hol_subtitle')}
actions={
caps.canConfig ? (
<AppButton variant="contained" color="primary" startIcon="add" onClick={() => setEditing('new')} sx={{ m: 0 }}>
{t('hol_add')}
</AppButton>
) : undefined
}
/>
{holidays.isLoading ? (
<Skeleton variant="rounded" height={200} />
) : holidays.isError ? (
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => holidays.refetch()} />
) : (holidays.data?.items.length ?? 0) === 0 ? (
<AdminEmptyState icon="calendar" title={t('hol_empty')} />
) : (
<AdminDataTable columns={columns} rows={holidays.data?.items ?? []} getRowKey={(h) => h.id} ariaLabel={t('hol_title')} />
)}
{editing ? (
<HolidayDialog holiday={editing === 'new' ? null : editing} onClose={() => setEditing(null)} />
) : null}
</Box>
);
}
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,
nameFa: holiday?.nameFa ?? '',
type: holiday?.type ?? 'official',
isBankClosed: holiday?.isBankClosed ?? true,
});
const valid = form.holidayDate.length > 0 && form.nameFa.trim().length > 0;
const onSave = () => {
if (!valid) return;
upsert.mutate(
{ ...form, nameFa: form.nameFa.trim() },
{
onSuccess: () => {
enqueueSnackbar(t('hol_saved'), { variant: 'success' });
onClose();
},
},
);
};
return (
<Dialog open onClose={upsert.isPending ? undefined : onClose} fullWidth maxWidth="xs">
<DialogTitle sx={{ fontWeight: 800 }}>{holiday ? t('hol_edit') : t('hol_add')}</DialogTitle>
<DialogContent>
<Stack sx={{ gap: 2, mt: 1 }}>
<TextField
type="date"
label={t('hol_col_date')}
value={form.holidayDate}
onChange={(e) => setForm((f) => ({ ...f, holidayDate: e.target.value }))}
disabled={!!holiday}
slotProps={{ inputLabel: { shrink: true } }}
/>
<TextField
label={t('hol_name_fa')}
value={form.nameFa}
onChange={(e) => setForm((f) => ({ ...f, nameFa: e.target.value }))}
/>
<TextField
select
label={t('hol_col_type')}
value={form.type}
onChange={(e) => setForm((f) => ({ ...f, type: e.target.value as HolidayType }))}
>
{HOLIDAY_TYPES.map((ty) => (
<MenuItem key={ty} value={ty}>
{t(`htype_${ty}`)}
</MenuItem>
))}
</TextField>
<FormControlLabel
control={<Switch checked={form.isBankClosed} onChange={(e) => setForm((f) => ({ ...f, isBankClosed: e.target.checked }))} />}
label={t('hol_bank_hint')}
/>
</Stack>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<AppButton variant="text" color="inherit" onClick={onClose} disabled={upsert.isPending} sx={{ m: 0 }}>
{t('cancel')}
</AppButton>
<AppButton variant="contained" color="primary" onClick={onSave} disabled={!valid || upsert.isPending} sx={{ m: 0 }}>
{upsert.isPending ? t('saving') : t('save')}
</AppButton>
</DialogActions>
</Dialog>
);
}
@@ -1,8 +1,81 @@
import { getTranslations } from 'next-intl/server';
import { PlaceholderScreen } from '@/components';
'use client';
import { useLocale, useTranslations } from 'next-intl';
import { Box, Paper, Typography } from '@mui/material';
import { AppIcon, AppLink } from '@/components';
import { AdminPageHeader } from '@/components/admin';
import { useAdminCapabilities } from '@/hooks';
import { ROUTES } from '@/constants';
export default async function AdminOverviewPage() {
const t = await getTranslations('nav');
const tShell = await getTranslations('shell');
return <PlaceholderScreen icon="admin" title={t('overview')} description={tShell('placeholder_body')} />;
/**
* Admin overview landing (f15) — the backoffice home. Renders one **console card** per worklist the current
* principal may act on, derived from `useAdminCapabilities()` (a UI hint; the server still enforces every
* command's role scope). A `support` admin sees verification/tickets/alerts; a `finance` admin sees
* payouts/config; only a `super_admin` sees roles. Each card deep-links into its console.
*/
export default function AdminOverviewPage() {
const t = useTranslations('admin');
const tNav = useTranslations('nav');
const locale = useLocale();
const caps = useAdminCapabilities();
// `key` doubles as the `nav` i18n key for the card label.
const consoles: { key: string; route: string; icon: string; enabled: boolean }[] = [
{ key: 'verification', route: ROUTES.ADMIN_VERIFICATION, icon: 'verification', enabled: caps.canVerify },
{ key: 'tickets', route: ROUTES.ADMIN_TICKETS, icon: 'support', enabled: caps.canManageTickets },
{ key: 'payouts', route: ROUTES.ADMIN_PAYOUTS, icon: 'earnings', enabled: caps.canPayout },
{ key: 'reviews', route: ROUTES.ADMIN_REVIEWS, icon: 'moderation', enabled: caps.canModerate },
{ key: 'config', route: ROUTES.ADMIN_CONFIG, icon: 'config', enabled: caps.canConfig },
{ key: 'holidays', route: ROUTES.ADMIN_HOLIDAYS, icon: 'calendar', enabled: caps.canConfig },
{ key: 'alerts', route: ROUTES.ADMIN_ALERTS, icon: 'alerts', enabled: caps.canManageAlerts },
{ key: 'audit', route: ROUTES.ADMIN_AUDIT, icon: 'audit', enabled: caps.canViewAudit },
{ key: 'partners', route: ROUTES.ADMIN_PARTNERS, icon: 'partners', enabled: caps.canManagePartners },
{ key: 'roles', route: ROUTES.ADMIN_ROLES, icon: 'roles', enabled: caps.canManageRoles },
].filter((c) => c.enabled);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader title={t('overview_title')} subtitle={t('overview_subtitle')} />
<Box
sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', md: '1fr 1fr 1fr' },
gap: 2,
}}
>
{consoles.map((c) => (
<AppLink
key={c.key}
to={`/${locale}${c.route}`}
color="inherit"
underline="none"
sx={{ display: 'block', height: '100%' }}
>
<Paper
elevation={0}
sx={{
p: 3,
height: '100%',
border: '1px solid',
borderColor: 'divider',
borderRadius: 2,
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
gap: 1.5,
cursor: 'pointer',
transition: 'border-color 150ms ease, box-shadow 150ms ease',
'&:hover': { borderColor: 'primary.main', boxShadow: 3 },
}}
>
<AppIcon icon={c.icon} size={32} color="var(--bal-primary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{tNav(c.key)}
</Typography>
</Paper>
</AppLink>
))}
</Box>
</Box>
);
}
@@ -0,0 +1,278 @@
'use client';
import { ReactNode, useState } from 'react';
import { useParams, useRouter } 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 { ROUTES } from '@/constants';
import {
usePartnerCenter,
useCenterSponsoredNurses,
useVerifyPartnerCenter,
useSetPartnerCenterActive,
useAssignNurseToPartnerCenter,
} from '@/services/partnerCenter';
import { CENTER_STATE_KIND, PartnerCenterFormDialog } from '../page';
/**
* Partner-center admin detail (f15) — the licensing/settlement record for one center, its lifecycle actions,
* and its sponsored-nurse roster. Admins with `canManagePartners` may verify & activate a center (records
* licensing approval), suspend/reactivate it, edit it, and add/remove sponsored nurses. The settlement IBAN
* is only ever shown masked (last-4); it is never rendered in plaintext (write-then-masked). The server
* enforces every command's scope — the capability flag only hides controls.
*/
export default function AdminPartnerCenterDetailPage() {
const t = useTranslations('admin');
const locale = useLocale();
const router = useRouter();
const caps = useAdminCapabilities();
const { enqueueSnackbar } = useSnackbar();
const params = useParams<{ id: string }>();
const parsed = Number(params?.id);
const centerId = Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
const center = usePartnerCenter(centerId || null);
const roster = useCenterSponsoredNurses(centerId || null);
const verify = useVerifyPartnerCenter(centerId);
const setActive = useSetPartnerCenterActive(centerId);
const assignNurse = useAssignNurseToPartnerCenter(centerId);
const [confirmVerify, setConfirmVerify] = useState(false);
const [editing, setEditing] = useState(false);
const [assignId, setAssignId] = useState('');
const data = center.data;
const onVerifyConfirm = () => {
verify.mutate(undefined, {
onSuccess: () => {
enqueueSnackbar(t('partner_verified_toast'), { variant: 'success' });
setConfirmVerify(false);
},
});
};
const onAssign = () => {
const nurseProfileId = Number(assignId);
if (!Number.isFinite(nurseProfileId) || nurseProfileId <= 0) return;
assignNurse.mutate(
{ nurseProfileId, unlink: false },
{
onSuccess: () => {
enqueueSnackbar(t('partner_nurse_assigned'), { variant: 'success' });
setAssignId('');
},
},
);
};
const onRemove = (nurseProfileId: number) => {
assignNurse.mutate(
{ nurseProfileId, unlink: true },
{ onSuccess: () => enqueueSnackbar(t('partner_nurse_assigned'), { variant: 'success' }) },
);
};
const back = (
<AppButton
variant="text"
color="primary"
startIcon="partners"
onClick={() => router.push(`/${locale}${ROUTES.ADMIN_PARTNERS}`)}
sx={{ m: 0, alignSelf: 'flex-start' }}
>
{t('back')}
</AppButton>
);
if (center.isLoading) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{back}
<Skeleton variant="rounded" height={80} />
<Skeleton variant="rounded" height={220} />
<Skeleton variant="rounded" height={160} />
</Box>
);
}
if (center.isError) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{back}
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => center.refetch()} />
</Box>
);
}
if (!data) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{back}
<AdminEmptyState icon="partners" title={t('partner_empty')} />
</Box>
);
}
const commissionPercent = new Intl.NumberFormat(locale, { style: 'percent', maximumFractionDigits: 2 }).format(
data.commissionRate,
);
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>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack divider={<Divider flexItem />} sx={{ gap: 1.25 }}>
<DetailRow label={t('partner_legal_type')}>{data.legalEntityType || '—'}</DetailRow>
<DetailRow label={t('partner_permit')}>{data.mohEstablishmentPermitNo || '—'}</DetailRow>
<DetailRow label={t('partner_tech_director')}>{data.technicalDirectorLicenseNo ?? '—'}</DetailRow>
<DetailRow label={t('partner_enamad')}>{data.enamadCode ?? '—'}</DetailRow>
<DetailRow label={t('partner_commission')}>{commissionPercent}</DetailRow>
<DetailRow label={t('partner_is_mor')}>{t(data.isMerchantOfRecord ? 'yes' : 'no')}</DetailRow>
<DetailRow label={t('partner_iban')}>
<Box component="span" dir="ltr">
{data.settlementIbanMasked ?? '—'}
</Box>
</DetailRow>
</Stack>
</Paper>
{caps.canManagePartners ? (
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
{data.verifiedAt == null ? (
<AppButton
variant="contained"
color="primary"
startIcon="verified"
onClick={() => setConfirmVerify(true)}
disabled={verify.isPending}
sx={{ m: 0 }}
>
{t('partner_verify')}
</AppButton>
) : null}
<AppButton
variant="outlined"
color="primary"
onClick={() => setActive.mutate(!data.isActive)}
disabled={setActive.isPending}
sx={{ m: 0 }}
>
{t(data.isActive ? 'partner_suspend' : 'partner_activate')}
</AppButton>
<AppButton variant="outlined" color="inherit" startIcon="edit" onClick={() => setEditing(true)} sx={{ m: 0 }}>
{t('partner_edit')}
</AppButton>
</Stack>
) : null}
<Stack sx={{ gap: 1.5 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('partner_roster_title')}
</Typography>
{roster.isLoading ? (
<Skeleton variant="rounded" height={120} />
) : (
<Paper elevation={0} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, overflow: 'hidden' }}>
<Stack divider={<Divider />}>
{(roster.data ?? []).map((nurse) => (
<Stack
key={nurse.nurseProfileId}
direction="row"
sx={{ p: 1.75, gap: 1.5, alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap' }}
>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{nurse.name}
</Typography>
<TrustBadge state={nurse.isVerified ? 'verified' : 'unverified'} />
</Stack>
{caps.canManagePartners ? (
<AppButton
variant="text"
color="error"
startIcon="delete"
onClick={() => onRemove(nurse.nurseProfileId)}
disabled={assignNurse.isPending}
sx={{ m: 0 }}
>
{t('partner_unlink_nurse')}
</AppButton>
) : null}
</Stack>
))}
</Stack>
</Paper>
)}
{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 }}
/>
<AppButton
variant="contained"
color="primary"
startIcon="assign"
onClick={onAssign}
disabled={assignNurse.isPending || Number(assignId) <= 0}
sx={{ m: 0, mt: 0.25 }}
>
{t('partner_assign_nurse')}
</AppButton>
</Stack>
) : null}
</Stack>
<ConfirmDialog
open={confirmVerify}
title={t('partner_verify')}
body={t('partner_verify_confirm')}
confirmLabel={t('confirm')}
cancelLabel={t('cancel')}
onConfirm={onVerifyConfirm}
onClose={() => setConfirmVerify(false)}
loading={verify.isPending}
/>
{editing ? <PartnerCenterFormDialog center={data} onClose={() => setEditing(false)} /> : null}
</Box>
);
}
/** One label/value line in the license/settlement block. */
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
return (
<Stack direction="row" sx={{ gap: 1.5, justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{label}
</Typography>
<Typography variant="body2" component="div" sx={{ fontWeight: 600, textAlign: 'end' }}>
{children}
</Typography>
</Stack>
);
}
@@ -0,0 +1,257 @@
'use client';
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,
FormControlLabel,
Skeleton,
Stack,
Switch,
TextField,
Typography,
} from '@mui/material';
import { AppButton, StatusChip } from '@/components';
import type { StatusKind } from '@/components';
import {
AdminDataTable,
AdminEmptyState,
AdminErrorState,
AdminPageHeader,
AdminPager,
type AdminTableColumn,
} from '@/components/admin';
import { useAdminCapabilities } from '@/hooks';
import { adminPartnerCenterPath } from '@/constants';
import { PARTNER_PAGE_SIZE } from '@/services/partnerCenter/constants';
import type { CenterOnboardingState, PartnerCenter, PartnerCenterInput } from '@/services/partnerCenter/types';
import { usePartnerCenters, useCreatePartnerCenter, useUpdatePartnerCenter } from '@/services/partnerCenter';
/** State → semantic chip color. verified = green, pending = amber, suspended = red, draft = neutral. */
export const CENTER_STATE_KIND: Record<CenterOnboardingState, StatusKind> = {
verified: 'verified',
pending_verification: 'pending',
suspended: 'rejected',
draft: 'neutral',
};
/**
* Partner-center admin list (f15) — the licensed sponsoring centers (پروانه تأسیس + مسئول فنی + نماد
* اعتماد الکترونیکی) that may be the merchant-of-record. Each row shows whether it issues invoices, its
* sponsored-nurse count, and its onboarding state; a row opens the center detail. Admins with
* `canManagePartners` may create a new center (inactive until verified). The full IBAN is write-then-masked —
* it is only ever entered here, never displayed (the list carries no IBAN at all).
*/
export default function AdminPartnersPage() {
const t = useTranslations('admin');
const locale = useLocale();
const router = useRouter();
const caps = useAdminCapabilities();
const [page, setPage] = useState(1);
const [creating, setCreating] = useState(false);
const centers = usePartnerCenters({}, page);
const items = centers.data?.items ?? [];
const pageCount = Math.max(1, Math.ceil((centers.data?.total ?? 0) / PARTNER_PAGE_SIZE));
const columns: AdminTableColumn<PartnerCenter>[] = [
{ key: 'name', header: t('partner_col_name'), render: (c) => c.name },
{ key: 'mor', header: t('partner_col_mor'), render: (c) => t(c.isMerchantOfRecord ? 'yes' : 'no') },
{ key: 'nurses', header: t('partner_col_nurses'), render: (c) => c.sponsoredNurseCount },
{
key: 'state',
header: t('partner_col_state'),
render: (c) => <StatusChip status={CENTER_STATE_KIND[c.onboardingState]} label={t(`center_state_${c.onboardingState}`)} />,
},
];
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader
title={t('partner_title')}
subtitle={t('partner_subtitle')}
actions={
caps.canManagePartners ? (
<AppButton variant="contained" color="primary" startIcon="add" onClick={() => setCreating(true)} sx={{ m: 0 }}>
{t('partner_create')}
</AppButton>
) : undefined
}
/>
{centers.isLoading ? (
<Skeleton variant="rounded" height={220} />
) : centers.isError ? (
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => centers.refetch()} />
) : items.length === 0 ? (
<AdminEmptyState icon="partners" title={t('partner_empty')} />
) : (
<AdminDataTable
columns={columns}
rows={items}
getRowKey={(c) => c.id}
ariaLabel={t('partner_title')}
onRowClick={(c) => router.push(`/${locale}${adminPartnerCenterPath(c.id)}`)}
/>
)}
<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 })}
/>
{creating ? <PartnerCenterFormDialog center={null} onClose={() => setCreating(false)} /> : null}
</Box>
);
}
/** The editable slice of `PartnerCenterInput`, kept as strings for controlled text/number inputs. */
interface CenterFormState {
name: string;
legalEntityType: string;
mohEstablishmentPermitNo: string;
technicalDirectorLicenseNo: string;
enamadCode: string;
settlementIban: string;
isMerchantOfRecord: boolean;
commissionRate: string;
adminUserId: string;
}
function initialForm(center: PartnerCenter | null): CenterFormState {
return {
name: center?.name ?? '',
legalEntityType: center?.legalEntityType ?? '',
mohEstablishmentPermitNo: center?.mohEstablishmentPermitNo ?? '',
technicalDirectorLicenseNo: center?.technicalDirectorLicenseNo ?? '',
enamadCode: center?.enamadCode ?? '',
// Write-then-masked: always blank on open. On edit, a blank IBAN keeps the existing masked value.
settlementIban: '',
isMerchantOfRecord: center?.isMerchantOfRecord ?? false,
commissionRate: center != null ? String(center.commissionRate) : '',
adminUserId: center?.adminUserId != null ? String(center.adminUserId) : '',
};
}
/**
* The create/edit dialog for a partner center — shared by the list (create, `center=null`) and the detail
* (edit, `center` prefilled). `settlementIban` is write-then-masked: the field is always blank on open and a
* blank submit on edit keeps the stored masked value. Validates name + permit non-empty, `commissionRate ∈
* [0, 1)`, and (create only) an IBAN when the center is merchant-of-record.
*/
export function PartnerCenterFormDialog({ center, onClose }: { center: PartnerCenter | null; onClose: () => void }) {
const t = useTranslations('admin');
const { enqueueSnackbar } = useSnackbar();
const isEdit = center != null;
const create = useCreatePartnerCenter();
const update = useUpdatePartnerCenter(center?.id ?? 0);
const mutation = isEdit ? update : create;
const [form, setForm] = useState<CenterFormState>(() => initialForm(center));
const set = <K extends keyof CenterFormState>(key: K, value: CenterFormState[K]) =>
setForm((f) => ({ ...f, [key]: value }));
const commission = Number(form.commissionRate);
const commissionValid = form.commissionRate.trim() !== '' && Number.isFinite(commission) && commission >= 0 && commission < 1;
// On edit a blank IBAN is allowed (it keeps the stored value); on create an MoR center must supply one.
const ibanValid = !form.isMerchantOfRecord || isEdit || form.settlementIban.trim() !== '';
const valid =
form.name.trim() !== '' && form.mohEstablishmentPermitNo.trim() !== '' && commissionValid && ibanValid;
const onSave = () => {
if (!valid) return;
const input: PartnerCenterInput = {
name: form.name.trim(),
legalEntityType: form.legalEntityType.trim(),
mohEstablishmentPermitNo: form.mohEstablishmentPermitNo.trim(),
technicalDirectorLicenseNo: form.technicalDirectorLicenseNo.trim() || null,
enamadCode: form.enamadCode.trim() || null,
settlementIban: form.settlementIban.trim() || null,
isMerchantOfRecord: form.isMerchantOfRecord,
commissionRate: commission,
adminUserId: form.adminUserId.trim() === '' ? null : Number(form.adminUserId),
};
mutation.mutate(input, {
onSuccess: () => {
enqueueSnackbar(t('partner_saved'), { variant: 'success' });
onClose();
},
});
};
return (
<Dialog open onClose={mutation.isPending ? undefined : onClose} fullWidth maxWidth="sm">
<DialogTitle sx={{ fontWeight: 800 }}>{isEdit ? t('partner_edit') : t('partner_create')}</DialogTitle>
<DialogContent>
<Stack sx={{ gap: 2, mt: 1 }}>
<TextField label={t('partner_name')} value={form.name} onChange={(e) => set('name', e.target.value)} />
<TextField
label={t('partner_legal_type')}
value={form.legalEntityType}
onChange={(e) => set('legalEntityType', e.target.value)}
/>
<TextField
label={t('partner_permit')}
value={form.mohEstablishmentPermitNo}
onChange={(e) => set('mohEstablishmentPermitNo', e.target.value)}
/>
<TextField
label={t('partner_tech_director_license')}
value={form.technicalDirectorLicenseNo}
onChange={(e) => set('technicalDirectorLicenseNo', e.target.value)}
/>
<TextField label={t('partner_enamad')} value={form.enamadCode} onChange={(e) => set('enamadCode', e.target.value)} />
<TextField
label={t('partner_iban')}
value={form.settlementIban}
onChange={(e) => set('settlementIban', e.target.value)}
helperText={t('partner_iban_write_hint')}
placeholder={center?.settlementIbanMasked ?? undefined}
slotProps={{ htmlInput: { dir: 'ltr' } }}
/>
<Box>
<FormControlLabel
control={<Switch checked={form.isMerchantOfRecord} onChange={(e) => set('isMerchantOfRecord', e.target.checked)} />}
label={t('partner_is_mor')}
/>
<Typography variant="caption" sx={{ display: 'block', color: 'text.secondary' }}>
{t('partner_is_mor_hint')}
</Typography>
</Box>
<TextField
type="number"
label={t('partner_commission')}
value={form.commissionRate}
onChange={(e) => set('commissionRate', e.target.value)}
slotProps={{ htmlInput: { min: 0, max: 0.999, step: 0.01 } }}
/>
<TextField
type="number"
label={t('partner_admin_user')}
value={form.adminUserId}
onChange={(e) => set('adminUserId', e.target.value)}
slotProps={{ htmlInput: { min: 1, step: 1 } }}
/>
</Stack>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<AppButton variant="text" color="inherit" onClick={onClose} disabled={mutation.isPending} sx={{ m: 0 }}>
{t('cancel')}
</AppButton>
<AppButton variant="contained" color="primary" onClick={onSave} disabled={!valid || mutation.isPending} sx={{ m: 0 }}>
{mutation.isPending ? t('saving') : t('save')}
</AppButton>
</DialogActions>
</Dialog>
);
}
@@ -0,0 +1,295 @@
'use client';
import { FunctionComponent, ReactNode, useState } from 'react';
import { useParams, useRouter } 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 type { StatusKind } from '@/components';
import { AdminEmptyState, AdminErrorState, AdminPager, ConfirmDialog } from '@/components/admin';
import { useAdminCapabilities } 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';
const BATCH_STATUS_KIND: Record<PayoutBatchStatus, StatusKind> = {
draft: 'neutral',
processing: 'info',
partially_failed: 'pending',
completed: 'verified',
failed: 'rejected',
};
const PAYOUT_STATUS_KIND: Record<PayoutStatus, StatusKind> = {
pending: 'pending',
submitted: 'info',
paid: 'verified',
failed: 'rejected',
};
/**
* Admin payout-batch detail (f15) — one batch expanded: its window + holiday-shifted processing date, and its
* paginated per-payout rows (money decomposition, masked IBAN, transfer reference, status). A failed payout
* can be retried (idempotency-keyed) and a reconciled bank transfer reference recorded — both gated on
* `canPayout`. Money is display-only Toman; the client never recomputes amounts, eligibility, or dates.
*/
export default function AdminPayoutBatchDetailPage() {
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 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={{ m: 0, alignSelf: 'flex-start' }}
>
{t('back')}
</AppButton>
<Typography variant="h5" component="h1" sx={{ fontWeight: 800 }}>
{t('payout_batch_title', { id: batchId })}
</Typography>
</Stack>
{detail.isLoading ? (
<Stack sx={{ gap: 2 }}>
<Skeleton variant="rounded" height={120} />
<Skeleton variant="rounded" height={96} />
<Skeleton variant="rounded" height={96} />
</Stack>
) : detail.isError ? (
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => detail.refetch()} />
) : !data ? (
<AdminEmptyState icon="wallet" title={t('payout_empty')} />
) : (
<>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1.5 }}>
<StatusChip
status={BATCH_STATUS_KIND[data.batch.status]}
label={t(`batch_status_${data.batch.status}`)}
sx={{ alignSelf: 'flex-start' }}
/>
<MetaLine label={t('payout_col_period')}>
{formatShamsiDate(data.batch.periodStart, locale)} {formatShamsiDate(data.batch.periodEnd, locale)}
</MetaLine>
<MetaLine label={t('payout_col_processing')}>
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center', flexWrap: 'wrap' }}>
<span>{formatShamsiDate(data.batch.processingDate, locale)}</span>
{data.batch.holidayShifted ? (
<Chip
size="small"
label={t('payout_holiday_shift')}
sx={{ bgcolor: 'var(--bal-warning)', color: 'var(--bal-warning-contrast)', fontWeight: 600 }}
/>
) : null}
</Stack>
</MetaLine>
</Stack>
</Paper>
<Stack sx={{ gap: 1.5 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('payout_rows_title')}
</Typography>
{data.payouts.length === 0 ? (
<AdminEmptyState icon="wallet" title={t('payout_empty')} />
) : (
data.payouts.map((row) => (
<PayoutRowCard key={row.id} row={row} batchId={batchId} canPayout={caps.canPayout} />
))
)}
</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 })}
/>
</>
)}
</Box>
);
}
/**
* One `nurse_payouts` row — the money decomposition (`gross clawback = net`), masked IBAN + transfer
* reference, status, and (for a failed payout) the reason + an idempotency-keyed retry. Recording a
* reconciled bank transfer reference is an inline per-row action. Both writes are gated on `canPayout`.
*/
const PayoutRowCard: FunctionComponent<{ row: AdminPayoutRow; batchId: number; canPayout: boolean }> = ({
row,
batchId,
canPayout,
}) => {
const t = useTranslations('admin');
const locale = useLocale();
const { enqueueSnackbar } = useSnackbar();
const retry = useRetryPayout();
const record = useRecordTransferReference();
const [retryOpen, setRetryOpen] = useState(false);
const [reference, setReference] = useState('');
const onRetryConfirm = () => {
retry.mutate(
{ payoutId: row.id, idempotencyKey: crypto?.randomUUID?.() ?? String(Date.now()), batchId },
{
onSuccess: () => {
setRetryOpen(false);
enqueueSnackbar(t('saved'), { variant: 'success' });
},
},
);
};
const onRecord = () => {
record.mutate(
{ payoutId: row.id, reference: reference.trim(), batchId },
{
onSuccess: () => {
enqueueSnackbar(t('payout_ref_saved'), { variant: 'success' });
setReference('');
},
},
);
};
return (
<Paper elevation={0} sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1.25 }}>
<Stack
direction="row"
sx={{ justifyContent: 'space-between', gap: 1, alignItems: 'center', flexWrap: 'wrap' }}
>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>
{t('payout_row_nurse')}: {row.nurseName ?? `#${row.nurseId}`}
</Typography>
<StatusChip status={PAYOUT_STATUS_KIND[row.status]} label={t(`pstatus_${row.status}`)} />
</Stack>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('payout_decomp', {
gross: formatIrrToToman(row.grossEarningsIrr, locale),
clawback: formatIrrToToman(row.clawbackAppliedIrr, locale),
net: formatIrrToToman(row.netAmountIrr, locale),
})}
</Typography>
<Stack direction="row" sx={{ gap: 3, flexWrap: 'wrap' }}>
<Field label={t('masked_iban_label')}>
<Box component="span" dir="ltr">
{row.maskedIban}
</Box>
</Field>
<Field label={t('payout_row_ref')}>
<Box component="span" dir="ltr">
{row.transferReference ?? '—'}
</Box>
</Field>
</Stack>
{row.status === 'failed' ? (
<Box
sx={{
p: 1.25,
borderRadius: 2,
border: '1px solid',
borderColor: 'divider',
borderInlineStart: '3px solid',
borderInlineStartColor: 'var(--bal-error)',
}}
>
<Typography variant="caption" sx={{ color: 'text.secondary' }} dir="ltr">
{t('payout_failure_reason', { reason: row.failureReason ?? '—' })}
</Typography>
{canPayout ? (
<Box sx={{ mt: 1 }}>
<AppButton
variant="outlined"
color="error"
onClick={() => setRetryOpen(true)}
disabled={retry.isPending}
sx={{ m: 0 }}
>
{t('payout_retry')}
</AppButton>
</Box>
) : null}
</Box>
) : null}
{canPayout ? (
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap', mt: 0.5 }}>
<TextField
size="small"
label={t('payout_record_ref')}
placeholder={t('payout_record_ref_ph')}
value={reference}
onChange={(e) => setReference(e.target.value)}
slotProps={{ htmlInput: { dir: 'ltr' } }}
sx={{ minWidth: 220 }}
/>
<AppButton
variant="contained"
color="primary"
onClick={onRecord}
disabled={reference.trim().length === 0 || record.isPending}
sx={{ m: 0 }}
>
{record.isPending ? t('saving') : t('save')}
</AppButton>
</Stack>
) : null}
</Stack>
<ConfirmDialog
open={retryOpen}
title={t('payout_retry')}
body={t('payout_retry_confirm')}
confirmLabel={t('confirm')}
cancelLabel={t('cancel')}
onConfirm={onRetryConfirm}
onClose={() => setRetryOpen(false)}
loading={retry.isPending}
confirmColor="error"
/>
</Paper>
);
};
const MetaLine: FunctionComponent<{ label: string; children: ReactNode }> = ({ label, children }) => (
<Stack direction="row" sx={{ gap: 1, justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{label}
</Typography>
<Box sx={{ fontWeight: 600, typography: 'body2' }}>{children}</Box>
</Stack>
);
const Field: FunctionComponent<{ label: string; children: ReactNode }> = ({ label, children }) => (
<Stack sx={{ gap: 0.25 }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{label}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{children}
</Typography>
</Stack>
);
@@ -0,0 +1,377 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import {
Box,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Divider,
MenuItem,
Skeleton,
Stack,
TextField,
Typography,
} from '@mui/material';
import { AppButton, StatusChip } from '@/components';
import type { StatusKind } from '@/components';
import {
AdminDataTable,
AdminEmptyState,
AdminErrorState,
AdminPageHeader,
AdminPager,
ConfirmDialog,
} from '@/components/admin';
import type { AdminTableColumn } from '@/components/admin';
import { useAdminCapabilities } from '@/hooks';
import { adminPayoutBatchPath } from '@/constants';
import { formatIrrToToman, 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';
/** Batch lifecycle → semantic chip color (server truth; the client only renders it). */
const BATCH_STATUS_KIND: Record<PayoutBatchStatus, StatusKind> = {
draft: 'neutral',
processing: 'info',
partially_failed: 'pending',
completed: 'verified',
failed: 'rejected',
};
const BATCH_STATUSES: readonly PayoutBatchStatus[] = [
'draft',
'processing',
'partially_failed',
'completed',
'failed',
];
/** UTC ISO date (`YYYY-MM-DD`) — the wire shape for the batch window. */
const isoDate = (d: Date): string => d.toISOString().slice(0, 10);
/**
* Admin payout-batch dashboard (f15) — the reconciliation list of weekly `nurse_payout_batches` and the
* entry point to previewing + running the next batch. Money is IRR digit-strings rendered as display-only
* Toman; the server owns eligibility and the holiday-shifted processing date — the client never computes
* them. Running a batch moves money, so it is gated (`canPayout`), idempotency-keyed, and confirmed.
*/
export default function AdminPayoutsPage() {
const t = useTranslations('admin');
const tCommon = useTranslations('common');
const locale = useLocale();
const router = useRouter();
const caps = useAdminCapabilities();
const [status, setStatus] = useState<PayoutBatchStatus | ''>('');
const [page, setPage] = useState(1);
const [previewOpen, setPreviewOpen] = useState(false);
const filters = { status: status || undefined };
const batches = usePayoutBatches(filters, page);
const items = batches.data?.items ?? [];
const pageCount = Math.max(1, Math.ceil((batches.data?.total ?? 0) / PAYOUTS_PAGE_SIZE));
const columns: AdminTableColumn<PayoutBatchSummary>[] = [
{
key: 'period',
header: t('payout_col_period'),
render: (b) => `${formatShamsiDate(b.periodStart, locale)} ${formatShamsiDate(b.periodEnd, locale)}`,
},
{
key: 'count',
header: t('payout_col_count'),
render: (b) => b.payoutCount,
},
{
key: 'total',
header: t('payout_col_total'),
render: (b) => `${formatIrrToToman(b.totalAmount, locale)} ${tCommon('currency_toman')}`,
},
{
key: 'status',
header: t('payout_col_status'),
render: (b) => <StatusChip status={BATCH_STATUS_KIND[b.status]} label={t(`batch_status_${b.status}`)} />,
},
{
key: 'processing',
header: t('payout_col_processing'),
render: (b) => (
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center', flexWrap: 'wrap' }}>
<span>{formatShamsiDate(b.processingDate, locale)}</span>
{b.holidayShifted ? (
<Chip
size="small"
label={t('payout_holiday_shift')}
sx={{ bgcolor: 'var(--bal-warning)', color: 'var(--bal-warning-contrast)', fontWeight: 600 }}
/>
) : null}
</Stack>
),
},
];
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader
title={t('payout_title')}
subtitle={t('payout_subtitle')}
actions={
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap', alignItems: 'center' }}>
<TextField
select
size="small"
label={t('payout_col_status')}
value={status}
onChange={(e) => {
setStatus(e.target.value as PayoutBatchStatus | '');
setPage(1);
}}
sx={{ minWidth: 160 }}
>
<MenuItem value="">{t('filter_all')}</MenuItem>
{BATCH_STATUSES.map((s) => (
<MenuItem key={s} value={s}>
{t(`batch_status_${s}`)}
</MenuItem>
))}
</TextField>
{caps.canPayout ? (
<AppButton variant="contained" color="primary" onClick={() => setPreviewOpen(true)} sx={{ m: 0 }}>
{t('payout_preview')}
</AppButton>
) : null}
</Stack>
}
/>
{batches.isLoading ? (
<Stack sx={{ gap: 2 }}>{[0, 1, 2].map((k) => <Skeleton key={k} variant="rounded" height={64} />)}</Stack>
) : batches.isError ? (
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => batches.refetch()} />
) : items.length === 0 ? (
<AdminEmptyState icon="wallet" title={t('payout_empty')} />
) : (
<AdminDataTable
columns={columns}
rows={items}
getRowKey={(b) => b.id}
onRowClick={(b) => router.push(`/${locale}${adminPayoutBatchPath(b.id)}`)}
ariaLabel={t('payout_title')}
/>
)}
<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 })}
/>
{previewOpen ? <PreviewBatchDialog canPayout={caps.canPayout} onClose={() => setPreviewOpen(false)} /> : null}
</Box>
);
}
/**
* The eligibility dry-run + run-batch dialog. Preview is a mutation (runs only when the admin asks), and its
* eligible/skipped breakdown + the server's holiday-shifted processing date are read straight from the
* mutation's `data`. Running is idempotency-keyed and confirmed; on success it deep-links to the new batch.
*/
function PreviewBatchDialog({ canPayout, onClose }: { canPayout: boolean; onClose: () => void }) {
const t = useTranslations('admin');
const tCommon = useTranslations('common');
const locale = useLocale();
const router = useRouter();
const { enqueueSnackbar } = useSnackbar();
// Default the window to the last 7 days (end = today). A lazy initializer runs the `Date` read once.
const [periodStart, setPeriodStart] = useState(() =>
isoDate(new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)),
);
const [periodEnd, setPeriodEnd] = useState(() => isoDate(new Date()));
const [runConfirmOpen, setRunConfirmOpen] = useState(false);
const preview = usePreviewPayoutBatch();
const run = useRunPayoutBatch();
const result = preview.data;
const onPreview = () => {
if (!periodStart || !periodEnd) return;
preview.mutate({ periodStart, periodEnd });
};
const onRunConfirm = () => {
const idempotencyKey = crypto?.randomUUID
? crypto.randomUUID()
: `batch_${periodStart}_${periodEnd}_${Date.now()}`;
run.mutate(
{ periodStart, periodEnd, idempotencyKey },
{
onSuccess: (batch) => {
setRunConfirmOpen(false);
onClose();
enqueueSnackbar(t('payout_ran'), { variant: 'success' });
router.push(`/${locale}${adminPayoutBatchPath(batch.id)}`);
},
},
);
};
return (
<Dialog open onClose={run.isPending ? undefined : onClose} fullWidth maxWidth="sm">
<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"
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 } }}
/>
<AppButton
variant="outlined"
color="primary"
onClick={onPreview}
disabled={!periodStart || !periodEnd || preview.isPending}
sx={{ m: 0 }}
>
{t('payout_preview')}
</AppButton>
</Stack>
{result ? (
<Stack sx={{ gap: 2, mt: 2.5 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('payout_col_processing')}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{formatShamsiDate(result.processingDate, locale)}
</Typography>
{result.holidayShifted ? (
<Chip
size="small"
label={t('payout_holiday_shift')}
sx={{ bgcolor: 'var(--bal-warning)', color: 'var(--bal-warning-contrast)', fontWeight: 600 }}
/>
) : null}
</Stack>
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>
{t('payout_eligible_nurses')}
</Typography>
{result.eligible.length === 0 ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
</Typography>
) : (
<Stack divider={<Divider />} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
{result.eligible.map((n) => (
<Stack key={n.nurseId} sx={{ p: 1.5, gap: 0.5 }}>
<Stack
direction="row"
sx={{ justifyContent: 'space-between', gap: 1, alignItems: 'center', flexWrap: 'wrap' }}
>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{n.nurseName ?? `#${n.nurseId}`}
</Typography>
{!n.hasVerifiedPrimaryIban ? (
<Chip
size="small"
label={t('payout_no_iban')}
sx={{ bgcolor: 'var(--bal-error)', color: 'var(--bal-error-contrast)', fontWeight: 600 }}
/>
) : null}
</Stack>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('payout_col_gross')}: {formatIrrToToman(n.grossEarningsIrr, locale)} ·{' '}
{t('payout_col_clawback')}: {formatIrrToToman(n.clawbackAppliedIrr, locale)} ·{' '}
{t('payout_col_net')}: {formatIrrToToman(n.netAmountIrr, locale)} {tCommon('currency_toman')}
</Typography>
</Stack>
))}
</Stack>
)}
</Stack>
{result.skipped.length > 0 ? (
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>
{t('payout_skipped')}
</Typography>
<Stack divider={<Divider />} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
{result.skipped.map((n) => (
<Stack
key={n.nurseId}
direction="row"
sx={{ p: 1.5, justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}
>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{n.nurseName ?? `#${n.nurseId}`}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }} dir="ltr">
{n.reason}
</Typography>
</Stack>
))}
</Stack>
</Stack>
) : null}
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('payout_eligibility_note')}
</Typography>
</Stack>
) : null}
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<AppButton variant="text" color="inherit" onClick={onClose} disabled={run.isPending} sx={{ m: 0 }}>
{t('cancel')}
</AppButton>
{canPayout ? (
<AppButton
variant="contained"
color="primary"
onClick={() => setRunConfirmOpen(true)}
disabled={!result || run.isPending}
sx={{ m: 0 }}
>
{t('payout_run')}
</AppButton>
) : null}
</DialogActions>
<ConfirmDialog
open={runConfirmOpen}
title={t('payout_run_confirm_title')}
body={t('payout_run_confirm_body')}
confirmLabel={t('payout_run')}
cancelLabel={t('cancel')}
onConfirm={onRunConfirm}
onClose={() => setRunConfirmOpen(false)}
loading={run.isPending}
/>
</Dialog>
);
}
@@ -0,0 +1,198 @@
'use client';
import { 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 { AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager, ConfirmDialog } from '@/components/admin';
import { useAdminCapabilities } 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';
/** The moderation worklist tabs — the four `moderationStatus` values; the queue defaults to the pending backlog. */
const MODERATION_STATUSES: readonly ModerationStatus[] = ['pending_moderation', 'published', 'hidden', 'rejected'];
/** Status → MUI chip color. **Never** styles `pending_moderation` like a published review (it reads as a warning). */
const STATUS_CHIP_COLOR: Record<ModerationStatus, 'default' | 'success' | 'warning' | 'error'> = {
pending_moderation: 'warning',
published: 'success',
hidden: 'default',
rejected: 'error',
};
/**
* Review moderation queue (f15) — the admin worklist over `reviews` awaiting a decision (b14). Each row carries
* moderation internals (`lowRatingAlertId`, the nurse/booking context) that are **never** rendered on a
* customer/nurse surface. A review is born `pending_moderation` and is never public / never counted until an
* admin publishes it, so the card presents pending content as an under-review item, not as a published review.
* 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.
*/
export default function AdminReviewsPage() {
const t = useTranslations('admin');
const caps = useAdminCapabilities();
const { enqueueSnackbar } = useSnackbar();
const [status, setStatus] = useState<ModerationStatus>('pending_moderation');
const [page, setPage] = useState(1);
const [pending, setPending] = useState<{ item: ModerationQueueItem; action: ModerationAction } | null>(null);
const queue = useModerationQueue({ status }, page);
const moderate = useModerateReview();
const items = queue.data?.items ?? [];
const pageCount = Math.max(1, Math.ceil((queue.data?.total ?? 0) / REVIEWS_PAGE_SIZE));
const requireReason = pending?.action === 'hide' || pending?.action === 'reject';
const onConfirm = (reason?: string) => {
if (!pending) return;
moderate.mutate(
{ reviewId: pending.item.id, action: pending.action, reason },
{
onSuccess: () => {
enqueueSnackbar(t('mod_done'), { variant: 'success' });
setPending(null);
},
},
);
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader
title={t('mod_title')}
subtitle={t('mod_subtitle')}
actions={
<TextField
select
size="small"
label={t('filter_label')}
value={status}
onChange={(e) => {
setStatus(e.target.value as ModerationStatus);
setPage(1);
}}
sx={{ minWidth: 180 }}
>
{MODERATION_STATUSES.map((s) => (
<MenuItem key={s} value={s}>
{t(`mstatus_${s}`)}
</MenuItem>
))}
</TextField>
}
/>
{queue.isLoading ? (
<Stack sx={{ gap: 2 }}>{[0, 1, 2].map((k) => <Skeleton key={k} variant="rounded" height={150} />)}</Stack>
) : queue.isError ? (
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => queue.refetch()} />
) : items.length === 0 ? (
<AdminEmptyState icon="moderation" title={t('mod_empty')} />
) : (
<Stack sx={{ gap: 2 }}>
{items.map((item) => (
<ModerationCard
key={item.id}
item={item}
canModerate={caps.canModerate}
onAct={(action) => setPending({ item, action })}
/>
))}
</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 })}
/>
<ConfirmDialog
open={pending != null}
title={pending ? t(`mod_${pending.action}`) : ''}
body={pending ? t(`mod_confirm_${pending.action}`) : undefined}
confirmLabel={pending ? t(`mod_${pending.action}`) : t('confirm')}
cancelLabel={t('cancel')}
confirmColor={pending?.action === 'reject' ? 'error' : 'primary'}
loading={moderate.isPending}
requireReason={requireReason}
reasonLabel={t('reason_label')}
onConfirm={onConfirm}
onClose={() => setPending(null)}
/>
</Box>
);
}
/** One review awaiting a decision. Presentational; the actions bubble up to the page-level confirm dialog. */
function ModerationCard({
item,
canModerate,
onAct,
}: {
item: ModerationQueueItem;
canModerate: boolean;
onAct: (action: ModerationAction) => void;
}) {
const t = useTranslations('admin');
const tReviews = useTranslations('reviews');
return (
<Paper
elevation={0}
sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2, display: 'flex', flexDirection: 'column', gap: 1.5 }}
>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<RatingInput value={item.rating} readOnly size={20} ariaLabel={t('mod_col_rating')} />
<Chip size="small" color={STATUS_CHIP_COLOR[item.moderationStatus]} label={t(`mstatus_${item.moderationStatus}`)} />
{item.lowRatingAlertId != null ? (
<Chip size="small" color="warning" variant="outlined" label={t('mod_low_rating')} />
) : null}
</Stack>
{item.body ? (
<Typography variant="body2" sx={{ color: 'text.primary' }}>
{item.body}
</Typography>
) : null}
{item.tagCodes.length > 0 ? (
<Stack direction="row" sx={{ gap: 0.5, flexWrap: 'wrap' }}>
{item.tagCodes.map((code) => (
<Chip key={code} size="small" variant="outlined" label={tReviews(`tag_${code}`)} />
))}
</Stack>
) : null}
<Stack direction="row" sx={{ gap: 2, flexWrap: 'wrap' }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('mod_nurse', { id: item.nurseProfileId })}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('mod_booking', { id: item.bookingId })}
</Typography>
</Stack>
{canModerate ? (
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
<AppButton variant="contained" color="primary" startIcon="publish" onClick={() => onAct('publish')} sx={{ m: 0 }}>
{t('mod_publish')}
</AppButton>
<AppButton variant="outlined" color="inherit" onClick={() => onAct('hide')} sx={{ m: 0 }}>
{t('mod_hide')}
</AppButton>
<AppButton variant="outlined" color="error" onClick={() => onAct('reject')} sx={{ m: 0 }}>
{t('mod_reject')}
</AppButton>
</Stack>
) : null}
</Paper>
);
}
@@ -0,0 +1,205 @@
'use client';
/**
* RBAC roles grid (f15) — **DEFERRED-IF-MISSING.** The b15 contract does not yet expose role grant/revoke
* endpoints, so this console is served by the admin **mock** (REQ-031); an info banner says so. When the
* endpoints land, only `services/admin/apis` flips — this screen is unchanged.
*
* Grants/revokes are gated on `canManageRoles` (only a `super_admin`); the server remains the authority. The
* grid lists **active** grants (revoked rows are filtered out); an audited confirm dialog fronts every
* revoke and grant.
*/
import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import {
Box,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
MenuItem,
Skeleton,
Stack,
TextField,
} from '@mui/material';
import { AppAlert, AppButton } from '@/components';
import {
AdminDataTable,
AdminEmptyState,
AdminErrorState,
AdminPageHeader,
ConfirmDialog,
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';
/** The fine-grained admin roles the grid grants (aligned with the b2 `AdminRole` enum). */
const ROLES: readonly AdminRole[] = ['super_admin', 'admin', 'support', 'finance', 'moderation'];
export default function AdminRolesPage() {
const t = useTranslations('admin');
const locale = useLocale();
const caps = useAdminCapabilities();
const { enqueueSnackbar } = useSnackbar();
const roles = useAdminRoles();
const revoke = useRevokeRole();
const [granting, setGranting] = useState(false);
const [revoking, setRevoking] = useState<RoleGrant | null>(null);
// Only active grants — a revoked grant leaves the grid.
const active = (roles.data ?? []).filter((g) => g.revokedAt == null);
const columns: AdminTableColumn<RoleGrant>[] = [
{ key: 'user', header: t('role_col_user'), render: (g) => `#${g.userId}` },
{
key: 'role',
header: t('role_col_role'),
render: (g) => <Chip size="small" variant="outlined" label={t(`role_${g.role}`)} />,
},
{ key: 'granted', header: t('role_col_granted'), render: (g) => formatShamsiDate(g.grantedAt, locale) },
...(caps.canManageRoles
? [
{
key: 'actions',
header: '',
align: 'right',
render: (g: RoleGrant) => (
<AppButton variant="text" color="error" startIcon="delete" onClick={() => setRevoking(g)} sx={{ m: 0 }}>
{t('role_revoke')}
</AppButton>
),
} as AdminTableColumn<RoleGrant>,
]
: []),
];
const onRevokeConfirm = () => {
if (!revoking) return;
revoke.mutate(
{ userId: revoking.userId, role: revoking.role },
{
onSuccess: () => {
enqueueSnackbar(t('role_updated'), { variant: 'success' });
setRevoking(null);
},
},
);
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader
title={t('role_title')}
subtitle={t('role_subtitle')}
actions={
caps.canManageRoles ? (
<AppButton variant="contained" color="primary" startIcon="add" onClick={() => setGranting(true)} sx={{ m: 0 }}>
{t('role_grant')}
</AppButton>
) : undefined
}
/>
<AppAlert severity="info" variant="outlined">
{t('role_deferred')}
</AppAlert>
{roles.isLoading ? (
<Skeleton variant="rounded" height={200} />
) : roles.isError ? (
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => roles.refetch()} />
) : active.length === 0 ? (
<AdminEmptyState icon="roles" title={t('role_title')} />
) : (
<AdminDataTable
columns={columns}
rows={active}
getRowKey={(g) => `${g.userId}:${g.role}`}
ariaLabel={t('role_title')}
/>
)}
{granting ? <GrantRoleDialog onClose={() => setGranting(false)} /> : null}
<ConfirmDialog
open={revoking != null}
title={t('role_revoke')}
body={revoking ? t('role_revoke_confirm', { role: t(`role_${revoking.role}`), id: revoking.userId }) : undefined}
confirmLabel={t('role_revoke')}
cancelLabel={t('cancel')}
confirmColor="error"
loading={revoke.isPending}
onConfirm={onRevokeConfirm}
onClose={() => setRevoking(null)}
/>
</Box>
);
}
/** Collect a target user id + a role, then grant it (inline confirm copy once both are set). */
function GrantRoleDialog({ onClose }: { onClose: () => void }) {
const t = useTranslations('admin');
const { enqueueSnackbar } = useSnackbar();
const grant = useGrantRole();
const [userId, setUserId] = useState('');
const [role, setRole] = useState<AdminRole>('support');
const parsedId = Number(userId);
const valid = /^\d+$/.test(userId.trim()) && parsedId > 0;
const onGrant = () => {
if (!valid) return;
grant.mutate(
{ userId: parsedId, role },
{
onSuccess: () => {
enqueueSnackbar(t('role_updated'), { variant: 'success' });
onClose();
},
},
);
};
return (
<Dialog open onClose={grant.isPending ? undefined : onClose} fullWidth maxWidth="xs">
<DialogTitle sx={{ fontWeight: 800 }}>{t('role_grant')}</DialogTitle>
<DialogContent>
<Stack sx={{ gap: 2, mt: 1 }}>
<TextField
label={t('role_col_user')}
type="number"
value={userId}
onChange={(e) => setUserId(e.target.value)}
slotProps={{ htmlInput: { min: 1 } }}
/>
<TextField select label={t('role_col_role')} value={role} onChange={(e) => setRole(e.target.value as AdminRole)}>
{ROLES.map((r) => (
<MenuItem key={r} value={r}>
{t(`role_${r}`)}
</MenuItem>
))}
</TextField>
{valid ? (
<DialogContentText>{t('role_grant_confirm', { role: t(`role_${role}`), id: parsedId })}</DialogContentText>
) : null}
</Stack>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<AppButton variant="text" color="inherit" onClick={onClose} disabled={grant.isPending} sx={{ m: 0 }}>
{t('cancel')}
</AppButton>
<AppButton variant="contained" color="primary" onClick={onGrant} disabled={!valid || grant.isPending} sx={{ m: 0 }}>
{grant.isPending ? t('saving') : t('confirm')}
</AppButton>
</DialogActions>
</Dialog>
);
}
@@ -0,0 +1,198 @@
'use client';
import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import {
Box,
Chip,
Collapse,
Paper,
Skeleton,
Stack,
TextField,
ToggleButton,
ToggleButtonGroup,
Typography,
} from '@mui/material';
import { AppButton, StatusChip } from '@/components';
import type { StatusKind } from '@/components';
import { AdminEmptyState, AdminErrorState, AdminMessageBubble, RefundPanel } from '@/components/admin';
import { useAdminCapabilities } from '@/hooks';
import { ROUTES } from '@/constants';
import { useAdminTicket, usePostAdminMessage } from '@/services/tickets';
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' };
/** The `tickets` author-label key. `admin` has no `author_admin` key — staff read as "support" (`author_support`). */
function authorLabelKey(role: TicketAuthorRole): string {
return `author_${role === 'admin' ? 'support' : role}`;
}
/** A client id so the optimistic bubble reconciles to the server message by identity (never double-rendered). */
function makeClientMessageId(): string {
return crypto?.randomUUID?.() ?? String(Date.now());
}
/**
* 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.
*/
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 params = useParams<{ id: string }>();
const parsed = Number(params?.id);
const ticketId = Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
const { data: detail, isLoading, isError, refetch } = useAdminTicket(ticketId || null);
const post = usePostAdminMessage(ticketId);
const [mode, setMode] = useState<'reply' | 'internal'>('reply');
const [body, setBody] = useState('');
const [refundShown, setRefundShown] = useState(false);
const isInternal = mode === 'internal';
const send = () => {
const trimmed = body.trim();
if (!trimmed || post.isPending) return;
post.mutate(
{ body: trimmed, isInternal, clientMessageId: makeClientMessageId() },
{
onSuccess: () => {
setBody('');
enqueueSnackbar(t('ticket_sent'), { variant: 'success' });
},
},
);
};
const showRefund = !!detail && detail.category === 'refund' && detail.bookingId != null && caps.canRefund;
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={{ m: 0, alignSelf: 'flex-start' }}
>
{t('back')}
</AppButton>
{isLoading ? (
<Stack sx={{ gap: 1.5 }}>
<Skeleton variant="rounded" height={96} />
<Skeleton variant="rounded" height={56} sx={{ width: '70%' }} />
<Skeleton variant="rounded" height={56} sx={{ width: '70%', alignSelf: 'flex-end' }} />
</Stack>
) : isError ? (
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => refetch()} />
) : !detail ? (
<AdminEmptyState icon="support" title={t('ticket_empty')} />
) : (
<>
<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>
{showRefund ? (
<Box>
<AppButton
variant="outlined"
color="primary"
startIcon="refunds"
onClick={() => setRefundShown((v) => !v)}
sx={{ m: 0 }}
>
{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>
</Paper>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, maxHeight: 520, overflowY: 'auto', p: 0.5 }}>
{detail.messages.map((m) => (
<AdminMessageBubble
key={m.clientMessageId ?? String(m.id)}
message={m}
authorLabel={tickets(authorLabelKey(m.authorRole))}
/>
))}
</Box>
{caps.canManageTickets ? (
<Paper elevation={0} sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1.5 }}>
<ToggleButtonGroup
size="small"
exclusive
value={mode}
onChange={(_e, next: 'reply' | 'internal' | null) => next && setMode(next)}
>
<ToggleButton value="reply">{t('ticket_public_reply')}</ToggleButton>
<ToggleButton value="internal">{t('ticket_internal_note')}</ToggleButton>
</ToggleButtonGroup>
<TextField
multiline
minRows={2}
maxRows={6}
size="small"
placeholder={isInternal ? t('ticket_composer_internal_ph') : t('ticket_composer_public_ph')}
value={body}
onChange={(e) => setBody(e.target.value)}
/>
<AppButton
variant="contained"
color="primary"
startIcon="send"
onClick={send}
disabled={post.isPending || body.trim().length === 0}
sx={{ m: 0, alignSelf: 'flex-start' }}
>
{t('ticket_send')}
</AppButton>
</Stack>
</Paper>
) : null}
</>
)}
</Box>
);
}
@@ -0,0 +1,166 @@
'use client';
import { useState } 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 type { StatusKind } from '@/components';
import {
AdminDataTable,
AdminEmptyState,
AdminErrorState,
AdminPageHeader,
AdminPager,
type AdminTableColumn,
} from '@/components/admin';
import { adminTicketThreadPath } from '@/constants';
import { useAdminTickets } from '@/services/tickets';
import { TICKETS_PAGE_SIZE } from '@/services/tickets/constants';
import type { AdminTicketFilters, AdminTicketSummary, TicketCategory, TicketStatus } from '@/services/tickets/types';
const STATUSES: readonly TicketStatus[] = ['open', 'closed'];
const CATEGORIES: readonly TicketCategory[] = ['coordination', 'support', 'refund', 'emergency'];
/** Queue status → chip color: an open ticket is pending work, a closed one is neutral (phase §5). */
const STATUS_KIND: Record<TicketStatus, StatusKind> = { open: 'pending', closed: 'neutral' };
const EMPTY: AdminTicketFilters = {};
/**
* 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.
*/
export default function AdminTicketsPage() {
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 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 columns: AdminTableColumn<AdminTicketSummary>[] = [
{
key: 'ref',
header: t('ticket_col_ref'),
render: (row) => (
<Box component="span" dir="ltr" sx={{ fontWeight: 700 }}>
{row.referenceCode}
</Box>
),
},
{ key: 'subject', header: t('ticket_col_subject'), render: (row) => row.subject ?? '—' },
{
key: 'category',
header: t('ticket_col_category'),
render: (row) => <Chip size="small" label={t(`tcat_${row.category}`)} />,
},
{
key: 'status',
header: t('ticket_col_status'),
render: (row) => <StatusChip status={STATUS_KIND[row.status]} label={t(`tstatus_${row.status}`)} />,
},
{ key: 'booking', header: t('ticket_col_booking'), render: (row) => row.bookingId ?? '—' },
];
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader title={t('ticket_title')} subtitle={t('ticket_subtitle')} />
<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('ticket_col_status')}
value={draft.status ?? ''}
onChange={(e) => setDraft((d) => ({ ...d, status: (e.target.value || undefined) as TicketStatus | undefined }))}
sx={{ minWidth: 140 }}
>
<MenuItem value="">{t('filter_all')}</MenuItem>
{STATUSES.map((s) => (
<MenuItem key={s} value={s}>
{t(`tstatus_${s}`)}
</MenuItem>
))}
</TextField>
<TextField
select
size="small"
label={t('ticket_col_category')}
value={draft.category ?? ''}
onChange={(e) => setDraft((d) => ({ ...d, category: (e.target.value || undefined) as TicketCategory | undefined }))}
sx={{ minWidth: 160 }}
>
<MenuItem value="">{t('filter_all')}</MenuItem>
{CATEGORIES.map((c) => (
<MenuItem key={c} value={c}>
{t(`tcat_${c}`)}
</MenuItem>
))}
</TextField>
<TextField
size="small"
label={t('ticket_col_ref')}
placeholder={t('ticket_search_ref_ph')}
value={draft.referenceCode ?? ''}
onChange={(e) => setDraft((d) => ({ ...d, referenceCode: e.target.value || undefined }))}
sx={{ minWidth: 220 }}
/>
<AppButton variant="contained" color="primary" onClick={apply} sx={{ m: 0 }}>
{t('apply')}
</AppButton>
<AppButton variant="text" color="inherit" onClick={clear} sx={{ m: 0 }}>
{t('clear')}
</AppButton>
</Stack>
{tickets.isLoading ? (
<Stack sx={{ gap: 1 }}>
{[0, 1, 2, 3].map((k) => (
<Skeleton key={k} variant="rounded" height={48} />
))}
</Stack>
) : tickets.isError ? (
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => tickets.refetch()} />
) : items.length === 0 ? (
<AdminEmptyState icon="support" title={t('ticket_empty')} />
) : (
<AdminDataTable
columns={columns}
rows={items}
getRowKey={(row) => row.id}
ariaLabel={t('ticket_title')}
onRowClick={(row) => router.push(`/${locale}${adminTicketThreadPath(row.id)}`)}
/>
)}
<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 })}
/>
</Box>
);
}
@@ -0,0 +1,452 @@
'use client';
import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import {
Box,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Skeleton,
Stack,
TextField,
Typography,
} from '@mui/material';
import { AppButton, StatusChip } from '@/components';
import type { StatusKind } from '@/components';
import {
AdminEmptyState,
AdminErrorState,
AdminPageHeader,
ConfirmDialog,
DocumentViewer,
} from '@/components/admin';
import { ROUTES } from '@/constants';
import { formatShamsiDate } from '@/utils';
import { useAdminCapabilities } from '@/hooks';
import {
useApproveVerification,
useDecideStep,
useRejectVerification,
useVerificationCase,
} from '@/services/verification';
import type { AdminVerificationStepDetail, VerificationStepStatus } from '@/services/verification/types';
/** 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'];
/** Per-step status → chip kind. `expired`/`failed` read red; `in_review`/`pending` amber; `passed` green. */
const STEP_STATUS_KIND: Record<VerificationStepStatus, StatusKind> = {
not_started: 'neutral',
pending: 'pending',
in_review: 'pending',
passed: 'verified',
failed: 'rejected',
expired: 'rejected',
};
/**
* 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
* short-lived URL on demand), and the manual-step decisions. A credential-bearing step records the
* (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.
*/
export default function AdminVerificationCasePage() {
const t = useTranslations('admin');
const locale = useLocale();
const router = useRouter();
const params = useParams<{ nurseId: string }>();
const nurseVerificationId = Number(params?.nurseId);
const caps = useAdminCapabilities();
const { enqueueSnackbar } = useSnackbar();
const { data, isLoading, isError, refetch } = useVerificationCase(
Number.isFinite(nurseVerificationId) ? nurseVerificationId : null,
);
const approve = useApproveVerification();
const reject = useRejectVerification();
const [approveOpen, setApproveOpen] = useState(false);
const [rejectOpen, setRejectOpen] = useState(false);
const backToQueue = () => router.push(`/${locale}${ROUTES.ADMIN_VERIFICATION}`);
const allPassed = !!data && data.steps.length > 0 && data.steps.every((step) => step.status === 'passed');
const onApprove = () => {
approve.mutate(nurseVerificationId, {
onSuccess: () => {
enqueueSnackbar(t('ver_decided'), { variant: 'success' });
setApproveOpen(false);
backToQueue();
},
});
};
const onReject = (reason?: string) => {
reject.mutate(
{ nurseVerificationId, reason: reason ?? '' },
{
onSuccess: () => {
enqueueSnackbar(t('ver_decided'), { variant: 'success' });
setRejectOpen(false);
backToQueue();
},
},
);
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Stack sx={{ gap: 1 }}>
<AppButton variant="text" color="primary" onClick={backToQueue} sx={{ m: 0, alignSelf: 'flex-start' }}>
{t('back')}
</AppButton>
<AdminPageHeader title={t('ver_case_title')} />
</Stack>
{isLoading ? (
<Stack sx={{ gap: 2 }}>
<Skeleton variant="rounded" height={72} />
<Skeleton variant="rounded" height={220} />
<Skeleton variant="rounded" height={160} />
</Stack>
) : isError ? (
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => refetch()} />
) : !data ? (
<AdminEmptyState icon="verified" title={t('ver_empty')} />
) : (
<>
<Box sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, p: 2 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('ver_identity_name')}
</Typography>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{data.identityName}
</Typography>
</Box>
<Stack sx={{ gap: 1.5 }}>
<Typography variant="h6">{t('ver_steps_title')}</Typography>
{data.steps.map((step) => (
<StepCard
key={step.id}
step={step}
nurseVerificationId={nurseVerificationId}
canVerify={caps.canVerify}
/>
))}
</Stack>
{data.credentials.length > 0 ? (
<Stack sx={{ gap: 1.5 }}>
<Typography variant="h6">{t('ver_credentials_title')}</Typography>
{data.credentials.map((cred) => (
<Box key={cred.id} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, p: 1.75 }}>
<Stack
direction="row"
sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}
>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t(`step_${cred.credentialType}`)}
</Typography>
{cred.expiresAt ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('ver_expires_at')}: {formatShamsiDate(cred.expiresAt, locale)}
</Typography>
) : null}
</Stack>
<Typography variant="body2">{cred.holderNameSnapshot}</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{cred.issuingAuthority}
</Typography>
</Box>
))}
</Stack>
) : null}
<Stack sx={{ gap: 0.5 }}>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
<AppButton
variant="contained"
color="primary"
onClick={() => setApproveOpen(true)}
disabled={!allPassed || !caps.canVerify}
sx={{ m: 0 }}
>
{t('ver_approve')}
</AppButton>
<AppButton
variant="outlined"
color="error"
onClick={() => setRejectOpen(true)}
disabled={!caps.canVerify}
sx={{ m: 0 }}
>
{t('ver_reject_all')}
</AppButton>
</Stack>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('ver_approve_hint')}
</Typography>
</Stack>
</>
)}
<ConfirmDialog
open={approveOpen}
title={t('ver_approve')}
body={t('ver_approve_confirm')}
confirmLabel={t('confirm')}
cancelLabel={t('cancel')}
onConfirm={onApprove}
onClose={() => setApproveOpen(false)}
loading={approve.isPending}
/>
<ConfirmDialog
open={rejectOpen}
title={t('ver_reject_all')}
body={t('ver_reject_confirm')}
confirmLabel={t('ver_reject_all')}
cancelLabel={t('cancel')}
onConfirm={onReject}
onClose={() => setRejectOpen(false)}
loading={reject.isPending}
requireReason
reasonLabel={t('reason_label')}
reasonPlaceholder={t('ver_reject_reason_ph')}
confirmColor="error"
/>
</Box>
);
}
/** One step of the case: label + status chip (+ automated badge), its documents, and — for a decidable
* manual step — Pass / Reject. A credential-bearing Pass opens the structured credential form. */
function StepCard({
step,
nurseVerificationId,
canVerify,
}: {
step: AdminVerificationStepDetail;
nurseVerificationId: number;
canVerify: boolean;
}) {
const t = useTranslations('admin');
const { enqueueSnackbar } = useSnackbar();
const decide = useDecideStep();
const [rejectOpen, setRejectOpen] = useState(false);
const [credentialOpen, setCredentialOpen] = useState(false);
const isManual = !step.isAutomated;
const isCredentialStep = CREDENTIAL_STEP_CODES.includes(step.code);
const isDecidable = isManual && (step.status === 'in_review' || step.status === 'pending');
const onPass = () => {
decide.mutate(
{ stepId: step.id, nurseVerificationId, input: { approve: true } },
{ onSuccess: () => enqueueSnackbar(t('ver_decided'), { variant: 'success' }) },
);
};
const onReject = (reason?: string) => {
decide.mutate(
{ stepId: step.id, nurseVerificationId, input: { approve: false, rejectionReason: reason ?? '' } },
{
onSuccess: () => {
enqueueSnackbar(t('ver_decided'), { variant: 'success' });
setRejectOpen(false);
},
},
);
};
return (
<Box sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, p: 2 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, flexGrow: 1 }}>
{t(`step_${step.code}`)}
</Typography>
<StatusChip status={STEP_STATUS_KIND[step.status]} label={t(`step_${step.status}`)} />
{step.isAutomated ? (
<Chip size="small" label={t('ver_automated_badge')} sx={{ bgcolor: 'action.hover', fontWeight: 600 }} />
) : null}
</Stack>
{step.documents.length > 0 ? (
<Stack sx={{ gap: 1.5, mt: 1.5 }}>
{step.documents.map((doc) => (
<DocumentViewer key={doc.id} document={doc} />
))}
</Stack>
) : isManual ? (
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 1 }}>
{t('ver_no_documents')}
</Typography>
) : null}
{step.failureReason ? (
<Typography variant="caption" sx={{ color: 'var(--bal-error)', mt: 1, display: 'block' }}>
{step.failureReason}
</Typography>
) : null}
{isDecidable && canVerify ? (
<Stack direction="row" sx={{ gap: 1, mt: 1.5, flexWrap: 'wrap' }}>
<AppButton
variant="contained"
color="primary"
onClick={() => (isCredentialStep ? setCredentialOpen(true) : onPass())}
disabled={decide.isPending}
sx={{ m: 0 }}
>
{t('ver_pass')}
</AppButton>
<AppButton
variant="outlined"
color="error"
onClick={() => setRejectOpen(true)}
disabled={decide.isPending}
sx={{ m: 0 }}
>
{t('ver_reject')}
</AppButton>
</Stack>
) : null}
<ConfirmDialog
open={rejectOpen}
title={t('ver_reject_step')}
confirmLabel={t('ver_reject')}
cancelLabel={t('cancel')}
onConfirm={onReject}
onClose={() => setRejectOpen(false)}
loading={decide.isPending}
requireReason
reasonLabel={t('reason_label')}
reasonPlaceholder={t('ver_reject_reason_ph')}
confirmColor="error"
/>
{isCredentialStep && credentialOpen ? (
<CredentialDialog step={step} nurseVerificationId={nurseVerificationId} onClose={() => setCredentialOpen(false)} />
) : null}
</Box>
);
}
/** The structured credential form recorded on approving a credential-bearing step. `criminal_record`
* requires an expiry date; `credentialNumber` is accepted as input and never echoed back. */
function CredentialDialog({
step,
nurseVerificationId,
onClose,
}: {
step: AdminVerificationStepDetail;
nurseVerificationId: number;
onClose: () => void;
}) {
const t = useTranslations('admin');
const { enqueueSnackbar } = useSnackbar();
const decide = useDecideStep();
const [credentialNumber, setCredentialNumber] = useState('');
const [holderName, setHolderName] = useState('');
const [issuingAuthority, setIssuingAuthority] = useState('');
const [issuedAt, setIssuedAt] = useState('');
const [expiresAt, setExpiresAt] = useState('');
const expiryRequired = step.code === 'criminal_record';
const expiryMissing = expiryRequired && expiresAt.trim().length === 0;
const onSubmit = () => {
if (expiryMissing) return;
decide.mutate(
{
stepId: step.id,
nurseVerificationId,
input: {
approve: true,
credentialNumber: credentialNumber.trim() || undefined,
holderName: holderName.trim() || undefined,
issuingAuthority: issuingAuthority.trim() || undefined,
issuedAt: issuedAt || undefined,
expiresAt: expiresAt || undefined,
},
},
{
onSuccess: () => {
enqueueSnackbar(t('ver_decided'), { variant: 'success' });
onClose();
},
},
);
};
return (
<Dialog open onClose={decide.isPending ? undefined : onClose} fullWidth maxWidth="sm">
<DialogTitle sx={{ fontWeight: 800 }}>{t('ver_credential_title')}</DialogTitle>
<DialogContent>
<Stack sx={{ gap: 2, mt: 1 }}>
<TextField
fullWidth
autoFocus
label={t('ver_credential_number')}
value={credentialNumber}
onChange={(e) => setCredentialNumber(e.target.value)}
/>
<TextField
fullWidth
label={t('ver_holder_name')}
helperText={t('ver_holder_hint')}
value={holderName}
onChange={(e) => setHolderName(e.target.value)}
/>
<TextField
fullWidth
label={t('ver_issuing_authority')}
value={issuingAuthority}
onChange={(e) => setIssuingAuthority(e.target.value)}
/>
<TextField
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)}
required={expiryRequired}
error={expiryMissing}
helperText={expiryMissing ? t('ver_expiry_required') : undefined}
slotProps={{ inputLabel: { shrink: true } }}
/>
</Stack>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<AppButton variant="text" color="inherit" onClick={onClose} disabled={decide.isPending} sx={{ m: 0 }}>
{t('cancel')}
</AppButton>
<AppButton
variant="contained"
color="primary"
onClick={onSubmit}
disabled={decide.isPending || expiryMissing}
sx={{ m: 0 }}
>
{decide.isPending ? t('saving') : t('save')}
</AppButton>
</DialogActions>
</Dialog>
);
}
@@ -0,0 +1,147 @@
'use client';
import { useState } 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 type { StatusKind } from '@/components';
import {
AdminDataTable,
AdminEmptyState,
AdminErrorState,
AdminPageHeader,
AdminPager,
} from '@/components/admin';
import type { AdminTableColumn } from '@/components/admin';
import { adminVerificationCasePath } from '@/constants';
import { formatShamsiDate } from '@/utils';
import { useVerificationQueue } from '@/services/verification';
import { ADMIN_QUEUE_PAGE_SIZE } from '@/services/verification/constants';
import type { AdminVerificationQueueItem, VerificationAggregateStatus } from '@/services/verification/types';
/** The queue status filter — a subset of the aggregate statuses the desk works (default all). */
type QueueStatusFilter = '' | 'pending' | 'in_review';
/** Aggregate status → chip kind. `in_review` reads as informational; a rejected/suspended case shows red. */
const AGG_STATUS_KIND: Record<VerificationAggregateStatus, StatusKind> = {
not_started: 'neutral',
pending: 'pending',
in_review: 'info',
approved: 'verified',
rejected: 'rejected',
suspended: 'rejected',
};
/**
* 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.
*/
export default function AdminVerificationQueuePage() {
const t = useTranslations('admin');
const locale = useLocale();
const router = useRouter();
const [status, setStatus] = useState<QueueStatusFilter>('');
const [page, setPage] = useState(1);
const queue = useVerificationQueue({ status: status || undefined }, page);
const items = queue.data?.items ?? [];
const pageCount = Math.max(1, Math.ceil((queue.data?.total ?? 0) / ADMIN_QUEUE_PAGE_SIZE));
const columns: AdminTableColumn<AdminVerificationQueueItem>[] = [
{
key: 'nurse',
header: t('ver_col_nurse'),
render: (item) => (
<Stack sx={{ gap: 0.5, alignItems: 'flex-start' }}>
<Box sx={{ fontWeight: 700 }}>{item.nurseName}</Box>
{item.hasExpiringCredential ? (
<Chip
size="small"
icon={<AppIcon icon="warning" size={14} color="var(--bal-warning-contrast)" />}
label={t('ver_expiring_warning')}
sx={{ bgcolor: 'var(--bal-warning)', color: 'var(--bal-warning-contrast)', fontWeight: 600 }}
/>
) : null}
</Stack>
),
},
{
key: 'status',
header: t('ver_col_status'),
render: (item) => <StatusChip status={AGG_STATUS_KIND[item.status]} label={t(`agg_${item.status}`)} />,
},
{
key: 'step',
header: t('ver_col_step'),
render: (item) => (
<Stack sx={{ gap: 0.25 }}>
<Box>{t('ver_progress', { done: item.stepsPassed, total: item.stepsTotal })}</Box>
<Box sx={{ color: 'text.secondary', fontSize: 13 }}>
{t('ver_next_step', { step: item.nextPendingStepCode ? t(`step_${item.nextPendingStepCode}`) : '—' })}
</Box>
</Stack>
),
},
{
key: 'submitted',
header: t('ver_col_submitted'),
render: (item) => (item.submittedAt ? formatShamsiDate(item.submittedAt, locale) : '—'),
},
];
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>
}
/>
{queue.isLoading ? (
<Stack sx={{ gap: 1 }}>{[0, 1, 2, 3].map((k) => <Skeleton key={k} variant="rounded" height={56} />)}</Stack>
) : queue.isError ? (
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => queue.refetch()} />
) : items.length === 0 ? (
<AdminEmptyState icon="verified" title={t('ver_empty')} />
) : (
<AdminDataTable
columns={columns}
rows={items}
getRowKey={(item) => item.nurseVerificationId}
ariaLabel={t('ver_title')}
onRowClick={(item) => router.push(`/${locale}${adminVerificationCasePath(item.nurseVerificationId)}`)}
/>
)}
<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 })}
/>
</Box>
);
}
@@ -0,0 +1,213 @@
'use client';
import { useMemo, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { Box, Collapse, Paper, Skeleton, Stack, Tab, Tabs, Typography } from '@mui/material';
import { AppButton, AppIcon, EarningsBalanceHeader, EarningsRow } from '@/components';
import { nurseBookingDetailPath, nursePayoutDetailPath } from '@/constants';
import { PAYOUTS_PAGE_SIZE } from '@/services/payouts/constants';
import { EARNINGS_STATES, type EarningsState } from '@/services/payouts/types';
import { useNurseEarnings, useNurseEarningsBalance } from '@/services/payouts';
type EarningsTab = 'all' | EarningsState;
const TABS: readonly EarningsTab[] = ['all', ...EARNINGS_STATES];
/**
* Nurse earnings home (f12) — the read-only "where is my money?" surface. Shows the ledger-derived net
* payable balance + four-bucket breakdown (`EarningsBalanceHeader`), a plain-Persian explainer of the
* weekly cadence + dispute-window gate, and a state-segmented earnings list. Each row deep-links to the f8
* booking detail (never rebuilt here) and, when paid, to the payout detail. The server owns eligibility and
* amounts; this screen only renders them.
*/
export default function NurseEarningsPage() {
const t = useTranslations('payouts');
const locale = useLocale();
const router = useRouter();
const [tab, setTab] = useState<EarningsTab>('all');
const [page, setPage] = useState(1);
const [explainerOpen, setExplainerOpen] = useState(false);
const stateFilter = tab === 'all' ? undefined : tab;
const balance = useNurseEarningsBalance();
const earnings = useNurseEarnings(stateFilter, page);
const items = earnings.data?.items ?? [];
const total = earnings.data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / PAYOUTS_PAGE_SIZE));
const openBooking = (bookingId: number) => router.push(`/${locale}${nurseBookingDetailPath(bookingId)}`);
const openPayout = (payoutId: number) => router.push(`/${locale}${nursePayoutDetailPath(payoutId)}`);
const onTabChange = (_: React.SyntheticEvent, next: EarningsTab) => {
setTab(next);
setPage(1);
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Box>
<Typography variant="h5" component="h1">
{t('title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('subtitle')}
</Typography>
</Box>
{balance.isLoading ? (
<Skeleton variant="rounded" height={200} />
) : balance.isError ? (
<ErrorPanel message={t('balance_error')} onRetry={() => balance.refetch()} retryLabel={t('retry')} />
) : balance.data ? (
<EarningsBalanceHeader summary={balance.data} />
) : null}
<ExplainerCard open={explainerOpen} onToggle={() => setExplainerOpen((v) => !v)} />
<Stack sx={{ gap: 2 }}>
<Tabs
value={tab}
onChange={onTabChange}
variant="scrollable"
scrollButtons="auto"
allowScrollButtonsMobile
>
{TABS.map((value) => (
<Tab key={value} value={value} label={t(`tab_${value}`)} sx={{ textTransform: 'none' }} />
))}
</Tabs>
{earnings.isLoading ? (
<Stack sx={{ gap: 2 }}>
{[0, 1].map((k) => (
<Skeleton key={k} variant="rounded" height={200} />
))}
</Stack>
) : earnings.isError ? (
<ErrorPanel message={t('list_error')} onRetry={() => earnings.refetch()} retryLabel={t('retry')} />
) : items.length === 0 ? (
<EmptyPanel title={t('list_empty_title')} body={t('list_empty_body')} />
) : (
<Stack sx={{ gap: 2 }}>
{items.map((item) => (
<EarningsRow key={item.bookingId} item={item} onViewBooking={openBooking} onViewPayout={openPayout} />
))}
</Stack>
)}
<Pager
page={page}
pageCount={pageCount}
onPrev={() => setPage((p) => Math.max(1, p - 1))}
onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
/>
</Stack>
</Box>
);
}
/** Collapsible "how payouts work" — the cadence + dispute-window + method-invariant copy (both locales). */
function ExplainerCard({ open, onToggle }: { open: boolean; onToggle: () => void }) {
const t = useTranslations('payouts');
const points = useMemo(() => ['explainer_point_1', 'explainer_point_2', 'explainer_point_3'] as const, []);
return (
<Paper
elevation={0}
sx={{
p: 2,
borderRadius: 2,
border: '1px solid',
borderColor: 'divider',
borderInlineStart: '3px solid',
borderInlineStartColor: 'var(--bal-info)',
}}
>
<Stack
direction="row"
sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between', cursor: 'pointer' }}
onClick={onToggle}
>
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center' }}>
<AppIcon icon="info" size={18} color="var(--bal-info)" />
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('explainer_title')}
</Typography>
</Stack>
<AppIcon icon={open ? 'visibilityoff' : 'visibilityon'} size={18} color="var(--bal-text-secondary)" />
</Stack>
<Collapse in={open}>
<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)}
</Typography>
))}
</Stack>
</Collapse>
</Paper>
);
}
function EmptyPanel({ title, body }: { title: string; body: string }) {
return (
<Paper
elevation={0}
sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}
>
<AppIcon icon="earnings" size={40} color="var(--bal-text-secondary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1 }}>
{title}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
{body}
</Typography>
</Paper>
);
}
function ErrorPanel({ message, onRetry, retryLabel }: { message: string; onRetry: () => void; retryLabel: string }) {
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 1.5 }}>
{message}
</Typography>
<AppButton variant="outlined" color="primary" startIcon="refresh" onClick={onRetry} sx={{ m: 0 }}>
{retryLabel}
</AppButton>
</Paper>
);
}
/** Prev/next pager — rendered only when there is more than one page. */
function Pager({
page,
pageCount,
onPrev,
onNext,
}: {
page: number;
pageCount: number;
onPrev: () => void;
onNext: () => void;
}) {
const t = useTranslations('payouts');
const locale = useLocale();
if (pageCount <= 1) return null;
const fmt = (n: number) => new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US').format(n);
return (
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'center' }}>
<AppButton variant="text" color="primary" onClick={onPrev} disabled={page <= 1} sx={{ m: 0 }}>
{t('page_prev')}
</AppButton>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('page_indicator', { page: fmt(page), total: fmt(pageCount) })}
</Typography>
<AppButton variant="text" color="primary" onClick={onNext} disabled={page >= pageCount} sx={{ m: 0 }}>
{t('page_next')}
</AppButton>
</Stack>
);
}
@@ -0,0 +1,220 @@
'use client';
import { FunctionComponent, ReactNode } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { Box, Divider, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, PriceBreakdown, StatusChip } from '@/components';
import type { StatusKind } from '@/components';
import { nurseBookingDetailPath, ROUTES } from '@/constants';
import { formatIrrToToman, formatShamsiDate, parseIrr } from '@/utils';
import { useNursePayoutDetail } from '@/services/payouts';
import type { PayoutBatchStatus, PayoutStatus } from '@/services/payouts/types';
const PAYOUT_STATUS_KIND: Record<PayoutStatus, StatusKind> = {
pending: 'pending',
submitted: 'info',
paid: 'verified',
failed: 'rejected',
};
const BATCH_STATUS_KIND: Record<PayoutBatchStatus, StatusKind> = {
draft: 'neutral',
processing: 'info',
partially_failed: 'pending',
completed: 'verified',
failed: 'rejected',
};
/**
* Nurse payout/batch reconciliation detail (f12) — one payout expanded: the batch window (holiday-shifted
* server-side), the money decomposition (`gross_earnings clawback_applied = net_amount`, plus the amount
* actually transferred), the masked IBAN + transfer reference, a failed reason (read-only), and the exact
* bookings the payout covered (each deep-linking to its f8 booking detail — "this transfer paid for these
* visits"). Money is display-only; the client never recomputes eligibility, dates, or amounts.
*/
export default function NursePayoutDetailPage() {
const t = useTranslations('payouts');
const tc = useTranslations('common');
const locale = useLocale();
const router = useRouter();
const params = useParams<{ id: string }>();
const payoutId = Number(params?.id);
const { data, isLoading, isError } = useNursePayoutDetail(Number.isFinite(payoutId) ? payoutId : undefined);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Stack sx={{ gap: 0.5 }}>
<AppButton
variant="text"
color="primary"
startIcon="wallet"
onClick={() => router.push(`/${locale}${ROUTES.NURSE_EARNINGS_PAYOUTS}`)}
sx={{ m: 0, alignSelf: 'flex-start' }}
>
{t('back_to_history')}
</AppButton>
<Typography variant="h5" component="h1">
{t('detail_title')}
</Typography>
</Stack>
{isLoading ? (
<Stack sx={{ gap: 2 }}>
<Skeleton variant="rounded" height={160} />
<Skeleton variant="rounded" height={200} />
</Stack>
) : isError || !data ? (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('detail_not_found')}
</Typography>
</Paper>
) : (
<>
<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('detail_batch')}
</Typography>
<Stack direction="row" sx={{ gap: 0.75, flexWrap: 'wrap' }}>
<StatusChip status={BATCH_STATUS_KIND[data.batch.status]} label={t(`bstatus_${data.batch.status}`)} />
<StatusChip status={PAYOUT_STATUS_KIND[data.status]} label={t(`pstatus_${data.status}`)} />
</Stack>
</Stack>
<MetaLine label={t('period_label')}>
{formatShamsiDate(data.batch.periodStart, locale)} {formatShamsiDate(data.batch.periodEnd, locale)}
</MetaLine>
{data.batch.processedAt ? (
<MetaLine label={t('processed_on_label')}>{formatShamsiDate(data.batch.processedAt, locale)}</MetaLine>
) : null}
<MetaLine label={t('masked_iban_label')}>
<Box component="span" dir="ltr">
{data.maskedIban}
</Box>
</MetaLine>
{data.transferReference ? (
<MetaLine label={t('transfer_reference_label')}>
<Box component="span" dir="ltr">
{data.transferReference}
</Box>
</MetaLine>
) : null}
{data.paidAt ? (
<MetaLine label={t('paid_at_label')}>{formatShamsiDate(data.paidAt, locale)}</MetaLine>
) : null}
</Stack>
</Paper>
{data.status === 'failed' ? (
<Box
sx={{
p: 1.75,
borderRadius: 2,
border: '1px solid',
borderColor: 'divider',
borderInlineStart: '3px solid',
borderInlineStartColor: 'var(--bal-error)',
}}
>
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center', mb: 0.5 }}>
<AppIcon icon="warning" size={18} color="var(--bal-error)" />
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{t('failure_title')}
</Typography>
</Stack>
{data.failureReason ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }} dir="ltr">
{t('failure_reason_label')}: {data.failureReason}
</Typography>
) : null}
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
{t('failure_hint')}
</Typography>
</Box>
) : null}
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('detail_money_title')}
</Typography>
<PriceBreakdown
rows={
parseIrr(data.clawbackAppliedIrr) > BigInt(0)
? [
{ key: 'gross_earnings', label: t('gross_earnings_label'), amountIrr: data.grossEarningsIrr },
{
key: 'clawback',
label: t('clawback_applied_label'),
amountIrr: String(-parseIrr(data.clawbackAppliedIrr)),
},
]
: [{ key: 'gross_earnings', label: t('gross_earnings_label'), amountIrr: data.grossEarningsIrr }]
}
totalLabel={t('net_amount_label')}
totalAmountIrr={data.netAmountIrr}
/>
<Stack direction="row" sx={{ justifyContent: 'space-between', px: 0.5 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('amount_transferred_label')}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{formatIrrToToman(data.amountIrr, locale)}
</Typography>
</Stack>
</Stack>
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('detail_bookings_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('detail_bookings_hint')}
</Typography>
<Paper elevation={0} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, overflow: 'hidden' }}>
<Stack divider={<Divider />}>
{data.bookings.map((link) => (
<Stack
key={link.bookingId}
direction="row"
sx={{ p: 1.75, gap: 1.5, alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap' }}
>
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{t('booking_ref', { id: link.bookingId })}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{formatIrrToToman(link.payoutAmountIrr, locale)} {tc('currency_toman')}
</Typography>
</Stack>
<AppButton
variant="text"
color="primary"
endIcon="visits"
onClick={() => router.push(`/${locale}${nurseBookingDetailPath(link.bookingId)}`)}
sx={{ m: 0 }}
>
{t('view_booking')}
</AppButton>
</Stack>
))}
</Stack>
</Paper>
</Stack>
</>
)}
</Box>
);
}
const MetaLine: FunctionComponent<{ label: string; children: ReactNode }> = ({ label, children }) => (
<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: 600 }}>
{children}
</Typography>
</Stack>
);
@@ -0,0 +1,102 @@
'use client';
import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, PayoutHistoryRow } from '@/components';
import { nursePayoutDetailPath, ROUTES } from '@/constants';
import { PAYOUTS_PAGE_SIZE } from '@/services/payouts/constants';
import { useNursePayoutHistory } from '@/services/payouts';
/**
* Nurse payout history (f12) — a paginated, read-only list of the nurse's `nurse_payouts`, newest first.
* Each row (`PayoutHistoryRow`) shows the net amount transferred, the status chip, the batch window, the
* masked IBAN + transfer reference, and — when failed — the reason (no retry; that's an admin action). A row
* opens the payout/batch reconciliation detail.
*/
export default function NursePayoutHistoryPage() {
const t = useTranslations('payouts');
const locale = useLocale();
const router = useRouter();
const [page, setPage] = useState(1);
const history = useNursePayoutHistory(page);
const items = history.data?.items ?? [];
const total = history.data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / PAYOUTS_PAGE_SIZE));
const fmt = (n: number) => new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US').format(n);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Stack sx={{ gap: 0.5 }}>
<AppButton
variant="text"
color="primary"
startIcon="earnings"
onClick={() => router.push(`/${locale}${ROUTES.NURSE_EARNINGS}`)}
sx={{ m: 0, alignSelf: 'flex-start' }}
>
{t('back_to_earnings')}
</AppButton>
<Typography variant="h5" component="h1">
{t('history_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('history_subtitle')}
</Typography>
</Stack>
{history.isLoading ? (
<Stack sx={{ gap: 2 }}>
{[0, 1].map((k) => (
<Skeleton key={k} variant="rounded" height={180} />
))}
</Stack>
) : history.isError ? (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 1.5 }}>
{t('history_error')}
</Typography>
<AppButton variant="outlined" color="primary" startIcon="refresh" onClick={() => history.refetch()} sx={{ m: 0 }}>
{t('retry')}
</AppButton>
</Paper>
) : items.length === 0 ? (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
<AppIcon icon="earnings" size={40} color="var(--bal-text-secondary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1 }}>
{t('history_empty_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
{t('history_empty_body')}
</Typography>
</Paper>
) : (
<Stack sx={{ gap: 2 }}>
{items.map((item) => (
<PayoutHistoryRow
key={item.id}
item={item}
onOpen={(id) => router.push(`/${locale}${nursePayoutDetailPath(id)}`)}
/>
))}
</Stack>
)}
{pageCount > 1 ? (
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'center' }}>
<AppButton variant="text" color="primary" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page <= 1} sx={{ m: 0 }}>
{t('page_prev')}
</AppButton>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('page_indicator', { page: fmt(page), total: fmt(pageCount) })}
</Typography>
<AppButton variant="text" color="primary" onClick={() => setPage((p) => Math.min(pageCount, p + 1))} disabled={page >= pageCount} sx={{ m: 0 }}>
{t('page_next')}
</AppButton>
</Stack>
) : null}
</Box>
);
}
@@ -0,0 +1,7 @@
'use client';
import { NotificationCenter } from '@/components/notifications';
/** /nurse/notifications — the nurse notification center (f14). The bell deep-links here. */
export default function NurseNotificationsPage() {
return <NotificationCenter role="nurse" />;
}
@@ -3,10 +3,12 @@ import { ChangeEvent, FunctionComponent, useRef, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import { Avatar, Box, Paper, Stack, TextField, Typography } from '@mui/material';
import { AppButton, AppIcon, AppLoading } from '@/components';
import { AppButton, AppIcon, AppLoading, TrustBadge } from '@/components';
import { ROUTES } from '@/constants';
import { useNurseProfile, useUpsertNurseProfile, useUploadAvatar } from '@/services/profiles';
import type { NurseProfile } from '@/services/profiles/types';
import { useVerificationStatus } from '@/services/verification';
import { ownBadgeState } from '@/services/verification/types';
const MAX_YEARS = 80;
@@ -24,6 +26,8 @@ const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({
const { enqueueSnackbar } = useSnackbar();
const upsert = useUpsertNurseProfile();
const uploadAvatar = useUploadAvatar();
const { data: verificationStatus } = useVerificationStatus();
const badgeState = ownBadgeState(verificationStatus);
const fileInputRef = useRef<HTMLInputElement>(null);
const [avatarUrl, setAvatarUrl] = useState<string | null>(initial?.avatarUrl ?? null);
@@ -63,41 +67,47 @@ const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 560 }}>
<Box>
<Typography variant="h5" component="h1">
{t('title')}
</Typography>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center', flexWrap: 'wrap' }}>
<Typography variant="h5" component="h1">
{t('title')}
</Typography>
{/* The public trust signal on the nurse's own profile — the same badge f6 reuses in search. */}
<TrustBadge state={badgeState} />
</Stack>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('subtitle')}
</Typography>
</Box>
{/* Not bookable until verification (f5) — a neutral placeholder, not the real banner. */}
<Paper
elevation={0}
sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider', borderInlineStartWidth: 4, borderInlineStartColor: 'var(--bal-warning)' }}
>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'flex-start' }}>
<AppIcon icon="warning" size={24} color="var(--bal-warning)" />
<Stack sx={{ gap: 1, flexGrow: 1 }}>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('unverified_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('unverified_body')}
</Typography>
</Box>
<AppButton
color="primary"
variant="outlined"
to={`/${locale}${ROUTES.NURSE_VERIFICATION}`}
sx={{ m: 0, alignSelf: 'flex-start' }}
>
{t('unverified_cta')}
</AppButton>
{/* Blocked-until-verified banner — shown until the aggregate is approved (incl. the expired state). */}
{badgeState !== 'verified' ? (
<Paper
elevation={0}
sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider', borderInlineStartWidth: 4, borderInlineStartColor: 'var(--bal-warning)' }}
>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'flex-start' }}>
<AppIcon icon="warning" size={24} color="var(--bal-warning)" />
<Stack sx={{ gap: 1, flexGrow: 1 }}>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('unverified_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('unverified_body')}
</Typography>
</Box>
<AppButton
color="primary"
variant="outlined"
to={`/${locale}${ROUTES.NURSE_VERIFICATION}`}
sx={{ m: 0, alignSelf: 'flex-start' }}
>
{t('unverified_cta')}
</AppButton>
</Stack>
</Stack>
</Stack>
</Paper>
</Paper>
) : null}
<Stack direction="row" sx={{ gap: 2, alignItems: 'center' }}>
<Avatar src={avatarUrl ?? undefined} sx={{ width: 72, height: 72, bgcolor: 'var(--bal-primary-soft)' }}>
@@ -0,0 +1,317 @@
'use client';
import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useParams, useRouter } from 'next/navigation';
import { useSnackbar } from 'notistack';
import {
Box,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Divider,
Paper,
Skeleton,
Stack,
TextField,
Typography,
} from '@mui/material';
import { AppButton, AppIcon, CountdownTimer, PriceDisplay, StatusChip } from '@/components';
import { ROUTES } from '@/constants';
import { ApiError } from '@/lib/api/errors';
import { formatShamsiDate } from '@/utils';
import {
useAcceptBookingRequest,
useBookingRequest,
useRejectBookingRequest,
} from '@/services/bookingRequests';
import { REJECTION_REASON_MAX_LENGTH } from '@/services/bookingRequests/constants';
import { isTerminalBookingRequestStatus, type BookingRequestDto } from '@/services/bookingRequests/types';
/**
* Nurse request detail (نمای پرستار). Renders the request summary and **only `customerNotes`** as the
* clinical context (two-stage disclosure — the address is masked to city/district and no clinical field
* exists pre-accept). A pending request offers accept / reject (with a reason); both invalidate the inbox
* + this detail so the request leaves the pending list and the customer's C5 reflects it. A stale action
* returns `409`, surfaced then refetched.
*/
export default function NurseRequestDetailPage() {
const t = useTranslations('booking');
const locale = useLocale();
const router = useRouter();
const { enqueueSnackbar } = useSnackbar();
const params = useParams<{ id: string }>();
const id = Number(params.id);
const { data: request, isLoading, isError, refetch } = useBookingRequest(
Number.isInteger(id) && id > 0 ? id : undefined,
'nurse',
);
const acceptRequest = useAcceptBookingRequest();
const rejectRequest = useRejectBookingRequest();
const [rejectOpen, setRejectOpen] = useState(false);
const [reason, setReason] = useState('');
const [reasonError, setReasonError] = useState(false);
if (isLoading) return <DetailSkeleton />;
if (isError || !request) {
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2, maxWidth: 640 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1 }}>
{t('not_found_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
{t('not_found_body')}
</Typography>
<AppButton variant="outlined" color="primary" onClick={() => router.push(`/${locale}${ROUTES.NURSE_REQUESTS}`)} sx={{ m: 0 }}>
{t('inbox_title')}
</AppButton>
</Paper>
);
}
const pending = request.status === 'pending_nurse_response';
const isTerminal = isTerminalBookingRequestStatus(request.status);
const location = coarseLocation(request, locale, t('address_whole_city'));
const handleStaleError = (error: unknown) => {
if (error instanceof ApiError && error.status === 409) {
enqueueSnackbar(t('action_stale'), { variant: 'warning' });
refetch();
} else {
enqueueSnackbar(t('error_generic'), { variant: 'error' });
}
};
const handleAccept = () => {
acceptRequest.mutate(request.id, {
onSuccess: () => enqueueSnackbar(t('accepted_toast'), { variant: 'success' }),
onError: handleStaleError,
});
};
const handleReject = () => {
const trimmed = reason.trim();
if (!trimmed) {
setReasonError(true);
return;
}
rejectRequest.mutate(
{ id: request.id, payload: { reason: trimmed } },
{
onSuccess: () => {
setRejectOpen(false);
setReason('');
enqueueSnackbar(t('rejected_toast'), { variant: 'success' });
},
onError: (error) => {
setRejectOpen(false);
handleStaleError(error);
},
},
);
};
const startDate = new Date(`${request.requestedDate}T${request.requestedTimeStart}`);
const endDate = new Date(`${request.requestedDate}T${request.requestedTimeEnd}`);
const timeFmt = new Intl.DateTimeFormat(locale === 'fa' ? 'fa-IR' : 'en-US', { hour: '2-digit', minute: '2-digit' });
const whenLabel = `${formatShamsiDate(startDate, locale)} · ${timeFmt.format(startDate)} ${timeFmt.format(endDate)}`;
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 640 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}>
<Typography variant="h5" component="h1">
{t('detail_title')}
</Typography>
<StatusChip status={statusKind(request.status)} label={t(`status_${request.status}`)} />
</Stack>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1.5 }}>
<DetailRow caption={t('summary_patient')}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{request.patientName}
</Typography>
</DetailRow>
<DetailRow caption={t('summary_service')}>
<Stack sx={{ gap: 0.25, alignItems: 'flex-end' }}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{request.variantLabel}
</Typography>
{request.variantPrice ? (
<PriceDisplay price={request.variantPrice} priceUnit={request.variantPriceUnit} />
) : null}
</Stack>
</DetailRow>
<DetailRow caption={t('location_label')}>
<Typography variant="body2" sx={{ fontWeight: 600, textAlign: 'end' }}>
{location}
</Typography>
</DetailRow>
<DetailRow caption={t('summary_when')}>
<Typography variant="body2" sx={{ fontWeight: 600, textAlign: 'end' }}>
{whenLabel}
</Typography>
</DetailRow>
{request.requiredCaregiverGender ? (
<DetailRow caption={t('gender_label')}>
<Chip
size="small"
label={t(`gender_${request.requiredCaregiverGender}`)}
sx={{ bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 600 }}
/>
</DetailRow>
) : null}
</Stack>
</Paper>
{/* Stage-1 clinical context — ONLY the family's notes, never a clinical/care field. */}
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('inbox_notes_label')}
</Typography>
<Typography variant="body2" sx={{ color: request.customerNotes ? 'text.primary' : 'text.secondary' }}>
{request.customerNotes || '—'}
</Typography>
<Divider sx={{ my: 0.5 }} />
<Stack direction="row" sx={{ gap: 1, alignItems: 'flex-start' }}>
<AppIcon icon="info" size={18} color="var(--bal-info)" />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('disclosure_note')}
</Typography>
</Stack>
</Stack>
</Paper>
{pending ? (
<Stack sx={{ gap: 1.5 }}>
<CountdownTimer
deadlineIso={request.nurseResponseDeadlineAt}
label={t('response_countdown_label')}
elapsedText={t('response_elapsed')}
onElapsed={() => refetch()}
/>
<Stack direction="row" sx={{ gap: 1 }}>
<AppButton
color="primary"
variant="contained"
startIcon="verified"
disabled={acceptRequest.isPending}
onClick={handleAccept}
sx={{ m: 0, flex: 1, py: 1.25 }}
>
{acceptRequest.isPending ? t('accepting') : t('accept')}
</AppButton>
<AppButton
color="error"
variant="outlined"
startIcon="rejected"
disabled={acceptRequest.isPending}
onClick={() => setRejectOpen(true)}
sx={{ m: 0, flex: 1, py: 1.25 }}
>
{t('reject')}
</AppButton>
</Stack>
</Stack>
) : (
<Paper
elevation={0}
sx={{
p: 2,
borderRadius: 2,
border: '1px solid',
borderColor: 'divider',
borderInlineStartWidth: 4,
borderInlineStartColor: isTerminal ? 'var(--bal-text-secondary)' : 'var(--bal-secondary)',
}}
>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t(`status_${request.status}`)}
</Typography>
</Paper>
)}
<Dialog open={rejectOpen} onClose={() => setRejectOpen(false)} fullWidth maxWidth="xs">
<DialogTitle>{t('reject_dialog_title')}</DialogTitle>
<DialogContent>
<TextField
autoFocus
label={t('reject_reason_label')}
placeholder={t('reject_reason_placeholder')}
value={reason}
onChange={(event) => {
setReason(event.target.value.slice(0, REJECTION_REASON_MAX_LENGTH));
if (reasonError) setReasonError(false);
}}
error={reasonError}
helperText={reasonError ? t('reason_required') : undefined}
multiline
minRows={2}
fullWidth
sx={{ mt: 1 }}
/>
</DialogContent>
<DialogActions>
<AppButton variant="text" onClick={() => setRejectOpen(false)}>
{t('cancel_request')}
</AppButton>
<AppButton color="error" variant="contained" disabled={rejectRequest.isPending} onClick={handleReject}>
{rejectRequest.isPending ? t('rejecting') : t('reject_submit')}
</AppButton>
</DialogActions>
</Dialog>
</Box>
);
}
/** Coarse, masked location (city · district) — the nurse view never receives the full address. */
function coarseLocation(request: BookingRequestDto, locale: string, wholeCityLabel: string): string {
const city = locale === 'en' ? request.cityNameEn : request.cityNameFa;
const district =
request.districtId == null ? wholeCityLabel : locale === 'en' ? request.districtNameEn : request.districtNameFa;
return `${city} · ${district}`;
}
function statusKind(status: BookingRequestDto['status']) {
switch (status) {
case 'accepted_awaiting_payment':
return 'active' as const;
case 'converted':
return 'verified' as const;
case 'rejected_by_nurse':
case 'payment_deadline_expired':
return 'rejected' as const;
case 'expired_no_response':
case 'cancelled_by_customer':
return 'neutral' as const;
default:
return 'pending' as const;
}
}
function DetailRow({ caption, children }: { caption: string; children: React.ReactNode }) {
return (
<Stack direction="row" sx={{ gap: 2, justifyContent: 'space-between', alignItems: 'flex-start' }}>
<Typography variant="body2" sx={{ color: 'text.secondary', flexShrink: 0 }}>
{caption}
</Typography>
{children}
</Stack>
);
}
function DetailSkeleton() {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 640 }}>
<Skeleton variant="text" width="40%" height={36} />
<Skeleton variant="rounded" height={180} />
<Skeleton variant="rounded" height={120} />
<Skeleton variant="rounded" height={56} />
</Box>
);
}
@@ -0,0 +1,115 @@
'use client';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { Box, Chip, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, CountdownTimer } from '@/components';
import { ROUTES } from '@/constants';
import { formatShamsiDate } from '@/utils';
import { useNurseRequestInbox } from '@/services/bookingRequests';
import type { BookingRequestListItem } from '@/services/bookingRequests/types';
/**
* Nurse incoming-requests inbox (نمای پرستار). Lists pending requests — each a card with the family's
* patient name, the requested time (Shamsi), the **required-caregiver-gender** chip, a notes preview, and
* a **per-request countdown** to that request's response deadline. Two-stage disclosure: the row shows
* only `customerNotes` — never an address or any clinical field. Lightly polled so new requests appear.
*/
export default function NurseRequestsPage() {
const t = useTranslations('booking');
const { data, isLoading } = useNurseRequestInbox();
const items = data?.items ?? [];
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 640 }}>
<Box>
<Typography variant="h5" component="h1">
{t('inbox_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('inbox_subtitle')}
</Typography>
</Box>
{isLoading ? (
<Stack sx={{ gap: 2 }}>
{[0, 1].map((key) => (
<Skeleton key={key} variant="rounded" height={140} />
))}
</Stack>
) : items.length === 0 ? (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
<AppIcon icon="requests" size={40} color="var(--bal-text-secondary)" />
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 1 }}>
{t('inbox_empty')}
</Typography>
</Paper>
) : (
<Stack sx={{ gap: 2 }}>
{items.map((item) => (
<InboxCard key={item.id} item={item} />
))}
</Stack>
)}
</Box>
);
}
function InboxCard({ item }: { item: BookingRequestListItem }) {
const t = useTranslations('booking');
const locale = useLocale();
const router = useRouter();
const startDate = new Date(`${item.requestedDate}T${item.requestedTimeStart}`);
const endDate = new Date(`${item.requestedDate}T${item.requestedTimeEnd}`);
const timeFmt = new Intl.DateTimeFormat(locale === 'fa' ? 'fa-IR' : 'en-US', { hour: '2-digit', minute: '2-digit' });
const whenLabel = `${formatShamsiDate(startDate, locale)} · ${timeFmt.format(startDate)} ${timeFmt.format(endDate)}`;
return (
<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: 'flex-start', gap: 2 }}>
<Stack sx={{ gap: 0.5, minWidth: 0 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{item.counterpartyName}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{whenLabel}
</Typography>
</Stack>
<CountdownTimer deadlineIso={item.nurseResponseDeadlineAt} elapsedText={t('response_elapsed')} />
</Stack>
{item.requiredCaregiverGender ? (
<Box>
<Chip
size="small"
label={t('required_gender_chip', { gender: t(`gender_${item.requiredCaregiverGender}`) })}
sx={{ bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 600 }}
/>
</Box>
) : null}
{item.customerNotes ? (
<Box>
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 700 }}>
{t('inbox_notes_label')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }} noWrap>
{item.customerNotes}
</Typography>
</Box>
) : null}
<AppButton
variant="outlined"
color="primary"
endIcon="requests"
onClick={() => router.push(`/${locale}${ROUTES.NURSE_REQUESTS}/${item.id}`)}
sx={{ m: 0, alignSelf: 'flex-start' }}
>
{t('open_detail')}
</AppButton>
</Stack>
</Paper>
);
}
@@ -16,6 +16,7 @@ import {
import { AppButton, AppIcon, VariantCard } from '@/components';
import { useMyVariants, useSetVariantActive } from '@/services/catalog';
import type { NurseServiceVariant } from '@/services/catalog/types';
import PublishGate from './PublishGate';
interface MyServicesListProps {
onAdd: () => void;
@@ -86,6 +87,8 @@ const MyServicesList: FunctionComponent<MyServicesListProps> = ({ onAdd, onEdit
) : null}
</Stack>
<PublishGate />
{isLoading ? (
<Stack sx={{ gap: 1.5 }}>
{[0, 1].map((key) => (
@@ -0,0 +1,84 @@
'use client';
import { FunctionComponent } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import { Paper, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon } from '@/components';
import { ROUTES } from '@/constants';
import { useVerificationStatus } from '@/services/verification';
import { isApproved } from '@/services/verification/types';
/**
* The go-live gate for the nurse's services (the f4 publish stub, wired to verification here). A nurse
* is **not bookable and cannot publish until verified**: when the aggregate isn't `approved` the publish
* CTA is **disabled** with a blocked-until-verified explanation that links to the B3 checklist; once
* approved it enables. Mirrors the server's guarded `is_verified` flip — the UI never implies a nurse is
* live before verification completes. Reads the shared `VerificationStatus` query (cached across the app).
*/
const PublishGate: FunctionComponent = () => {
const t = useTranslations('verification');
const locale = useLocale();
const { enqueueSnackbar } = useSnackbar();
const { data: status, isLoading } = useVerificationStatus();
if (isLoading) return null;
const approved = isApproved(status);
return (
<Paper
elevation={0}
sx={{
p: 2,
borderRadius: 2,
border: '1px solid',
borderColor: 'divider',
borderInlineStartWidth: 4,
borderInlineStartColor: approved ? 'var(--bal-success)' : 'var(--bal-warning)',
display: 'flex',
flexDirection: 'column',
gap: 1,
}}
>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'flex-start' }}>
<AppIcon
icon={approved ? 'verified' : 'warning'}
size={24}
color={approved ? 'var(--bal-success)' : 'var(--bal-warning)'}
/>
<Stack sx={{ gap: 0.5, flexGrow: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{approved ? t('publish_ready_title') : t('publish_blocked_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{approved ? t('publish_ready_body') : t('publish_blocked_body')}
</Typography>
</Stack>
</Stack>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
<AppButton
color="primary"
variant="contained"
startIcon="publish"
disabled={!approved}
onClick={() => enqueueSnackbar(t('publish_done'), { variant: 'success' })}
sx={{ m: 0 }}
>
{t('publish_cta')}
</AppButton>
{!approved ? (
<AppButton
variant="outlined"
color="primary"
to={`/${locale}${ROUTES.NURSE_VERIFICATION}`}
sx={{ m: 0 }}
>
{t('publish_complete_verification')}
</AppButton>
) : null}
</Stack>
</Paper>
);
};
export default PublishGate;
@@ -0,0 +1,11 @@
'use client';
import { useParams } from 'next/navigation';
import { TicketThreadScreen } from '@/components/messaging';
/** /nurse/support/tickets/[id] — the nurse ticket thread (f14). */
export default function NurseTicketThreadPage() {
const params = useParams<{ id: string }>();
const id = Number(params.id);
const ticketId = Number.isInteger(id) && id > 0 ? id : -1;
return <TicketThreadScreen role="nurse" ticketId={ticketId} />;
}
@@ -0,0 +1,7 @@
'use client';
import { TicketInboxScreen } from '@/components/messaging';
/** /nurse/support/tickets — the nurse "My Tickets" inbox (f14). Same screen as the customer, nurse chrome. */
export default function NurseTicketsPage() {
return <TicketInboxScreen role="nurse" />;
}
@@ -0,0 +1,138 @@
'use client';
import { FunctionComponent } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Box, LinearProgress, Paper, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, StatusChip } from '@/components';
import type { VerificationStatus, VerificationStep } from '@/services/verification/types';
import {
displaySteps,
progressCounts,
routeForStep,
stepDescriptionKey,
stepLabelKey,
stepStatusChip,
} from './verificationSteps';
interface VerificationChecklistProps {
status: VerificationStatus;
}
/**
* The B3 checklist body: the "X از Y" progress meter + the data-driven, ordered step rows (reusing the
* shared `StatusChip`). Rows are rendered from `displaySteps(status)` — a new step type appears without a
* code change. Failed/expired rows surface their reason and a re-submit path; the first actionable
* automated/manual step gets an inline "go" link to its screen.
*/
const VerificationChecklist: FunctionComponent<VerificationChecklistProps> = ({ status }) => {
const { passed, total } = progressCounts(status);
const steps = displaySteps(status);
const firstActionableId = steps.find(
(step) => step.status !== 'passed' && step.status !== 'in_review' && routeForStep(step.code) !== null,
)?.id;
return (
<Stack sx={{ gap: 2 }}>
<ProgressMeter passed={passed} total={total} />
<Stack sx={{ gap: 1 }}>
{steps.map((step) => (
<StepRow key={step.code} step={step} highlighted={step.id === firstActionableId} />
))}
</Stack>
</Stack>
);
};
const ProgressMeter: FunctionComponent<{ passed: number; total: number }> = ({ passed, total }) => {
const t = useTranslations('verification');
const locale = useLocale();
const percent = total === 0 ? 0 : (passed / total) * 100;
const format = (value: number) => new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US').format(value);
return (
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'baseline', mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('progress_title')}
</Typography>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: 'var(--bal-primary)' }}>
{t('progress_count', { passed: format(passed), total: format(total) })}
</Typography>
</Stack>
<LinearProgress variant="determinate" value={percent} sx={{ height: 8, borderRadius: 1 }} />
</Paper>
);
};
const StepRow: FunctionComponent<{ step: VerificationStep; highlighted: boolean }> = ({ step, highlighted }) => {
const t = useTranslations('verification');
const locale = useLocale();
const chip = stepStatusChip(step.status);
const labelKey = stepLabelKey(step.code);
const descKey = stepDescriptionKey(step.code);
const label = t.has(labelKey) ? t(labelKey) : step.displayName;
const description = t.has(descKey) ? t(descKey) : '';
const route = routeForStep(step.code);
const showReason = step.status === 'failed' || step.status === 'expired';
const reason = step.failureReason
? t.has(`reason_${step.failureReason}`)
? t(`reason_${step.failureReason}`)
: step.failureReason
: null;
// Only the genuinely automated checks may advertise "استعلام خودکار" — the honesty constraint.
const showAutoNote = step.isAutomated && step.status === 'not_started';
return (
<Paper
elevation={0}
sx={{
p: 2,
borderRadius: 2,
border: '1px solid',
borderColor: highlighted ? 'var(--bal-primary)' : 'divider',
display: 'flex',
flexDirection: 'column',
gap: 1,
}}
>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
<AppIcon icon={step.isAutomated ? 'verified' : 'document'} size={22} color="var(--bal-text-secondary)" />
<Stack sx={{ gap: 0.25, flexGrow: 1, minWidth: 0 }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{label}
</Typography>
{description ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{description}
</Typography>
) : null}
</Stack>
<StatusChip status={chip.kind} label={t(chip.labelKey)} sx={{ flexShrink: 0 }} />
</Stack>
{showReason && reason ? (
<Typography variant="body2" sx={{ color: 'var(--bal-error)' }}>
{reason}
</Typography>
) : null}
{showAutoNote ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('auto_query_note')}
</Typography>
) : null}
{route && (highlighted || step.status === 'failed' || step.status === 'expired') ? (
<AppButton
variant="text"
color="primary"
endIcon="edit"
to={`/${locale}${route}`}
sx={{ m: 0, alignSelf: 'flex-start' }}
>
{step.status === 'failed' || step.status === 'expired' ? t('row_fix') : t('row_go')}
</AppButton>
) : null}
</Paper>
);
};
export default VerificationChecklist;
@@ -0,0 +1,266 @@
'use client';
import { useMemo, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { useSnackbar } from 'notistack';
import { Box, Chip, Paper, Stack, TextField, Typography } from '@mui/material';
import { AppButton, AppIcon, AppLoading, DocumentUpload, StepperHeader } from '@/components';
import type { UploadedDocInfo } from '@/components';
import { ROUTES } from '@/constants';
import {
useSubmitCredentials,
useUploadVerificationDocument,
useVerificationStatus,
} from '@/services/verification';
import { SPECIALTY_PRESETS } from '@/services/verification/types';
import type { VerificationStep } from '@/services/verification/types';
import { stepDescriptionKey, stepLabelKey } from '../verificationSteps';
const MANUAL_CREDENTIAL_CODES = ['moh_competency_license', 'ino_membership', 'criminal_record'];
/**
* B5 — professional credentials. Renders a `DocumentUpload` for **each manual credential step in the
* status** (data-driven — a new manual step renders without a code change); each upload moves its step to
* `in_review` (manual admin review — copy never claims an automated authority check). Collects the INO
* number + specialty chips + optional registry fields, persisted on submit. Lands on B6 (under review).
*/
export default function CredentialsSubmitPage() {
const t = useTranslations('verification');
const locale = useLocale();
const router = useRouter();
const { enqueueSnackbar } = useSnackbar();
const { data: status, isLoading } = useVerificationStatus();
const uploadDocument = useUploadVerificationDocument();
const submitCredentials = useSubmitCredentials();
const [inoNumber, setInoNumber] = useState('');
const [inoError, setInoError] = useState(false);
const [specialties, setSpecialties] = useState<string[]>([]);
const [customSpecialty, setCustomSpecialty] = useState('');
const [issuingAuthority, setIssuingAuthority] = useState('');
const [issuedAt, setIssuedAt] = useState('');
const [expiresAt, setExpiresAt] = useState('');
const [uploadedSteps, setUploadedSteps] = useState<Record<number, boolean>>({});
const manualSteps = useMemo(
() => (status?.steps ?? []).filter((step) => MANUAL_CREDENTIAL_CODES.includes(step.code)),
[status],
);
const toggleSpecialty = (value: string) =>
setSpecialties((prev) => (prev.includes(value) ? prev.filter((item) => item !== value) : [...prev, value]));
const addCustomSpecialty = () => {
const value = customSpecialty.trim();
if (value && !specialties.includes(value)) setSpecialties((prev) => [...prev, value]);
setCustomSpecialty('');
};
const uploadToStep = (step: VerificationStep) => async (file: File, onProgress: (percent: number) => void) => {
const doc = await uploadDocument.mutateAsync({ stepId: step.id, file, onProgress });
return { name: doc.originalFileName ?? file.name, sizeBytes: doc.fileSizeBytes } satisfies UploadedDocInfo;
};
const handleSubmit = () => {
const inoValid = inoNumber.trim().length > 0;
setInoError(!inoValid);
if (!inoValid) return;
submitCredentials.mutate(
{
inoNumber: inoNumber.trim(),
specialties,
issuingAuthority: issuingAuthority.trim() || undefined,
issuedAt: issuedAt || undefined,
expiresAt: expiresAt || undefined,
},
{
onSuccess: () => {
enqueueSnackbar(t('credentials_submitted'), { variant: 'success' });
router.push(`/${locale}${ROUTES.NURSE_VERIFICATION_REVIEW}`);
},
onError: () => enqueueSnackbar(t('credentials_error'), { variant: 'error' }),
},
);
};
if (isLoading) return <AppLoading />;
if (!status || manualSteps.length === 0) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, maxWidth: 560 }}>
<Typography variant="h5" component="h1">
{t('credentials_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('credentials_needs_start')}
</Typography>
<AppButton color="primary" variant="contained" to={`/${locale}${ROUTES.NURSE_VERIFICATION}`} sx={{ m: 0, alignSelf: 'flex-start' }}>
{t('back_to_checklist')}
</AppButton>
</Box>
);
}
const anyUploaded = Object.values(uploadedSteps).some(Boolean);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 560 }}>
<Box>
<Typography variant="h5" component="h1">
{t('credentials_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('credentials_subtitle')}
</Typography>
</Box>
<Box sx={{ overflowX: 'auto' }}>
<StepperHeader steps={[t('journey_identity'), t('journey_credentials'), t('journey_review')]} activeStep={1} />
</Box>
<TextField
label={t('ino_number_label')}
value={inoNumber}
onChange={(event) => {
setInoNumber(event.target.value);
if (inoError) setInoError(false);
}}
error={inoError}
helperText={inoError ? t('ino_number_required') : t('ino_number_hint')}
slotProps={{ htmlInput: { dir: 'ltr', style: { textAlign: 'start' } } }}
fullWidth
/>
{/* One uploader per manual credential step — data-driven from the status. */}
<Stack sx={{ gap: 2 }}>
{manualSteps.map((step) => (
<DocumentUpload
key={step.code}
label={t.has(stepLabelKey(step.code)) ? t(stepLabelKey(step.code)) : step.displayName}
hint={t.has(stepDescriptionKey(step.code)) ? t(stepDescriptionKey(step.code)) : undefined}
onUpload={uploadToStep(step)}
onUploaded={() => setUploadedSteps((prev) => ({ ...prev, [step.id]: true }))}
rejected={step.status === 'failed'}
rejectionReason={step.failureReason ?? undefined}
existingDoc={step.status === 'in_review' ? { name: t('doc_uploaded') } : null}
/>
))}
</Stack>
{/* Supplementary education certificate — a local attachment (no dedicated step). */}
<DocumentUpload label={t('education_label')} hint={t('education_hint')} onUpload={async (file) => ({ name: file.name })} />
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('specialties_label')}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('specialties_hint')}
</Typography>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
{SPECIALTY_PRESETS.map((preset) => {
const selected = specialties.includes(preset);
return (
<Chip
key={preset}
label={t.has(`specialty_${preset}`) ? t(`specialty_${preset}`) : preset}
onClick={() => toggleSpecialty(preset)}
sx={{
fontWeight: 600,
backgroundColor: selected ? 'var(--bal-primary)' : 'var(--bal-primary-soft)',
color: selected ? 'var(--bal-primary-contrast)' : 'var(--bal-primary)',
}}
/>
);
})}
{specialties
.filter((value) => !SPECIALTY_PRESETS.includes(value))
.map((value) => (
<Chip
key={value}
label={value}
onDelete={() => toggleSpecialty(value)}
sx={{ fontWeight: 600, backgroundColor: 'var(--bal-primary)', color: 'var(--bal-primary-contrast)' }}
/>
))}
</Stack>
<Stack direction="row" sx={{ gap: 1, alignItems: 'flex-start' }}>
<TextField
size="small"
placeholder={t('specialty_add_placeholder')}
value={customSpecialty}
onChange={(event) => setCustomSpecialty(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
addCustomSpecialty();
}
}}
/>
<AppButton variant="outlined" color="primary" startIcon="add" onClick={addCustomSpecialty} sx={{ m: 0 }}>
{t('specialty_add')}
</AppButton>
</Stack>
</Stack>
{/* Optional registry details the admin cross-checks — issue/expiry are stored UTC (Shamsi shown elsewhere). */}
<Stack sx={{ gap: 1.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('registry_details_label')}
</Typography>
<TextField
label={t('issuing_authority_label')}
value={issuingAuthority}
onChange={(event) => setIssuingAuthority(event.target.value)}
fullWidth
/>
<Stack direction="row" sx={{ gap: 1.5, flexWrap: 'wrap' }}>
<TextField
label={t('issued_at_label')}
type="date"
value={issuedAt}
onChange={(event) => setIssuedAt(event.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
sx={{ flex: 1, minWidth: 160 }}
/>
<TextField
label={t('expires_at_label')}
type="date"
value={expiresAt}
onChange={(event) => setExpiresAt(event.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
sx={{ flex: 1, minWidth: 160 }}
/>
</Stack>
</Stack>
<Paper
elevation={0}
sx={{ p: 1.5, borderRadius: 2, bgcolor: 'var(--bal-primary-soft)', display: 'flex', gap: 1, alignItems: 'center' }}
>
<AppIcon icon="info" size={18} color="var(--bal-primary)" />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('manual_review_note')}
</Typography>
</Paper>
<Stack direction="row" sx={{ gap: 1 }}>
<AppButton
color="primary"
variant="contained"
startIcon="license"
onClick={handleSubmit}
disabled={submitCredentials.isPending || !anyUploaded}
sx={{ m: 0 }}
>
{submitCredentials.isPending ? t('credentials_submitting') : t('credentials_submit')}
</AppButton>
<AppButton variant="text" color="primary" to={`/${locale}${ROUTES.NURSE_VERIFICATION}`} sx={{ m: 0 }}>
{t('back_to_checklist')}
</AppButton>
</Stack>
</Box>
);
}
@@ -0,0 +1,156 @@
'use client';
import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { useSnackbar } from 'notistack';
import { Box, Paper, Stack, TextField, Typography } from '@mui/material';
import { AppAlert, AppButton, AppIcon, DocumentUpload, StepperHeader } from '@/components';
import { ROUTES } from '@/constants';
import { toEnglishDigits } from '@/utils';
import { useSubmitIdentity } from '@/services/verification';
import { isValidNationalId } from '@/services/verification/validation';
import { ACCEPTED_IMAGE_TYPES, NATIONAL_ID_LENGTH } from '@/services/verification/constants';
import type { SubmitIdentityResult } from '@/services/verification/hooks/useSubmitIdentity';
type SubmitError = { key: 'national_id_mismatch' | 'shared_sim' | 'shahkar_mismatch' } | null;
/**
* B4 — identity submission. Collects the national id (10-digit + checksum), a national-ID card image,
* and a liveness selfie, then runs the automated civil-registry KYC + the chained Shahkar match. The
* card/selfie are **local captures** feeding the automated check (identity stores no document server-side),
* so `DocumentUpload` runs in local mode here. The auto-query note is honest — this check is performed.
* The shared-SIM Shahkar failure surfaces as a clear, non-accusatory message; a national-ID mismatch on
* its own step.
*/
export default function IdentitySubmitPage() {
const t = useTranslations('verification');
const locale = useLocale();
const router = useRouter();
const { enqueueSnackbar } = useSnackbar();
const submitIdentity = useSubmitIdentity();
const [nationalId, setNationalId] = useState('');
const [idError, setIdError] = useState(false);
const [cardCaptured, setCardCaptured] = useState(false);
const [selfieCaptured, setSelfieCaptured] = useState(false);
const [submitError, setSubmitError] = useState<SubmitError>(null);
// Local capture: the card/selfie feed the automated KYC (no stored document) — resolve immediately.
const captureLocally = async (file: File) => ({ name: file.name });
const canSubmit = isValidNationalId(nationalId) && selfieCaptured && !submitIdentity.isPending;
const handleSubmit = () => {
const idValid = isValidNationalId(nationalId);
setIdError(!idValid);
setSubmitError(null);
if (!idValid || !selfieCaptured) return;
submitIdentity.mutate(
{ nationalId, livenessCaptured: selfieCaptured },
{
onSuccess: (result: SubmitIdentityResult) => {
if (result.identity.stepStatus === 'failed') {
setSubmitError({ key: 'national_id_mismatch' });
return;
}
if (result.shahkar?.stepStatus === 'failed') {
setSubmitError({ key: result.shahkar.failureReason === 'shared_sim' ? 'shared_sim' : 'shahkar_mismatch' });
return;
}
enqueueSnackbar(t('identity_submitted'), { variant: 'success' });
router.push(`/${locale}${ROUTES.NURSE_VERIFICATION}`);
},
},
);
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 560 }}>
<Box>
<Typography variant="h5" component="h1">
{t('identity_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('identity_subtitle')}
</Typography>
</Box>
<Box sx={{ overflowX: 'auto' }}>
<StepperHeader
steps={[t('journey_identity'), t('journey_credentials'), t('journey_review')]}
activeStep={0}
/>
</Box>
<TextField
label={t('national_id_label')}
value={nationalId}
onChange={(event) => {
setNationalId(toEnglishDigits(event.target.value).replace(/\D/g, '').slice(0, NATIONAL_ID_LENGTH));
if (idError) setIdError(false);
}}
error={idError}
helperText={idError ? t('national_id_invalid') : t('national_id_hint')}
slotProps={{ htmlInput: { dir: 'ltr', inputMode: 'numeric', style: { textAlign: 'start', letterSpacing: 2 } } }}
fullWidth
/>
<DocumentUpload
label={t('card_label')}
hint={t('card_hint')}
accept={ACCEPTED_IMAGE_TYPES}
capture="environment"
onUpload={captureLocally}
onUploaded={() => setCardCaptured(true)}
/>
<DocumentUpload
label={t('selfie_label')}
hint={t('selfie_hint')}
accept={ACCEPTED_IMAGE_TYPES}
capture="user"
onUpload={captureLocally}
onUploaded={() => setSelfieCaptured(true)}
/>
<Paper
elevation={0}
sx={{ p: 1.5, borderRadius: 2, bgcolor: 'var(--bal-primary-soft)', display: 'flex', gap: 1, alignItems: 'center' }}
>
<AppIcon icon="info" size={18} color="var(--bal-primary)" />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('auto_registry_note')}
</Typography>
</Paper>
{submitError ? (
<AppAlert severity={submitError.key === 'shared_sim' ? 'warning' : 'error'} variant="outlined">
{t(`error_${submitError.key}`)}
</AppAlert>
) : null}
{!cardCaptured ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('card_recommended')}
</Typography>
) : null}
<Stack direction="row" sx={{ gap: 1 }}>
<AppButton
color="primary"
variant="contained"
startIcon="identity"
onClick={handleSubmit}
disabled={!canSubmit}
sx={{ m: 0 }}
>
{submitIdentity.isPending ? t('identity_submitting') : t('identity_submit')}
</AppButton>
<AppButton variant="text" color="primary" to={`/${locale}${ROUTES.NURSE_VERIFICATION}`} sx={{ m: 0 }}>
{t('back_to_checklist')}
</AppButton>
</Stack>
</Box>
);
}
@@ -1,8 +1,237 @@
import { getTranslations } from 'next-intl/server';
import { PlaceholderScreen } from '@/components';
'use client';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { useQueryClient } from '@tanstack/react-query';
import { Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { AppAlert, AppButton, AppIcon } from '@/components';
import { ROUTES } from '@/constants';
import { useStartVerification, useVerificationStatus } from '@/services/verification';
import { verificationKeys } from '@/services/verification/keys';
import { USE_VERIFICATION_MOCK } from '@/services/verification/constants';
import { __mockApproveAll, __mockRejectStep } from '@/services/verification/apis/mockApi';
import VerificationChecklist from './VerificationChecklist';
import { nextActionRoute, nextActionableIndex } from './verificationSteps';
export default async function NurseVerificationPage() {
const t = await getTranslations('nav');
const tShell = await getTranslations('shell');
return <PlaceholderScreen icon="verification" title={t('verification')} description={tShell('placeholder_body')} />;
/**
* B3 — the verification status hub. The canonical view of the single cached `VerificationStatus` query
* (B6 is a focused second view of the same data, never a separate fetch). Renders the loading skeleton,
* error, the `not_started` start-CTA, the in-progress checklist + a single "continue" CTA that routes to
* the next actionable step, and the terminal `approved` state (link to publish). The mock-only admin
* simulation lets a human observe the verified flip while the b6 admin queue is deferred (f15).
*/
export default function NurseVerificationPage() {
const t = useTranslations('verification');
const locale = useLocale();
const router = useRouter();
const queryClient = useQueryClient();
const { data: status, isLoading, isError, refetch } = useVerificationStatus();
const startVerification = useStartVerification();
const go = (route: string) => router.push(`/${locale}${route}`);
const handleContinue = () => {
if (!status || status.status === 'not_started') {
startVerification.mutate(undefined, {
onSuccess: () => go(ROUTES.NURSE_VERIFICATION_IDENTITY),
});
return;
}
const route = nextActionRoute(status);
if (route) go(route);
};
const refreshStatus = () => queryClient.invalidateQueries({ queryKey: verificationKeys.status() });
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 620 }}>
<Box>
<Typography variant="h5" component="h1">
{t('title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('subtitle')}
</Typography>
</Box>
{isLoading ? (
<Stack sx={{ gap: 1.5 }}>
<Skeleton variant="rounded" height={72} sx={{ borderRadius: 2 }} />
{[0, 1, 2, 3].map((key) => (
<Skeleton key={key} variant="rounded" height={64} sx={{ borderRadius: 2 }} />
))}
</Stack>
) : isError ? (
<AppAlert
severity="error"
action={
<AppButton variant="text" color="inherit" onClick={() => refetch()} sx={{ m: 0 }}>
{t('retry')}
</AppButton>
}
>
{t('load_error')}
</AppAlert>
) : !status || status.status === 'not_started' ? (
<NotStarted onStart={handleContinue} pending={startVerification.isPending} />
) : status.status === 'approved' ? (
<Approved onPublish={() => go(ROUTES.NURSE_SERVICES)} />
) : (
<>
<VerificationChecklist status={status} />
<BlockingSummary hasBlocking={status.blockingSteps.length > 0} />
<ContinueCta status={status} onContinue={handleContinue} />
</>
)}
{/* Dev-only: stand in for the deferred (f15) admin review queue so a human can watch the flip. */}
{USE_VERIFICATION_MOCK && status && status.status !== 'not_started' ? (
<MockAdminControls
onApprove={() => {
__mockApproveAll();
refreshStatus();
}}
onReject={() => {
__mockRejectStep('moh_competency_license', 'blurry_scan');
refreshStatus();
}}
/>
) : null}
</Box>
);
}
function NotStarted({ onStart, pending }: { onStart: () => void; pending: boolean }) {
const t = useTranslations('verification');
return (
<Paper
elevation={0}
sx={{
p: 4,
textAlign: 'center',
border: '1px dashed',
borderColor: 'divider',
borderRadius: 2,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 1.5,
}}
>
<AppIcon icon="verification" size={44} color="var(--bal-primary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('start_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', maxWidth: 440 }}>
{t('start_body')}
</Typography>
<AppButton
color="primary"
variant="contained"
startIcon="verification"
onClick={onStart}
disabled={pending}
sx={{ mt: 1 }}
>
{pending ? t('starting') : t('start_cta')}
</AppButton>
</Paper>
);
}
function Approved({ onPublish }: { onPublish: () => void }) {
const t = useTranslations('verification');
return (
<Paper
elevation={0}
sx={{
p: 3,
borderRadius: 2,
border: '1px solid',
borderColor: 'divider',
borderInlineStartWidth: 4,
borderInlineStartColor: 'var(--bal-success)',
display: 'flex',
flexDirection: 'column',
gap: 1.5,
}}
>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
<AppIcon icon="verified" size={28} color="var(--bal-success)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('approved_title')}
</Typography>
</Stack>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('approved_body')}
</Typography>
<AppButton
color="primary"
variant="contained"
startIcon="publish"
onClick={onPublish}
sx={{ m: 0, alignSelf: 'flex-start' }}
>
{t('approved_cta')}
</AppButton>
</Paper>
);
}
function BlockingSummary({ hasBlocking }: { hasBlocking: boolean }) {
const t = useTranslations('verification');
if (!hasBlocking) return null;
return (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('blocking_summary')}
</Typography>
);
}
function ContinueCta({
status,
onContinue,
}: {
status: Parameters<typeof nextActionRoute>[0];
onContinue: () => void;
}) {
const t = useTranslations('verification');
const index = nextActionableIndex(status);
const route = nextActionRoute(status);
if (route == null) return null;
return (
<AppButton color="primary" variant="contained" onClick={onContinue} sx={{ m: 0, alignSelf: 'flex-start' }}>
{index != null ? t('continue_step', { n: index }) : t('continue')}
</AppButton>
);
}
function MockAdminControls({ onApprove, onReject }: { onApprove: () => void; onReject: () => void }) {
const t = useTranslations('verification');
return (
<Paper
elevation={0}
sx={{
p: 2,
borderRadius: 2,
border: '1px dashed',
borderColor: 'divider',
display: 'flex',
flexDirection: 'column',
gap: 1,
}}
>
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 700 }}>
{t('mock_admin_title')}
</Typography>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
<AppButton variant="outlined" color="primary" onClick={onApprove} sx={{ m: 0 }}>
{t('mock_admin_approve')}
</AppButton>
<AppButton variant="outlined" color="error" onClick={onReject} sx={{ m: 0 }}>
{t('mock_admin_reject')}
</AppButton>
</Stack>
</Paper>
);
}
@@ -0,0 +1,92 @@
'use client';
import { useLocale, useTranslations } from 'next-intl';
import { Box, Paper, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, AppLoading, StatusChip, StepperHeader } from '@/components';
import { ROUTES } from '@/constants';
import { useVerificationStatus } from '@/services/verification';
import { displaySteps, stepLabelKey, stepStatusChip } from '../verificationSteps';
/**
* B6 — under review. A focused view of the **same cached `VerificationStatus`** B3 reads (one query, two
* views — never a second fetch). Shows the waiting message + the 2448h expectation + a condensed
* mini-checklist (reusing the shared `StatusChip`) of what is passed vs in-review vs pending. "مشاهده
* وضعیت" returns to the canonical B3 hub.
*/
export default function UnderReviewPage() {
const t = useTranslations('verification');
const locale = useLocale();
const { data: status, isLoading } = useVerificationStatus();
if (isLoading) return <AppLoading />;
const steps = displaySteps(status);
const isApproved = status?.status === 'approved';
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 560 }}>
<Box sx={{ overflowX: 'auto' }}>
<StepperHeader steps={[t('journey_identity'), t('journey_credentials'), t('journey_review')]} activeStep={2} />
</Box>
<Paper
elevation={0}
sx={{
p: 3,
borderRadius: 2,
border: '1px solid',
borderColor: 'divider',
borderInlineStartWidth: 4,
borderInlineStartColor: isApproved ? 'var(--bal-success)' : 'var(--bal-warning)',
display: 'flex',
flexDirection: 'column',
gap: 1,
}}
>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
<AppIcon icon={isApproved ? 'verified' : 'pending'} size={28} color={isApproved ? 'var(--bal-success)' : 'var(--bal-warning)'} />
<Typography variant="h6" component="h1">
{isApproved ? t('review_approved_title') : t('review_title')}
</Typography>
</Stack>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{isApproved ? t('review_approved_body') : t('review_body')}
</Typography>
{!isApproved ? (
<Typography variant="body2" sx={{ fontWeight: 600, color: 'var(--bal-warning)' }}>
{t('review_eta')}
</Typography>
) : null}
</Paper>
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('review_summary_title')}
</Typography>
{steps.map((step) => {
const chip = stepStatusChip(step.status);
const label = t.has(stepLabelKey(step.code)) ? t(stepLabelKey(step.code)) : step.displayName;
return (
<Stack
key={step.code}
direction="row"
sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between', py: 0.5 }}
>
<Typography variant="body2">{label}</Typography>
<StatusChip status={chip.kind} label={t(chip.labelKey)} sx={{ flexShrink: 0 }} />
</Stack>
);
})}
</Stack>
<AppButton
color="primary"
variant="contained"
startIcon="verification"
to={`/${locale}${ROUTES.NURSE_VERIFICATION}`}
sx={{ m: 0, alignSelf: 'flex-start' }}
>
{t('review_view_status')}
</AppButton>
</Box>
);
}
@@ -0,0 +1,117 @@
import type { StatusKind } from '@/components';
import { ROUTES } from '@/constants';
import type { VerificationStatus, VerificationStep, VerificationStepStatus } from '@/services/verification/types';
/**
* Screen helpers shared across the verification subtree (B3/B4/B5/B6). Keeps the rendering **data-driven**:
* the screens iterate `steps[]` and map each `code`/`status` to a label + chip via these helpers — a new
* step type in the response renders without a code change. Labels are i18n keys off the `code`/`status`,
* never derived from the enum string (honesty + localisation rule).
*/
/**
* A display-only "mobile verified" step prepended to the checklist. It is **not** a server step — it is
* satisfied at login (f1 phone-OTP), so it always renders `passed` and never blocks. Id 0 keeps it out
* of the server-id space.
*/
export const MOBILE_STEP: VerificationStep = {
id: 0,
code: 'mobile_verified',
displayName: 'mobile_verified',
status: 'passed',
isAutomated: true,
expiresAt: null,
failureReason: null,
};
/** The full ordered checklist as shown: the synthetic mobile step, then the server's seeded steps. */
export function displaySteps(status: VerificationStatus | undefined): VerificationStep[] {
return status ? [MOBILE_STEP, ...status.steps] : [MOBILE_STEP];
}
/** "X از Y" meter counts — X = passed steps, Y = total (all seeded steps are required). */
export function progressCounts(status: VerificationStatus | undefined): { passed: number; total: number } {
const steps = displaySteps(status);
return { passed: steps.filter((step) => step.status === 'passed').length, total: steps.length };
}
/** The i18n label key for a step, keyed off its stable `code`. */
export function stepLabelKey(code: string): string {
return `step_${code}`;
}
/** The i18n one-line description key for a step (what it verifies / why it is manual vs automatic). */
export function stepDescriptionKey(code: string): string {
return `step_${code}_desc`;
}
interface StepChip {
kind: StatusKind;
labelKey: string;
}
/**
* The chip kind + label key for a per-step status. Encodes the wireframe legend: green = passed,
* amber = pending/in-review/expired, grey = not-started/next, red = failed. Expired is amber but keeps
* its own label so copy stays honest ("expired", not "pending").
*/
export function stepStatusChip(status: VerificationStepStatus): StepChip {
switch (status) {
case 'passed':
return { kind: 'verified', labelKey: 'status_passed' };
case 'in_review':
return { kind: 'pending', labelKey: 'status_in_review' };
case 'pending':
return { kind: 'pending', labelKey: 'status_pending' };
case 'failed':
return { kind: 'rejected', labelKey: 'status_failed' };
case 'expired':
return { kind: 'pending', labelKey: 'status_expired' };
case 'not_started':
default:
return { kind: 'neutral', labelKey: 'status_next' };
}
}
/** Which submission screen owns a given step code (drives the row CTA + the "continue" router). */
export function routeForStep(code: string): string | null {
switch (code) {
case 'identity_kyc':
case 'shahkar_match':
return ROUTES.NURSE_VERIFICATION_IDENTITY;
case 'moh_competency_license':
case 'ino_membership':
case 'criminal_record':
return ROUTES.NURSE_VERIFICATION_CREDENTIALS;
case 'bank_account_verification':
return ROUTES.NURSE_BANK;
default:
return null;
}
}
/** A step the nurse can act on now — not passed, not waiting on an admin (`in_review`). */
function isActionable(step: VerificationStep): boolean {
return step.status !== 'passed' && step.status !== 'in_review' && routeForStep(step.code) !== null;
}
/**
* The route the B3 "continue" CTA targets: the first actionable step's screen (in checklist order); if
* everything left is waiting on admin (`in_review`), the under-review screen; `null` when approved (the
* publish CTA takes over).
*/
export function nextActionRoute(status: VerificationStatus | undefined): string | null {
if (!status || status.status === 'approved') return null;
const next = status.steps.find(isActionable);
if (next) return routeForStep(next.code);
if (status.steps.some((step) => step.status === 'in_review')) return ROUTES.NURSE_VERIFICATION_REVIEW;
return null;
}
/** The 1-based index of the next actionable step (for the "continue to step N" CTA label). */
export function nextActionableIndex(status: VerificationStatus | undefined): number | null {
if (!status) return null;
const steps = displaySteps(status);
const index = steps.findIndex(isActionable);
return index === -1 ? null : index + 1;
}
@@ -0,0 +1,156 @@
'use client';
import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import { Checkbox, FormControlLabel, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material';
import { AppButton, AppIcon, VisitNoteCard } from '@/components';
import { formatShamsiDate } from '@/utils';
import { useBookingDetail } from '@/services/bookings';
import { isBookingConfirmedOrBeyond } from '@/services/bookings/types';
import { useRecordAccess, usePatientCareRecord, usePatientHistory, useCreateVisitNote } from '@/services/patientRecords';
import { VISIT_NOTE_MAX_LENGTH } from '@/services/patientRecords/constants';
import type { TaskResult } from '@/services/patientRecords/types';
/**
* E3 (نمای پرستار) — the nurse visit-note authoring, mounted **below** the f8 EVV banner on the nurse booking
* detail. **Append-only:** the nurse ticks today's task checklist and writes a free-text note, then submits
* ONE appended note. It exposes **no** medication/routine/task editing and never wires `updateCareRecord` —
* append-only is a hard boundary. The nurse can read prior history for continuity (patient-scoped, so it
* persists across nurse changes). The composer hides when the nurse lacks append access.
*/
export default function NurseVisitNotesPanel({ bookingId }: { bookingId: number }) {
const t = useTranslations('records');
const tc = useTranslations('common');
const locale = useLocale();
const { enqueueSnackbar } = useSnackbar();
const booking = useBookingDetail(bookingId, 'nurse');
const status = booking.data?.status;
const patientId = booking.data?.patientId ?? -1;
const engaged = status ? isBookingConfirmedOrBeyond(status) : false;
const access = useRecordAccess(patientId, { enabled: engaged && patientId > 0 });
const canAppend = access.data?.canAppendNote ?? false;
const canView = access.data?.canView ?? false;
const record = usePatientCareRecord(patientId, { enabled: engaged && patientId > 0 && canAppend });
const history = usePatientHistory(patientId, 1, { enabled: engaged && patientId > 0 && canView });
const createNote = useCreateVisitNote(patientId);
const [note, setNote] = useState('');
const [checked, setChecked] = useState<Record<string, boolean>>({});
if (!booking.data || !engaged) return null;
const tasks = record.data?.tasks ?? [];
const submit = () => {
if (!note.trim()) {
enqueueSnackbar(t('note_required'), { variant: 'error' });
return;
}
const taskResults: TaskResult[] = tasks.map((task) => ({ label: task.label, done: Boolean(checked[task.id]) }));
createNote.mutate(
{ bookingId, body: note.trim(), taskResults },
{
onSuccess: () => {
enqueueSnackbar(t('note_saved'), { variant: 'success' });
setNote('');
setChecked({});
},
onError: () => enqueueSnackbar(t('note_error'), { variant: 'error' }),
},
);
};
const historyItems = history.data?.items ?? [];
return (
<Stack sx={{ gap: 2, maxWidth: 640, mx: 'auto', width: '100%' }}>
{canAppend ? (
<Paper
elevation={0}
sx={{ p: 2.5, borderRadius: 2, border: '1px solid', borderColor: 'divider', borderTop: '3px solid var(--bal-secondary)' }}
>
<Stack sx={{ gap: 2 }}>
<Stack direction="row" sx={{ alignItems: 'center', gap: 1 }}>
<AppIcon icon="notes" size={20} color="var(--bal-secondary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('notes_title')}
</Typography>
</Stack>
{record.isLoading || tasks.length > 0 ? (
<Stack sx={{ gap: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('tasks_checklist_title')}
</Typography>
{record.isLoading ? (
<Skeleton variant="rounded" height={80} />
) : (
tasks.map((task) => (
<FormControlLabel
key={task.id}
control={
<Checkbox
checked={Boolean(checked[task.id])}
onChange={(e) => setChecked((c) => ({ ...c, [task.id]: e.target.checked }))}
/>
}
label={task.label}
/>
))
)}
</Stack>
) : null}
<TextField
label={t('note_label')}
placeholder={t('note_placeholder')}
value={note}
onChange={(e) => setNote(e.target.value.slice(0, VISIT_NOTE_MAX_LENGTH))}
multiline
minRows={3}
fullWidth
/>
<AppButton
variant="contained"
color="secondary"
startIcon="notes"
onClick={submit}
disabled={createNote.isPending || !note.trim()}
sx={{ m: 0, alignSelf: 'flex-end' }}
>
{createNote.isPending ? tc('saving') : t('note_submit')}
</AppButton>
</Stack>
</Paper>
) : null}
{canView ? (
<Stack sx={{ gap: 1.5 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('continuity_title')}
</Typography>
{history.isLoading ? (
<Skeleton variant="rounded" height={96} />
) : historyItems.length === 0 ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('history_empty')}
</Typography>
) : (
historyItems.map((visitNote) => (
<VisitNoteCard
key={visitNote.id}
note={visitNote}
dateLabel={formatShamsiDate(visitNote.recordedAt, locale)}
authorFallback={t('author_fallback')}
/>
))
)}
</Stack>
) : null}
</Stack>
);
}
@@ -0,0 +1,27 @@
'use client';
import { useParams } from 'next/navigation';
import { Stack } from '@mui/material';
import { BookingDetailView } from '@/components/booking';
import { BookingSupportEntry } from '@/components/messaging';
import NurseVisitNotesPanel from './NurseVisitNotesPanel';
/**
* Nurse booking detail (`/nurse/visits/{id}`) — the both-roles view in the **nurse** shell, where the
* assigned nurse gets the per-session EVV check-in/out controls and the gated care-instructions card
* (two-stage disclosure). Below the EVV surface, the f13 append-only visit-note panel lets the nurse tick
* today's task checklist, write a note, and read the patient's continuity history. Reached from the ویزیت
* امروز day surface.
*/
export default function NurseBookingDetailPage() {
const params = useParams<{ id: string }>();
const id = Number(params.id);
const bookingId = Number.isInteger(id) && id > 0 ? id : -1;
return (
<Stack sx={{ gap: 3 }}>
<BookingDetailView bookingId={bookingId} viewerRole="nurse" />
{bookingId > 0 && <BookingSupportEntry bookingId={bookingId} role="nurse" />}
{bookingId > 0 && <NurseVisitNotesPanel bookingId={bookingId} />}
</Stack>
);
}
@@ -1,8 +1,98 @@
import { getTranslations } from 'next-intl/server';
import { PlaceholderScreen } from '@/components';
'use client';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon } from '@/components';
import { SessionCard, useEvvController } from '@/components/booking';
import { ROUTES } from '@/constants';
import { useSessionEvv, useTodaySessions } from '@/services/bookings';
import type { BookingSessionListItemDto } from '@/services/bookings/types';
export default async function NurseVisitsPage() {
const t = await getTranslations('nav');
const tShell = await getTranslations('shell');
return <PlaceholderScreen icon="visits" title={t('visits')} description={tShell('placeholder_body')} />;
/**
* Nurse ویزیت امروز (E3 top) — the day's operational surface. Lists today's sessions from
* `useTodaySessions`; each renders the shared `SessionCard` with the per-session EVV check-in/out control
* (driven by one `useEvvController`) and the advisory EVV banner once checked in. A GPS mismatch is
* advisory, never a block. Each card also deep-links to the full booking detail (`/nurse/visits/{id}`),
* where the gated care instructions live. Visit-note authoring + task checklist are deferred to f13.
*/
export default function NurseVisitsPage() {
const t = useTranslations('booking');
const { data, isLoading } = useTodaySessions();
const evv = useEvvController();
const items = data?.items ?? [];
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 640 }}>
<Box>
<Typography variant="h5" component="h1">
{t('evv_visits_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('evv_visits_subtitle')}
</Typography>
</Box>
{isLoading ? (
<Stack sx={{ gap: 2 }}>
{[0, 1].map((key) => (
<Skeleton key={key} variant="rounded" height={150} />
))}
</Stack>
) : items.length === 0 ? (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
<AppIcon icon="visits" size={40} color="var(--bal-text-secondary)" />
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 1 }}>
{t('evv_no_visits')}
</Typography>
</Paper>
) : (
<Stack sx={{ gap: 2 }}>
{items.map((item) => (
<TodayVisitCard key={item.sessionId} item={item} evv={evv} />
))}
</Stack>
)}
</Box>
);
}
function TodayVisitCard({ item, evv }: { item: BookingSessionListItemDto; evv: ReturnType<typeof useEvvController> }) {
const t = useTranslations('booking');
const locale = useLocale();
const router = useRouter();
// Fetch the EVV detail only once the session has activity, so the banner has the server check-in time.
const hasEvvActivity = item.evvStatus !== 'pending';
const { data: evvDetail } = useSessionEvv(item.sessionId, { enabled: hasEvvActivity });
return (
<Stack sx={{ gap: 0.75 }}>
<SessionCard
title={item.patientName}
sessionIndex={item.sessionIndex}
scheduledDate={item.scheduledDate}
scheduledTimeStart={item.scheduledTimeStart}
scheduledTimeEnd={item.scheduledTimeEnd}
status={item.status}
evvStatus={item.evvStatus}
checkInAt={evvDetail?.checkInAt ?? null}
checkOutAt={evvDetail?.checkOutAt ?? null}
checkInAddressMatch={evvDetail?.checkInAddressMatch ?? null}
showEvvControls
evvPending={evv.busySessionId === item.sessionId}
acquiringLocation={evv.acquiringSessionId === item.sessionId}
onCheckIn={() => evv.checkIn({ sessionId: item.sessionId, bookingId: item.bookingId })}
onCheckOut={() => evv.checkOut({ sessionId: item.sessionId, bookingId: item.bookingId })}
/>
<AppButton
variant="text"
color="primary"
endIcon="bookings"
onClick={() => router.push(`/${locale}${ROUTES.NURSE_VISITS}/${item.bookingId}`)}
sx={{ m: 0, alignSelf: 'flex-start' }}
>
{t('view_booking')}
</AppButton>
</Stack>
);
}
@@ -0,0 +1,105 @@
'use client';
import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Box, Chip, MenuItem, Skeleton, Stack, TextField } from '@mui/material';
import { AdminDataTable, AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager } from '@/components/admin';
import type { AdminTableColumn } from '@/components/admin';
import { formatShamsiDate } from '@/utils';
import type { SponsoredBooking } 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.
*/
const BOOKING_STATUS_OPTIONS = ['pending_payment', 'confirmed', 'in_progress', 'completed', 'disputed', 'closed', 'cancelled'] as const;
/**
* 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.
*/
export default function PartnerBookingsPage() {
const t = useTranslations('partner');
const ta = useTranslations('admin');
const locale = useLocale();
const [status, setStatus] = useState<string>('');
const [page, setPage] = useState(1);
const filters = { status: status || undefined };
const bookings = useMySponsoredBookings(filters, page);
const items = bookings.data?.items ?? [];
const pageCount = Math.max(1, Math.ceil((bookings.data?.total ?? 0) / PARTNER_PAGE_SIZE));
const columns: AdminTableColumn<SponsoredBooking>[] = [
{ key: 'id', header: t('bookings_col_id'), render: (b) => `#${b.bookingId}` },
{ key: 'patient', header: t('bookings_col_patient'), render: (b) => b.patientName },
{ key: 'date', header: t('bookings_col_date'), render: (b) => formatShamsiDate(b.scheduledDate, locale) },
{
key: 'status',
header: t('bookings_col_status'),
render: (b) => <Chip size="small" variant="outlined" label={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>
}
/>
{bookings.isLoading ? (
<Stack sx={{ gap: 2 }}>
{[0, 1, 2].map((k) => (
<Skeleton key={k} variant="rounded" height={56} />
))}
</Stack>
) : bookings.isError ? (
<AdminErrorState message={ta('error_generic')} retryLabel={ta('retry')} onRetry={() => bookings.refetch()} />
) : items.length === 0 ? (
<AdminEmptyState icon="bookings" title={t('bookings_empty')} />
) : (
<AdminDataTable
columns={columns}
rows={items}
getRowKey={(b) => b.bookingId}
ariaLabel={t('bookings_title')}
/>
)}
<AdminPager
page={page}
pageCount={pageCount}
onPrev={() => setPage((p) => Math.max(1, p - 1))}
onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
prevLabel={ta('prev_page')}
nextLabel={ta('next_page')}
indicator={ta('page_indicator', { page })}
/>
</Box>
);
}
@@ -0,0 +1,12 @@
'use client';
import type { ReactNode } from 'react';
import { PartnerLayout } from '@/layout';
/*
* Partner-center portal route group (/partner/…) — a separate authz scope from /admin (f15). A center
* admin sees only their own center; tenancy is server-enforced and each portal page resolves the caller's
* own center via `useMyPartnerCenter` (a 403/404 renders the access-denied state).
*/
export default function PartnerRouteLayout({ children }: { children: ReactNode }) {
return <PartnerLayout>{children}</PartnerLayout>;
}
@@ -0,0 +1,63 @@
'use client';
import { useTranslations } from 'next-intl';
import { Box, Skeleton, Stack } from '@mui/material';
import { AdminDataTable, AdminEmptyState, AdminErrorState, AdminPageHeader } from '@/components/admin';
import type { AdminTableColumn } from '@/components/admin';
import { StatusChip } from '@/components';
import type { SponsoredNurse } from '@/services/partnerCenter/types';
import { useMySponsoredNurses } from '@/services/partnerCenter';
/**
* Partner portal — sponsored-nurses roster (f15). The read-only list of nurses the signed-in center
* sponsors (portal scope; server-enforced tenancy). Two columns: name + a verification `StatusChip`. Not
* paginated (the portal roster is a bounded set), so no pager.
*/
export default function PartnerNursesPage() {
const t = useTranslations('partner');
const ta = useTranslations('admin');
const nurses = useMySponsoredNurses();
const items = nurses.data ?? [];
const columns: AdminTableColumn<SponsoredNurse>[] = [
{
key: 'name',
header: t('nurses_col_name'),
render: (n) => n.name,
},
{
key: 'verified',
header: t('nurses_col_verified'),
render: (n) => (
<StatusChip
status={n.isVerified ? 'verified' : 'neutral'}
label={n.isVerified ? ta('center_state_verified') : ta('center_state_pending_verification')}
/>
),
},
];
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader title={t('nurses_title')} />
{nurses.isLoading ? (
<Stack sx={{ gap: 2 }}>
{[0, 1, 2].map((k) => (
<Skeleton key={k} variant="rounded" height={56} />
))}
</Stack>
) : nurses.isError ? (
<AdminErrorState message={ta('error_generic')} retryLabel={ta('retry')} onRetry={() => nurses.refetch()} />
) : items.length === 0 ? (
<AdminEmptyState icon="patients" title={t('nurses_empty')} />
) : (
<AdminDataTable
columns={columns}
rows={items}
getRowKey={(n) => n.nurseProfileId}
ariaLabel={t('nurses_title')}
/>
)}
</Box>
);
}
@@ -0,0 +1,130 @@
'use client';
import { useTranslations } from 'next-intl';
import { Alert, Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { AdminEmptyState, AdminPageHeader } from '@/components/admin';
import { StatusChip } from '@/components';
import type { CenterOnboardingState, PartnerCenter } from '@/services/partnerCenter/types';
import { useMyPartnerCenter } from '@/services/partnerCenter';
/**
* Partner portal home (f15) — the signed-in center admin's **own** center at a glance (a separate authz
* scope from /admin; tenancy is server-enforced). `useMyPartnerCenter` doubles as the access gate: a
* 403/404 (non-owner / no center) surfaces the non-leaking access-denied state, never any center data.
* On success it shows the onboarding banner (draft/pending/suspended), the license block, the
* merchant-of-record indicator, and the masked settlement IBAN. Read-only.
*/
export default function PartnerHomePage() {
const t = useTranslations('partner');
const center = useMyPartnerCenter();
if (center.isLoading) {
return (
<Stack sx={{ gap: 3 }}>
<Skeleton variant="rounded" height={72} />
<Skeleton variant="rounded" height={64} />
<Skeleton variant="rounded" height={220} />
</Stack>
);
}
// The center query is the portal's access gate — a 403/404 means the caller owns no center.
if (center.isError || !center.data) {
return <AdminEmptyState icon="lock" title={t('access_denied')} />;
}
const c = center.data;
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader
title={t('home_title')}
subtitle={t('home_subtitle')}
actions={
<StatusChip
status={c.isMerchantOfRecord ? 'active' : 'neutral'}
label={c.isMerchantOfRecord ? t('is_mor_yes') : t('is_mor_no')}
/>
}
/>
<OnboardingBanner state={c.onboardingState} />
<LicenseBlock center={c} />
</Box>
);
}
/**
* Onboarding/verification banner keyed off `onboardingState`. `verified` shows a subtle chip instead of a
* banner; every other state shows an MUI `Alert` (draft/suspended → warning, pending → info).
*/
function OnboardingBanner({ state }: { state: CenterOnboardingState }) {
const t = useTranslations('partner');
const ta = useTranslations('admin');
if (state === 'verified') {
return (
<Box>
<StatusChip status="verified" label={ta('center_state_verified')} />
</Box>
);
}
const banner: Record<Exclude<CenterOnboardingState, 'verified'>, { severity: 'warning' | 'info'; key: string }> = {
draft: { severity: 'warning', key: 'state_banner_draft' },
pending_verification: { severity: 'info', key: 'state_banner_pending' },
suspended: { severity: 'warning', key: 'state_banner_suspended' },
};
const { severity, key } = banner[state];
return (
<Alert severity={severity} sx={{ borderRadius: 2 }}>
{t(key)}
</Alert>
);
}
/** License details + merchant-of-record settlement IBAN (masked last-4). Nulls render as an em dash. */
function LicenseBlock({ center }: { center: PartnerCenter }) {
const t = useTranslations('partner');
return (
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 800, mb: 2 }}>
{t('license_title')}
</Typography>
<Stack sx={{ gap: 1.5 }}>
<DetailRow label={t('permit')} value={center.mohEstablishmentPermitNo} />
<DetailRow label={t('tech_director')} value={center.technicalDirectorLicenseNo} />
<DetailRow label={t('enamad')} value={center.enamadCode} />
<DetailRow label={t('legal_type')} value={center.legalEntityType} />
{center.isMerchantOfRecord ? (
<DetailRow label={t('settlement_iban')} value={center.settlementIbanMasked} ltr />
) : null}
</Stack>
</Paper>
);
}
/** One label → value row. `ltr` forces LTR display for latin/numeric values (IBAN) inside an RTL page. */
function DetailRow({ label, value, ltr }: { label: string; value: string | null; ltr?: boolean }) {
return (
<Stack
direction="row"
sx={{ gap: 2, alignItems: 'baseline', justifyContent: 'space-between', flexWrap: 'wrap' }}
>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{label}
</Typography>
{ltr ? (
<Typography component="span" dir="ltr" variant="body2" sx={{ fontWeight: 600, fontFamily: 'monospace' }}>
{value ?? '—'}
</Typography>
) : (
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{value ?? '—'}
</Typography>
)}
</Stack>
);
}
@@ -0,0 +1,109 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { Alert, Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
import {
AdminEmptyState,
AdminErrorState,
AdminPageHeader,
AdminPager,
PartnerSettlementRow,
} from '@/components/admin';
import { PARTNER_PAGE_SIZE } from '@/services/partnerCenter/constants';
import { useMyPartnerCenter, useMySettlement } from '@/services/partnerCenter';
/**
* 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.
*/
export default function PartnerSettlementPage() {
const t = useTranslations('partner');
const ta = useTranslations('admin');
const center = useMyPartnerCenter();
const [page, setPage] = useState(1);
// Called unconditionally (rules of hooks); only rendered for a merchant-of-record center.
const settlement = useMySettlement(page);
if (center.isLoading) {
return (
<Stack sx={{ gap: 3 }}>
<Skeleton variant="rounded" height={72} />
<Skeleton variant="rounded" height={200} />
</Stack>
);
}
if (center.isError || !center.data) {
return <AdminErrorState message={ta('error_generic')} retryLabel={ta('retry')} onRetry={() => center.refetch()} />;
}
const c = center.data;
if (!c.isMerchantOfRecord) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader title={t('settlement_title')} />
<Alert severity="info" sx={{ borderRadius: 2 }}>
{t('settlement_not_mor')}
</Alert>
</Box>
);
}
const invoices = settlement.data?.items ?? [];
const pageCount = Math.max(1, Math.ceil((settlement.data?.total ?? 0) / PARTNER_PAGE_SIZE));
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader title={t('settlement_title')} />
<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' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('settlement_iban')}
</Typography>
<Typography component="span" dir="ltr" variant="body2" sx={{ fontWeight: 600, fontFamily: 'monospace' }}>
{c.settlementIbanMasked ?? '—'}
</Typography>
</Stack>
</Paper>
{settlement.isLoading ? (
<Stack sx={{ gap: 2 }}>
{[0, 1].map((k) => (
<Skeleton key={k} variant="rounded" height={200} />
))}
</Stack>
) : settlement.isError ? (
<AdminErrorState message={ta('error_generic')} retryLabel={ta('retry')} onRetry={() => settlement.refetch()} />
) : invoices.length === 0 ? (
<AdminEmptyState icon="payment" title={t('settlement_empty')} />
) : (
<Stack sx={{ gap: 2 }}>
{invoices.map((inv) => (
<PartnerSettlementRow
key={inv.id}
invoice={inv}
onDownloadPdf={(i) => {
if (i.pdfUrl) window.open(i.pdfUrl, '_blank', 'noopener');
}}
/>
))}
</Stack>
)}
<AdminPager
page={page}
pageCount={pageCount}
onPrev={() => setPage((p) => Math.max(1, p - 1))}
onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
prevLabel={ta('prev_page')}
nextLabel={ta('next_page')}
indicator={ta('page_indicator', { page })}
/>
</Box>
);
}
@@ -0,0 +1,81 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ThemeProvider } from '../../theme';
// next-intl mocked to echo keys (+ params) and locale = en so the money util groups with ASCII digits.
jest.mock('next-intl', () => ({
useTranslations: () => (key: string, params?: Record<string, unknown>) =>
params ? `${key}:${JSON.stringify(params)}` : key,
useLocale: () => 'en',
}));
import BnplPlanCard from './BnplPlanCard';
import type { BnplPlanOption } from '@/services/bnpl/types';
const FEE_PLAN: BnplPlanOption = {
planId: 'digipay_6m',
termMonths: 6,
installmentCount: 6,
feePercent: 0.04,
downPaymentPercent: 0.2,
monthlyAmountIrr: '4040000', // 404,000 Toman
downPaymentIrr: '4660000',
totalIrr: '23300000',
};
const INTEREST_FREE_PLAN: BnplPlanOption = {
planId: 'snapppay_4',
termMonths: null,
installmentCount: 4,
feePercent: 0,
downPaymentPercent: 0,
monthlyAmountIrr: '5825000',
downPaymentIrr: '0',
totalIrr: '23300000',
};
function renderCard(props: Partial<React.ComponentProps<typeof BnplPlanCard>> = {}) {
const onSelect = props.onSelect ?? jest.fn();
render(
<ThemeProvider>
<BnplPlanCard plan={FEE_PLAN} selected={false} onSelect={onSelect} {...props} />
</ThemeProvider>,
);
return { onSelect };
}
describe('<BnplPlanCard/> component', () => {
it('renders the served monthly amount as grouped Toman (never derived)', () => {
renderCard();
expect(screen.getByText(/404,000/)).toBeInTheDocument();
expect(screen.getByText('monthly')).toBeInTheDocument();
});
it('shows the fee sub-label for a fee-bearing plan and the down-payment indicator', () => {
renderCard();
// t('plan_fee', { percent: 4 }) — feePercent 0.04 → 4%
expect(screen.getByText('plan_fee:{"percent":4}')).toBeInTheDocument();
expect(screen.getByText('down_payment_percent:{"percent":20}')).toBeInTheDocument();
expect(screen.getByRole('progressbar')).toBeInTheDocument();
});
it('shows "interest-free" and no down-payment bar for a 0-fee, 0-down plan', () => {
renderCard({ plan: INTEREST_FREE_PLAN });
expect(screen.getByText('plan_interest_free')).toBeInTheDocument();
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
});
it('marks the selected card via aria-pressed + data-selected', () => {
renderCard({ selected: true });
const card = screen.getByRole('button');
expect(card).toHaveAttribute('aria-pressed', 'true');
expect(card).toHaveAttribute('data-selected', 'true');
});
it('calls onSelect with the planId when clicked', async () => {
const user = userEvent.setup();
const { onSelect } = renderCard();
await user.click(screen.getByRole('button'));
expect(onSelect).toHaveBeenCalledWith('digipay_6m');
});
});
@@ -0,0 +1,104 @@
'use client';
import { FunctionComponent } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Box, ButtonBase, LinearProgress, Stack, Typography } from '@mui/material';
import { formatIrrToToman } from '@/utils';
import type { BnplPlanOption } from '@/services/bnpl/types';
export interface BnplPlanCardProps {
plan: BnplPlanOption;
selected: boolean;
onSelect: (planId: string) => void;
}
/** A whole-percent from a 0..1 fraction (display only — never money math). */
const asPercent = (fraction: number): number => Math.round(fraction * 100);
/**
* D2 installment-plan option card (terracotta financial accent). Shows the plan term / installment count,
* its interest-free / fee sub-label, the **served** monthly amount (Toman via the money util — never
* computed here), and a down-payment indicator. Single-select: the selected card gets the terracotta
* `--bal-secondary` border + soft tint. Labels are i18n keys off the served fields; money is display-only.
* @component BnplPlanCard
*/
const BnplPlanCard: FunctionComponent<BnplPlanCardProps> = ({ plan, selected, onSelect }) => {
const t = useTranslations('bnpl');
const tc = useTranslations('common');
const locale = useLocale();
const termLabel =
plan.termMonths != null
? t('plan_term_months', { months: plan.termMonths })
: t('plan_installments', { count: plan.installmentCount });
const feeLabel = plan.feePercent > 0 ? t('plan_fee', { percent: asPercent(plan.feePercent) }) : t('plan_interest_free');
const hasDownPayment = plan.downPaymentPercent > 0;
return (
<ButtonBase
data-plan-id={plan.planId}
data-selected={selected}
aria-pressed={selected}
onClick={() => onSelect(plan.planId)}
sx={{
display: 'block',
width: '100%',
textAlign: 'start',
borderRadius: 2.5,
p: 1.75,
border: '1px solid',
borderColor: selected ? 'var(--bal-secondary)' : 'divider',
borderWidth: selected ? 2 : 1,
backgroundColor: selected ? 'var(--bal-secondary-soft)' : 'transparent',
}}
>
<Stack sx={{ gap: hasDownPayment ? 1.25 : 0 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 2 }}>
<Stack sx={{ gap: 0.25 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{termLabel}
</Typography>
<Typography
variant="caption"
sx={{ color: plan.feePercent > 0 ? 'var(--bal-secondary-dark)' : 'text.secondary' }}
>
{feeLabel}
</Typography>
</Stack>
<Stack sx={{ alignItems: 'flex-end', gap: 0.25 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: 'var(--bal-secondary)' }}>
{formatIrrToToman(plan.monthlyAmountIrr, locale)} {tc('currency_toman')}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('monthly')}
</Typography>
</Stack>
</Stack>
{hasDownPayment ? (
<Box>
<Stack direction="row" sx={{ justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('down_payment')}
</Typography>
<Typography variant="caption" sx={{ fontWeight: 700 }}>
{t('down_payment_percent', { percent: asPercent(plan.downPaymentPercent) })}
</Typography>
</Stack>
<LinearProgress
variant="determinate"
value={asPercent(plan.downPaymentPercent)}
sx={{
height: 6,
borderRadius: 3,
backgroundColor: 'var(--bal-divider)',
'& .MuiLinearProgress-bar': { backgroundColor: 'var(--bal-secondary)' },
}}
/>
</Box>
) : null}
</Stack>
</ButtonBase>
);
};
export default BnplPlanCard;
@@ -0,0 +1,4 @@
import BnplPlanCard from './BnplPlanCard';
export { BnplPlanCard as default, BnplPlanCard };
export type { BnplPlanCardProps } from './BnplPlanCard';
@@ -0,0 +1,58 @@
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
// next-intl mocked to echo keys; locale = en so money/date format with ASCII digits we can assert on.
jest.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
useLocale: () => 'en',
}));
import BookingRequestSummaryCard, { BookingRequestSummaryCardProps } from './BookingRequestSummaryCard';
const BASE: BookingRequestSummaryCardProps = {
nurseName: 'Maryam Rezaei',
nurseAvatarUrl: null,
nurseRating: 4.8,
patientName: 'Haj Mousavi',
variantLabel: 'Elderly care — day shift',
variantPrice: '2800000',
variantPriceUnit: 'per_hour',
addressLabel: 'Home · Tehran · Saadat Abad',
requestedDate: '2026-08-01',
requestedTimeStart: '09:00:00',
requestedTimeEnd: '13:00:00',
};
function renderCard(props: Partial<BookingRequestSummaryCardProps> = {}) {
return render(
<ThemeProvider>
<BookingRequestSummaryCard {...BASE} {...props} />
</ThemeProvider>,
);
}
describe('<BookingRequestSummaryCard/> component', () => {
it('renders the nurse, patient, service and address', () => {
renderCard();
expect(screen.getByText('Maryam Rezaei')).toBeInTheDocument();
expect(screen.getByText('Haj Mousavi')).toBeInTheDocument();
expect(screen.getByText('Elderly care — day shift')).toBeInTheDocument();
expect(screen.getByText('Home · Tehran · Saadat Abad')).toBeInTheDocument();
});
it('prices the service in grouped Toman when a variantPrice is present', () => {
renderCard();
// 2,800,000 IRR = 280,000 Toman.
expect(screen.getByText(/280,000/)).toBeInTheDocument();
});
it('hides the price line when variantPrice is null (real-path contract gap)', () => {
renderCard({ variantPrice: null });
expect(screen.queryByText(/280,000/)).not.toBeInTheDocument();
});
it('hides the rating row when no rating is supplied', () => {
renderCard({ nurseRating: null });
expect(screen.queryByText('4.8')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,143 @@
'use client';
import { FunctionComponent, ReactNode } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Avatar, Divider, Paper, Stack, Typography } from '@mui/material';
import AppIcon from '@/components/common/AppIcon';
import PriceDisplay from '@/components/PriceDisplay';
import { formatShamsiDate } from '@/utils';
import type { PriceUnit } from '@/services/catalog/types';
export interface BookingRequestSummaryCardProps {
nurseName: string;
nurseAvatarUrl?: string | null;
/** Average rating; `null`/omitted hides the rating row (e.g. the nurse-side view). */
nurseRating?: number | null;
patientName: string;
variantLabel: string;
/** IRR digit-string; when `null` the price line is hidden (contract gap REQ-013 on the real path). */
variantPrice?: string | null;
variantPriceUnit: PriceUnit;
/** Localised "title · city · district" label, computed by the caller (locale-aware region names). */
addressLabel: string;
/** ISO date `YYYY-MM-DD`. */
requestedDate: string;
/** `HH:mm:ss`. */
requestedTimeStart: string;
requestedTimeEnd: string;
}
/**
* The engagement summary shared by the customer's awaiting screen (C5), the nurse request detail, and
* (later) the f8 booking detail: nurse identity + rating, patient, priced service, address label, and the
* requested date/time (Shamsi). Presentational — it reads only the `booking` caption keys and formats
* money/dates through the shared utils; every value is supplied by the caller. Kept at the shared level so
* f8 reuses it rather than re-deriving the layout.
* @component BookingRequestSummaryCard
*/
const BookingRequestSummaryCard: FunctionComponent<BookingRequestSummaryCardProps> = ({
nurseName,
nurseAvatarUrl,
nurseRating,
patientName,
variantLabel,
variantPrice,
variantPriceUnit,
addressLabel,
requestedDate,
requestedTimeStart,
requestedTimeEnd,
}) => {
const t = useTranslations('booking');
const locale = useLocale();
const name = nurseName.trim() || t('unnamed_nurse');
const startDate = new Date(`${requestedDate}T${requestedTimeStart}`);
const endDate = new Date(`${requestedDate}T${requestedTimeEnd}`);
const timeFmt = new Intl.DateTimeFormat(locale === 'fa' ? 'fa-IR' : 'en-US', {
hour: '2-digit',
minute: '2-digit',
});
const whenLabel = `${formatShamsiDate(startDate, locale)} · ${timeFmt.format(startDate)} ${timeFmt.format(endDate)}`;
const ratingLabel =
nurseRating != null
? new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US', {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
}).format(nurseRating)
: null;
return (
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 2 }}>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
<Avatar
src={nurseAvatarUrl ?? undefined}
sx={{ width: 56, height: 56, bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 700 }}
>
{name.charAt(0)}
</Avatar>
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{name}
</Typography>
{ratingLabel ? (
<Stack direction="row" sx={{ gap: 0.5, alignItems: 'center' }}>
<AppIcon icon="star" size={16} color="var(--bal-warning)" />
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{ratingLabel}
</Typography>
</Stack>
) : null}
</Stack>
</Stack>
<Divider />
<Stack sx={{ gap: 1.5 }}>
<SummaryRow caption={t('summary_patient')}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{patientName}
</Typography>
</SummaryRow>
<SummaryRow caption={t('summary_service')}>
<Stack sx={{ gap: 0.25, alignItems: 'flex-end' }}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{variantLabel}
</Typography>
{variantPrice ? (
<PriceDisplay price={variantPrice} priceUnit={variantPriceUnit} align="start" />
) : null}
</Stack>
</SummaryRow>
<SummaryRow caption={t('summary_address')}>
<Typography variant="body2" sx={{ fontWeight: 600, textAlign: 'end' }}>
{addressLabel}
</Typography>
</SummaryRow>
<SummaryRow caption={t('summary_when')}>
<Typography variant="body2" sx={{ fontWeight: 600, textAlign: 'end' }}>
{whenLabel}
</Typography>
</SummaryRow>
</Stack>
</Stack>
</Paper>
);
};
function SummaryRow({ caption, children }: { caption: string; children: ReactNode }) {
return (
<Stack direction="row" sx={{ gap: 2, justifyContent: 'space-between', alignItems: 'flex-start' }}>
<Typography variant="body2" sx={{ color: 'text.secondary', flexShrink: 0 }}>
{caption}
</Typography>
{children}
</Stack>
);
}
export default BookingRequestSummaryCard;
@@ -0,0 +1,2 @@
export { default } from './BookingRequestSummaryCard';
export type { BookingRequestSummaryCardProps } from './BookingRequestSummaryCard';
@@ -0,0 +1,80 @@
import { FunctionComponent } from 'react';
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
import { formatIrrToToman } from '@/utils';
import type { CancellationPolicyPreview } from '@/services/refunds/types';
jest.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
useLocale: () => 'en',
}));
import CancellationPolicyDisclosure, {
CancellationPolicyDisclosureProps,
} from './CancellationPolicyDisclosure';
const ComponentToTest: FunctionComponent<CancellationPolicyDisclosureProps> = (props) => (
<ThemeProvider>
<CancellationPolicyDisclosure {...props} />
</ThemeProvider>
);
// Multi-session, free tier: two un-started (refundable) + one completed (locked). refund + fee = total.
const PREVIEW: CancellationPolicyPreview = {
bookingId: 5003,
cancellable: true,
cancellationPolicyCode: 'free_24h',
refundPercentageApplied: 1,
feePercentage: 0,
refundAmountIrr: '17600000',
feeAmountIrr: '0',
refundableAmountIrr: '17600000',
platformFeeRefundedIrr: '2400000',
nursePayoutRefundedIrr: '15200000',
appliesTo: 'remaining_sessions',
leadTimeLabel: 'gt_24h',
refundChannel: 'psp_card',
expectedCustomerRefundEta: null,
refundableSessionIds: [70032, 70033],
sessions: [
{ bookingSessionId: 70031, sessionIndex: 1, scheduledDate: '2026-08-01', refundable: false, reasonCode: 'completed' },
{ bookingSessionId: 70032, sessionIndex: 2, scheduledDate: '2026-08-05', refundable: true, reasonCode: 'un_started' },
{ bookingSessionId: 70033, sessionIndex: 3, scheduledDate: '2026-08-07', refundable: true, reasonCode: 'un_started' },
],
};
describe('<CancellationPolicyDisclosure/> component', () => {
it('renders the tier label off the policy code (never the raw code) and the fee %', () => {
const { container } = render(<ComponentToTest preview={PREVIEW} />);
expect(container.querySelector('[data-policy-code="free_24h"]')).toBeInTheDocument();
expect(screen.getByText('policy_free_24h')).toBeInTheDocument();
expect(screen.getByText('fee_percent')).toBeInTheDocument();
});
it('renders a refund-vs-fee breakdown that reconciles to the refundable total', () => {
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
const { container } = render(<ComponentToTest preview={PREVIEW} />);
expect(container.querySelector('[data-row="refund"]')).toBeInTheDocument();
expect(container.querySelector('[data-row="fee"]')).toBeInTheDocument();
expect(container.querySelector('[data-row="total"]')).toBeInTheDocument();
// Free tier: the full refundable amount is returned, so it appears on both the refund row and the total.
expect(screen.getAllByText(new RegExp(formatIrrToToman('17600000', 'en'))).length).toBeGreaterThanOrEqual(1);
// No reconciliation warning — refund + fee equals the refundable total.
expect(errorSpy).not.toHaveBeenCalled();
errorSpy.mockRestore();
});
it('marks un-started sessions refundable and completed sessions locked', () => {
const { container } = render(<ComponentToTest preview={PREVIEW} />);
expect(container.querySelector('[data-session-id="70031"][data-refundable="false"]')).toBeInTheDocument();
expect(container.querySelector('[data-session-id="70032"][data-refundable="true"]')).toBeInTheDocument();
expect(screen.getByText('reason_completed')).toBeInTheDocument();
expect(screen.getAllByText('session_refundable').length).toBe(2);
});
it('shows the admin-approval explainer and the channel ETA banner', () => {
render(<ComponentToTest preview={PREVIEW} />);
expect(screen.getByTestId('admin-approval-explainer')).toBeInTheDocument();
expect(screen.getByTestId('refund-eta-banner')).toBeInTheDocument();
});
});
@@ -0,0 +1,113 @@
'use client';
import { FunctionComponent } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Divider, Paper, Stack, Typography } from '@mui/material';
import PriceBreakdown from '@/components/PriceBreakdown';
import StatusChip from '@/components/StatusChip';
import AppAlert from '@/components/common/AppAlert';
import AppIcon from '@/components/common/AppIcon';
import RefundEtaBanner from '@/components/RefundEtaBanner';
import { formatShamsiDate } from '@/utils';
import type { CancellationPolicyPreview } from '@/services/refunds/types';
export interface CancellationPolicyDisclosureProps {
preview: CancellationPolicyPreview;
}
/** Percent (integer) from a 01 fraction — a small display number, never money, so JS math is safe. */
function toPercent(fraction: number): number {
return Math.round(fraction * 100);
}
/**
* The pre-confirm cancellation disclosure (f10) — the whole point of the cancel screen: the applicable
* policy tier (label off the `cancellation_policy_code` i18n key, never the raw code), the **refund % + fee
* %**, the concrete refund-vs-fee split (reusing `PriceBreakdown`, which reconciles to the rial), the
* multi-session refundable/locked breakdown, the admin-approved reality, and a per-channel ETA preview
* (BNPL surfaces the ~710-day window honestly). Display-only — the reason field, acknowledgement and
* confirm live in the page; shared so the confirm dialog and any future per-session cancel reuse it.
* @component CancellationPolicyDisclosure
*/
const CancellationPolicyDisclosure: FunctionComponent<CancellationPolicyDisclosureProps> = ({ preview }) => {
const t = useTranslations('refunds');
const locale = useLocale();
const refundPercent = toPercent(preview.refundPercentageApplied);
const feePercent = toPercent(preview.feePercentage);
const isMultiSession = preview.sessions.length > 1;
return (
<Stack sx={{ gap: 2.5 }} data-testid="cancellation-disclosure" data-policy-code={preview.cancellationPolicyCode}>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1 }}>
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>
{t(`policy_${preview.cancellationPolicyCode}`)}
</Typography>
<StatusChip
status={refundPercent > 0 ? 'active' : 'rejected'}
label={t('refund_percent', { percent: refundPercent })}
/>
</Stack>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t(`lead_${preview.leadTimeLabel}`)}
</Typography>
<Typography variant="body2" sx={{ color: 'var(--bal-secondary)', fontWeight: 600 }}>
{t('fee_percent', { percent: feePercent })}
</Typography>
</Stack>
</Paper>
<PriceBreakdown
rows={[
{ key: 'refund', label: t('row_refund'), amountIrr: preview.refundAmountIrr },
{ key: 'fee', label: t('row_fee'), amountIrr: preview.feeAmountIrr },
]}
totalLabel={t('row_refundable_total')}
totalAmountIrr={preview.refundableAmountIrr}
/>
{isMultiSession && (
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1.25 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('sessions_title')}
</Typography>
<Divider />
{preview.sessions.map((session) => (
<Stack
key={session.bookingSessionId}
data-session-id={session.bookingSessionId}
data-refundable={session.refundable}
direction="row"
sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 2, opacity: session.refundable ? 1 : 0.6 }}
>
<Typography variant="body2">
{`${session.sessionIndex}. ${formatShamsiDate(session.scheduledDate, locale)}`}
</Typography>
<StatusChip
status={session.refundable ? 'active' : 'neutral'}
label={session.refundable ? t('session_refundable') : t(`reason_${session.reasonCode}`)}
/>
</Stack>
))}
</Stack>
</Paper>
)}
<RefundEtaBanner channel={preview.refundChannel} eta={preview.expectedCustomerRefundEta} />
<AppAlert
severity="info"
variant="outlined"
icon={<AppIcon icon="info" size={20} color="var(--bal-info)" />}
data-testid="admin-approval-explainer"
sx={{ marginY: 0 }}
>
{t('admin_approval_explainer')}
</AppAlert>
</Stack>
);
};
export default CancellationPolicyDisclosure;
@@ -0,0 +1,2 @@
export { default } from './CancellationPolicyDisclosure';
export type { CancellationPolicyDisclosureProps } from './CancellationPolicyDisclosure';
@@ -0,0 +1,56 @@
import { act, render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
// next-intl mocked so the locale is `en` and the countdown formats with ASCII digits we can assert on.
jest.mock('next-intl', () => ({ useLocale: () => 'en' }));
import CountdownTimer, { CountdownTimerProps } from './CountdownTimer';
const deadlineInSeconds = (seconds: number) => new Date(Date.now() + seconds * 1000).toISOString();
function renderTimer(props: CountdownTimerProps) {
return render(
<ThemeProvider>
<CountdownTimer {...props} />
</ThemeProvider>,
);
}
describe('<CountdownTimer/> component', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-07-09T10:00:00.000Z'));
});
afterEach(() => {
jest.useRealTimers();
});
it('renders MM:SS remaining for a sub-hour deadline', () => {
renderTimer({ deadlineIso: deadlineInSeconds(90), elapsedText: 'time up' });
expect(screen.getByText('01:30')).toBeInTheDocument();
});
it('renders HH:MM:SS for a multi-hour deadline', () => {
renderTimer({ deadlineIso: deadlineInSeconds(3661), elapsedText: 'time up' });
expect(screen.getByText('01:01:01')).toBeInTheDocument();
});
it('ticks down each second without lifting state to the page', () => {
renderTimer({ deadlineIso: deadlineInSeconds(90), elapsedText: 'time up' });
act(() => {
jest.advanceTimersByTime(1000);
});
expect(screen.getByText('01:29')).toBeInTheDocument();
});
it('shows the elapsed text and fires onElapsed exactly once at zero', () => {
const onElapsed = jest.fn();
renderTimer({ deadlineIso: deadlineInSeconds(2), elapsedText: 'time up', onElapsed });
act(() => {
jest.advanceTimersByTime(3000);
});
expect(screen.getByText('time up')).toBeInTheDocument();
expect(onElapsed).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,113 @@
'use client';
import { FunctionComponent, useEffect, useMemo, useRef, useState } from 'react';
import { useLocale } from 'next-intl';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import AppIcon from '@/components/common/AppIcon';
export interface CountdownTimerProps {
/**
* The **server-supplied** absolute UTC instant to count down to (e.g. `nurseResponseDeadlineAt`). The
* client only renders the difference against `Date.now()` it never computes or recomputes a deadline.
*/
deadlineIso: string;
/** Optional label above the digits (already translated by the caller). */
label?: string;
/** Shown once the deadline has passed — the poll then resolves the real terminal status. */
elapsedText: string;
/** Terracotta-accented urgency styling for the money-adjacent payment window. */
urgent?: boolean;
/** Fired once when the countdown reaches zero (e.g. to nudge a refetch). */
onElapsed?: () => void;
}
const MS_PER_SECOND = 1000;
const SECONDS_PER_MINUTE = 60;
const SECONDS_PER_HOUR = 3600;
/**
* A pure presentational countdown to a server-frozen deadline. It owns its own one-second tick so only
* this component re-renders each second never the page around it (the summary card / form stay put).
* The ticking stops the moment the deadline passes; crossing zero shows `elapsedText` and fires
* `onElapsed` once. Digits render in the active locale (Persian for `fa`), forced LTR so the `HH:MM:SS`
* order is correct under RTL.
* @component CountdownTimer
*/
const CountdownTimer: FunctionComponent<CountdownTimerProps> = ({
deadlineIso,
label,
elapsedText,
urgent = false,
onElapsed,
}) => {
const locale = useLocale();
const [now, setNow] = useState(() => Date.now());
const target = useMemo(() => Date.parse(deadlineIso), [deadlineIso]);
const remainingMs = Number.isFinite(target) ? Math.max(0, target - now) : 0;
const elapsed = remainingMs <= 0;
// Recreated only when `elapsed` flips (once) — not every tick, since `elapsed` stays false until zero.
useEffect(() => {
if (elapsed) return undefined;
const interval = setInterval(() => setNow(Date.now()), MS_PER_SECOND);
return () => clearInterval(interval);
}, [elapsed]);
const firedRef = useRef(false);
useEffect(() => {
if (elapsed && !firedRef.current) {
firedRef.current = true;
onElapsed?.();
} else if (!elapsed) {
firedRef.current = false;
}
}, [elapsed, onElapsed]);
const accent = urgent ? 'var(--bal-secondary)' : 'var(--bal-primary)';
if (elapsed) {
return (
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center', color: 'text.secondary' }}>
<AppIcon icon="pending" size={18} color="var(--bal-text-secondary)" />
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{elapsedText}
</Typography>
</Stack>
);
}
const totalSeconds = Math.floor(remainingMs / MS_PER_SECOND);
const hours = Math.floor(totalSeconds / SECONDS_PER_HOUR);
const minutes = Math.floor((totalSeconds % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE);
const seconds = totalSeconds % SECONDS_PER_MINUTE;
const pad = new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US', {
minimumIntegerDigits: 2,
useGrouping: false,
});
const clock = [hours > 0 ? pad.format(hours) : null, pad.format(minutes), pad.format(seconds)]
.filter((part) => part !== null)
.join(':');
return (
<Stack sx={{ gap: 0.25, alignItems: 'center' }}>
{label ? (
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 600 }}>
{label}
</Typography>
) : null}
<Stack direction="row" sx={{ gap: 0.5, alignItems: 'center' }}>
<AppIcon icon="pending" size={20} color={accent} />
<Typography
component="span"
dir="ltr"
sx={{ fontWeight: 700, fontSize: '1.5rem', fontVariantNumeric: 'tabular-nums', color: accent }}
>
{clock}
</Typography>
</Stack>
</Stack>
);
};
export default CountdownTimer;
@@ -0,0 +1,2 @@
export { default } from './CountdownTimer';
export type { CountdownTimerProps } from './CountdownTimer';
@@ -0,0 +1,79 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
// next-intl echoes keys (and ignores interpolation params) so we assert on the state keys.
jest.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
}));
import DocumentUpload from './DocumentUpload';
function renderUpload(props: Partial<React.ComponentProps<typeof DocumentUpload>> = {}) {
const onUpload = props.onUpload ?? jest.fn().mockResolvedValue({ name: 'license.pdf' });
const utils = render(
<ThemeProvider>
<DocumentUpload label="License" onUpload={onUpload} {...props} />
</ThemeProvider>,
);
const input = utils.container.querySelector('input[type="file"]') as HTMLInputElement;
return { ...utils, input, onUpload };
}
function selectFile(input: HTMLInputElement, file: File) {
Object.defineProperty(input, 'files', { value: [file], configurable: true });
fireEvent.change(input);
}
describe('<DocumentUpload/> component', () => {
it('renders the field label and the idle choose zone', () => {
const { container } = renderUpload();
expect(screen.getByText('License')).toBeInTheDocument();
expect(container.querySelector('[data-upload-state="idle"]')).toBeInTheDocument();
});
it('rejects a disallowed file type before uploading', () => {
const { input, onUpload, container } = renderUpload();
selectFile(input, new File(['x'], 'note.txt', { type: 'text/plain' }));
expect(onUpload).not.toHaveBeenCalled();
expect(container.querySelector('[data-upload-state="error"]')).toBeInTheDocument();
expect(screen.getByText('upload_bad_type')).toBeInTheDocument();
});
it('rejects a file over the size cap before uploading', () => {
const { input, onUpload } = renderUpload({ maxSizeBytes: 10 });
const big = new File([new Uint8Array(50)], 'card.png', { type: 'image/png' });
selectFile(input, big);
expect(onUpload).not.toHaveBeenCalled();
expect(screen.getByText('upload_too_large')).toBeInTheDocument();
});
it('uploads a valid file and shows the success state', async () => {
const onUploaded = jest.fn();
const { input, onUpload } = renderUpload({ onUploaded });
selectFile(input, new File(['%PDF'], 'license.pdf', { type: 'application/pdf' }));
await waitFor(() => expect(screen.getByText('upload_success')).toBeInTheDocument());
expect(onUpload).toHaveBeenCalledTimes(1);
expect(onUploaded).toHaveBeenCalledWith({ name: 'license.pdf' });
});
it('shows a retryable error when the upload fails', async () => {
const onUpload = jest.fn().mockRejectedValue(new Error('boom'));
const { input, container } = renderUpload({ onUpload });
selectFile(input, new File(['%PDF'], 'license.pdf', { type: 'application/pdf' }));
await waitFor(() => expect(container.querySelector('[data-upload-state="error"]')).toBeInTheDocument());
expect(screen.getByText('upload_retry')).toBeInTheDocument();
});
it('renders the rejected state with its reason and a re-upload affordance', () => {
const { container } = renderUpload({ rejected: true, rejectionReason: 'Blurry scan' });
expect(container.querySelector('[data-upload-state="rejected"]')).toBeInTheDocument();
expect(screen.getByText('Blurry scan')).toBeInTheDocument();
expect(screen.getByText('upload_reupload')).toBeInTheDocument();
});
it('renders an already-uploaded document from server metadata', () => {
const { container } = renderUpload({ existingDoc: { name: 'prior-license.pdf' } });
expect(container.querySelector('[data-upload-state="success"]')).toBeInTheDocument();
expect(screen.getByText('prior-license.pdf')).toBeInTheDocument();
});
});
@@ -0,0 +1,313 @@
'use client';
import { ChangeEvent, FunctionComponent, useEffect, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import Box from '@mui/material/Box';
import LinearProgress from '@mui/material/LinearProgress';
import Paper from '@mui/material/Paper';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import AppButton from '../common/AppButton';
import AppIcon from '../common/AppIcon';
import { ACCEPTED_DOCUMENT_TYPES, MAX_DOCUMENT_SIZE_BYTES } from '@/services/verification/constants';
/** The stored document as the uploader displays it — a name + optional size, never bytes. */
export interface UploadedDocInfo {
name: string;
sizeBytes?: number;
}
type UploadState = 'idle' | 'uploading' | 'success' | 'error';
export interface DocumentUploadProps {
/** Field label (already translated). */
label: string;
/** Optional helper text under the label. */
hint?: string;
/** Accepted MIME types. Defaults to jpg/png/pdf (the b6 object-storage limits). */
accept?: readonly string[];
/** Max file size in bytes. Defaults to 5 MB. */
maxSizeBytes?: number;
/** Mobile camera hint: `environment` (rear — ID card) or `user` (front — selfie). */
capture?: 'user' | 'environment';
disabled?: boolean;
/**
* The async upload action receives the file + a progress reporter (0100) and resolves with the
* stored doc. For a server step this calls the verification seam; for a local capture (B4 identity) it
* validates + resolves locally without a round-trip.
*/
onUpload: (file: File, onProgress: (percent: number) => void) => Promise<UploadedDocInfo>;
/** Called after a successful upload with the resolved metadata. */
onUploaded?: (doc: UploadedDocInfo) => void;
/** A previously-uploaded document (drives the "already uploaded ✓" state from server metadata). */
existingDoc?: UploadedDocInfo | null;
/** When the step was rejected — renders the reason and a re-upload affordance (never a dead end). */
rejected?: boolean;
rejectionReason?: string;
}
/**
* Reusable document uploader for every verification document step (national-ID card, license, education
* cert, criminal record). Owns the full state machine idle validating uploading (progress %)
* success ( + file name / local image preview) error (retry) with client-side type/size validation
* before any upload and a re-upload affordance on reject. Returns the server's stored **metadata** only;
* a local image preview never becomes the source of the "uploaded" truth. Its own chrome strings come
* from the `verification` namespace; the caller passes the field `label`/`hint`.
* @component DocumentUpload
*/
const DocumentUpload: FunctionComponent<DocumentUploadProps> = ({
label,
hint,
accept = ACCEPTED_DOCUMENT_TYPES,
maxSizeBytes = MAX_DOCUMENT_SIZE_BYTES,
capture,
disabled = false,
onUpload,
onUploaded,
existingDoc = null,
rejected = false,
rejectionReason,
}) => {
const t = useTranslations('verification');
const inputRef = useRef<HTMLInputElement>(null);
const [state, setState] = useState<UploadState>('idle');
const [progress, setProgress] = useState(0);
const [errorKey, setErrorKey] = useState<string | null>(null);
const [fileName, setFileName] = useState<string | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
// A local image preview is an object URL — revoke it when it changes or the component unmounts.
useEffect(() => {
return () => {
if (previewUrl) URL.revokeObjectURL(previewUrl);
};
}, [previewUrl]);
const openPicker = () => inputRef.current?.click();
const onFileSelected = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = ''; // allow re-picking the same file after an error
if (!file) return;
if (!accept.includes(file.type)) {
setState('error');
setErrorKey('upload_bad_type');
return;
}
if (file.size > maxSizeBytes) {
setState('error');
setErrorKey('upload_too_large');
return;
}
setErrorKey(null);
setFileName(file.name);
if (file.type.startsWith('image/')) {
setPreviewUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return URL.createObjectURL(file);
});
}
setProgress(0);
setState('uploading');
try {
const doc = await onUpload(file, setProgress);
setFileName(doc.name);
setState('success');
onUploaded?.(doc);
} catch {
// 401/403/5xx are already toasted by the fetch layer; show a retryable inline error here.
setState('error');
setErrorKey('upload_error');
}
};
const megabytes = Math.round(maxSizeBytes / (1024 * 1024));
const acceptAttr = accept.join(',');
// The "already uploaded" resting state is driven by server metadata (existingDoc) or a just-completed
// upload — never by retained bytes.
const showUploaded = state === 'success' || (state === 'idle' && existingDoc != null);
const uploadedName = fileName ?? existingDoc?.name ?? '';
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{label}
</Typography>
{hint ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{hint}
</Typography>
) : null}
<input
ref={inputRef}
type="file"
accept={acceptAttr}
capture={capture}
hidden
disabled={disabled}
onChange={onFileSelected}
/>
{rejected ? (
<Paper
elevation={0}
data-upload-state="rejected"
sx={{
p: 2,
borderRadius: 2,
border: '1px solid',
borderColor: 'divider',
borderInlineStartWidth: 4,
borderInlineStartColor: 'var(--bal-error)',
display: 'flex',
flexDirection: 'column',
gap: 1,
}}
>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon="rejected" size={20} color="var(--bal-error)" />
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{t('upload_rejected')}
</Typography>
</Stack>
{rejectionReason ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{rejectionReason}
</Typography>
) : null}
<AppButton
variant="outlined"
color="primary"
startIcon="upload"
onClick={openPicker}
disabled={disabled}
sx={{ m: 0, alignSelf: 'flex-start' }}
>
{t('upload_reupload')}
</AppButton>
</Paper>
) : state === 'uploading' ? (
<Paper elevation={0} data-upload-state="uploading" sx={uploadedSx}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon="upload" size={20} color="var(--bal-primary)" />
<Typography variant="body2" sx={{ fontWeight: 600, flexGrow: 1, wordBreak: 'break-all' }}>
{fileName}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{progress}%
</Typography>
</Stack>
<LinearProgress variant="determinate" value={progress} sx={{ borderRadius: 1, height: 6 }} />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('upload_uploading')}
</Typography>
</Paper>
) : showUploaded ? (
<Paper elevation={0} data-upload-state="success" sx={uploadedSx}>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
{previewUrl ? (
<Box
component="img"
src={previewUrl}
alt=""
sx={{ width: 48, height: 48, objectFit: 'cover', borderRadius: 1.5, flexShrink: 0 }}
/>
) : (
<AppIcon icon="document" size={28} color="var(--bal-primary)" />
)}
<Stack sx={{ gap: 0.25, flexGrow: 1, minWidth: 0 }}>
<Typography variant="body2" sx={{ fontWeight: 600, wordBreak: 'break-all' }}>
{uploadedName}
</Typography>
<Stack direction="row" sx={{ gap: 0.5, alignItems: 'center' }}>
<AppIcon icon="verified" size={16} color="var(--bal-success)" />
<Typography variant="caption" sx={{ color: 'var(--bal-success)' }}>
{t('upload_success')}
</Typography>
</Stack>
</Stack>
<AppButton variant="text" color="primary" onClick={openPicker} disabled={disabled} sx={{ m: 0 }}>
{t('upload_change')}
</AppButton>
</Stack>
</Paper>
) : state === 'error' ? (
<Paper
elevation={0}
data-upload-state="error"
sx={{ ...uploadedSx, borderInlineStartWidth: 4, borderInlineStartColor: 'var(--bal-error)' }}
>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon="error" size={20} color="var(--bal-error)" />
<Typography variant="body2" sx={{ color: 'var(--bal-error)', flexGrow: 1 }}>
{t(errorKey ?? 'upload_error', { size: megabytes })}
</Typography>
</Stack>
<AppButton
variant="outlined"
color="primary"
startIcon="refresh"
onClick={openPicker}
disabled={disabled}
sx={{ m: 0, alignSelf: 'flex-start' }}
>
{t('upload_retry')}
</AppButton>
</Paper>
) : (
<Box
role="button"
tabIndex={disabled ? -1 : 0}
data-upload-state="idle"
onClick={disabled ? undefined : openPicker}
onKeyDown={(event) => {
if (!disabled && (event.key === 'Enter' || event.key === ' ')) {
event.preventDefault();
openPicker();
}
}}
sx={{
p: 3,
borderRadius: 2,
border: '1px dashed',
borderColor: 'divider',
textAlign: 'center',
cursor: disabled ? 'default' : 'pointer',
opacity: disabled ? 0.6 : 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 0.75,
transition: 'border-color 120ms',
'&:hover': disabled ? undefined : { borderColor: 'var(--bal-primary)' },
}}
>
<AppIcon icon={capture ? 'camera' : 'upload'} size={28} color="var(--bal-primary)" />
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{capture ? t('upload_capture') : t('upload_choose')}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('upload_size_hint', { size: megabytes })}
</Typography>
</Box>
)}
</Box>
);
};
// Shared card styling for the uploading / success / error states.
const uploadedSx = {
p: 2,
borderRadius: 2,
border: '1px solid',
borderColor: 'divider',
display: 'flex',
flexDirection: 'column',
gap: 1,
} as const;
export default DocumentUpload;
@@ -0,0 +1,2 @@
export { default } from './DocumentUpload';
export type { DocumentUploadProps, UploadedDocInfo } from './DocumentUpload';
@@ -0,0 +1,57 @@
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
import type { NurseEarningsSummary } from '@/services/payouts/types';
// next-intl mocked to echo keys; locale 'en' so the money util renders ASCII digits for assertions.
jest.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
useLocale: () => 'en',
}));
import EarningsBalanceHeader from './EarningsBalanceHeader';
const BASE: NurseEarningsSummary = {
pendingTotalIrr: '4250000',
eligibleTotalIrr: '3400000',
paidTotalIrr: '8500000',
clawbackOutstandingIrr: '1700000',
netPayableBalanceIrr: '5950000',
};
function renderHeader(summary: NurseEarningsSummary) {
return render(
<ThemeProvider>
<EarningsBalanceHeader summary={summary} />
</ThemeProvider>,
);
}
describe('<EarningsBalanceHeader/> component', () => {
it('renders a positive net balance as the payable state with the Toman magnitude', () => {
const { container } = renderHeader(BASE);
expect(container.querySelector('[data-balance-state="payable"]')).toBeInTheDocument();
expect(screen.getByText('balance_net_label')).toBeInTheDocument();
// 5,950,000 IRR ÷ 10 = 595,000 Toman
expect(screen.getByText('595,000')).toBeInTheDocument();
});
it('renders a negative net balance as "owed back" — magnitude only, never a bare minus', () => {
const { container } = renderHeader({ ...BASE, netPayableBalanceIrr: '-4350000' });
expect(container.querySelector('[data-balance-state="owed"]')).toBeInTheDocument();
expect(screen.getByText('balance_owed_label')).toBeInTheDocument();
// The magnitude 435,000 shows; the raw "-435,000" must not.
expect(screen.getByText('435,000')).toBeInTheDocument();
expect(screen.queryByText('-435,000')).not.toBeInTheDocument();
});
it('renders all four buckets with their formatted Toman amounts', () => {
const { container } = renderHeader(BASE);
for (const bucket of ['pending', 'eligible', 'paid', 'clawback']) {
expect(container.querySelector(`[data-bucket="${bucket}"]`)).toBeInTheDocument();
}
expect(screen.getByText('425,000')).toBeInTheDocument(); // pending 4,250,000
expect(screen.getByText('340,000')).toBeInTheDocument(); // eligible 3,400,000
expect(screen.getByText('850,000')).toBeInTheDocument(); // paid lifetime 8,500,000
expect(screen.getByText('170,000')).toBeInTheDocument(); // clawback outstanding 1,700,000
});
});
@@ -0,0 +1,122 @@
'use client';
import { FunctionComponent, useMemo } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Box, Paper, Stack, Typography } from '@mui/material';
import AppIcon from '../common/AppIcon';
import { formatIrrToToman, parseIrr } from '@/utils';
import type { NurseEarningsSummary } from '@/services/payouts/types';
export interface EarningsBalanceHeaderProps {
summary: NurseEarningsSummary;
}
/** The four roll-up buckets — each keyed to a semantic token + icon so the states read at a glance. */
const BUCKETS: ReadonlyArray<{
key: 'pending' | 'eligible' | 'paid' | 'clawback';
amountKey: keyof NurseEarningsSummary;
token: string;
icon: string;
}> = [
{ key: 'pending', amountKey: 'pendingTotalIrr', token: 'var(--bal-warning)', icon: 'pending' },
{ key: 'eligible', amountKey: 'eligibleTotalIrr', token: 'var(--bal-info)', icon: 'schedule' },
{ key: 'paid', amountKey: 'paidTotalIrr', token: 'var(--bal-success)', icon: 'verified' },
{ key: 'clawback', amountKey: 'clawbackOutstandingIrr', token: 'var(--bal-error)', icon: 'refresh' },
];
/**
* The nurse's money home header: the **net payable balance** prominently, with the four-bucket breakdown
* (pending / eligible / paid-lifetime / clawback-outstanding) beneath. The net balance is **ledger-derived
* and may be negative** when it is, this renders an explicit **"owed back"** state (a distinct error-toned
* card + the magnitude, never a bare minus sign as if it were a positive amount). Every amount is formatted
* Toman via the money util (integer-safe BigInt); this component only formats, never computes a figure.
* @component EarningsBalanceHeader
*/
const EarningsBalanceHeader: FunctionComponent<EarningsBalanceHeaderProps> = ({ summary }) => {
const t = useTranslations('payouts');
const tc = useTranslations('common');
const locale = useLocale();
const net = useMemo(() => parseIrr(summary.netPayableBalanceIrr), [summary.netPayableBalanceIrr]);
const isOwed = net < BigInt(0);
// Render the magnitude; the "owed back" framing carries the sign in words, never a bare minus.
const magnitude = isOwed ? -net : net;
const accent = isOwed ? 'var(--bal-error)' : 'var(--bal-primary)';
return (
<Stack sx={{ gap: 2 }}>
<Paper
elevation={0}
data-balance-state={isOwed ? 'owed' : 'payable'}
sx={{
p: 3,
borderRadius: 2,
border: '1px solid',
borderColor: 'divider',
borderInlineStart: '4px solid',
borderInlineStartColor: accent,
}}
>
<Stack sx={{ gap: 0.75 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon={isOwed ? 'warning' : 'wallet'} size={20} color={accent} />
<Typography variant="subtitle2" sx={{ color: 'text.secondary', fontWeight: 600 }}>
{isOwed ? t('balance_owed_label') : t('balance_net_label')}
</Typography>
</Stack>
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'baseline', flexWrap: 'wrap' }}>
<Typography component="span" sx={{ fontWeight: 800, fontSize: '2rem', color: accent }}>
{formatIrrToToman(magnitude, locale)}
</Typography>
<Typography component="span" variant="subtitle1" sx={{ color: 'text.secondary', fontWeight: 600 }}>
{tc('currency_toman')}
</Typography>
</Stack>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{isOwed ? t('balance_owed_hint') : t('balance_net_hint')}
</Typography>
</Stack>
</Paper>
<Box
sx={{
display: 'grid',
gap: 1.5,
gridTemplateColumns: { xs: 'repeat(2, 1fr)', md: 'repeat(4, 1fr)' },
}}
>
{BUCKETS.map((bucket) => (
<Paper
key={bucket.key}
elevation={0}
data-bucket={bucket.key}
sx={{
p: 2,
borderRadius: 2,
border: '1px solid',
borderColor: 'divider',
borderInlineStart: '3px solid',
borderInlineStartColor: bucket.token,
}}
>
<Stack sx={{ gap: 0.75 }}>
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center' }}>
<AppIcon icon={bucket.icon} size={16} color={bucket.token} />
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 600 }}>
{t(`bucket_${bucket.key}`)}
</Typography>
</Stack>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{formatIrrToToman(summary[bucket.amountKey], locale)}{' '}
<Typography component="span" variant="caption" sx={{ color: 'text.secondary' }}>
{tc('currency_toman')}
</Typography>
</Typography>
</Stack>
</Paper>
))}
</Box>
</Stack>
);
};
export default EarningsBalanceHeader;
@@ -0,0 +1,2 @@
export { default } from './EarningsBalanceHeader';
export type { EarningsBalanceHeaderProps } from './EarningsBalanceHeader';
@@ -0,0 +1,101 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
import type { EarningsState, NurseEarningsItem } from '@/services/payouts/types';
jest.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
useLocale: () => 'en',
}));
import EarningsRow from './EarningsRow';
function makeItem(state: EarningsState, overrides: Partial<NurseEarningsItem> = {}): NurseEarningsItem {
return {
bookingId: 5001,
patientName: 'Test Patient',
scheduledDate: '2026-06-01',
grossPriceIrr: '5000000',
balinyaarCommissionIrr: '750000',
nursePayoutAmount: '4250000',
state,
disputeWindowEndsAt: null,
payoutEligibleAt: null,
paidAt: null,
transferReference: null,
nursePayoutId: null,
batchId: null,
clawbackAppliedIrr: null,
netAmountIrr: null,
...overrides,
};
}
function renderRow(item: NurseEarningsItem, handlers?: { onViewBooking?: jest.Mock; onViewPayout?: jest.Mock }) {
const onViewBooking = handlers?.onViewBooking ?? jest.fn();
const onViewPayout = handlers?.onViewPayout ?? jest.fn();
const utils = render(
<ThemeProvider>
<EarningsRow item={item} onViewBooking={onViewBooking} onViewPayout={onViewPayout} />
</ThemeProvider>,
);
return { ...utils, onViewBooking, onViewPayout };
}
const STATE_KIND: Array<{ state: EarningsState; kind: string }> = [
{ state: 'pending', kind: 'pending' },
{ state: 'eligible', kind: 'info' },
{ state: 'paid', kind: 'verified' },
{ state: 'clawback_applied', kind: 'rejected' },
];
describe('<EarningsRow/> component', () => {
it.each(STATE_KIND)('maps the $state state to the $kind chip kind', ({ state, kind }) => {
const { container } = renderRow(makeItem(state));
expect(container.querySelector(`[data-earnings-state="${state}"]`)).toBeInTheDocument();
expect(container.querySelector(`[data-status="${kind}"]`)).toBeInTheDocument();
});
it('renders the reconciled payout as the breakdown total (gross commission = payout)', () => {
const { container } = renderRow(makeItem('eligible'));
const total = container.querySelector('[data-row="total"]');
// 4,250,000 IRR ÷ 10 = 425,000 Toman
expect(total?.textContent).toContain('425,000');
});
it('shows the pending dispute-window affordance', () => {
renderRow(makeItem('pending', { disputeWindowEndsAt: '2999-01-01T00:00:00Z' }));
expect(screen.getByText('pending_affordance')).toBeInTheDocument();
});
it('shows the clawback net-explanation block for a clawed-back earning', () => {
// Reconciling fixture: original payout 1,700,000 clawback 1,700,000 = net 0 (PriceBreakdown guard stays silent).
renderRow(
makeItem('clawback_applied', {
grossPriceIrr: '2000000',
balinyaarCommissionIrr: '300000',
nursePayoutAmount: '1700000',
clawbackAppliedIrr: '1700000',
netAmountIrr: '0',
}),
);
expect(screen.getByText('clawback_explainer')).toBeInTheDocument();
expect(screen.getByText('clawback_net')).toBeInTheDocument();
});
it('links a paid earning to its payout detail', () => {
const onViewPayout = jest.fn();
renderRow(
makeItem('paid', { paidAt: '2026-06-10T00:00:00Z', transferReference: 'PAYA-1', nursePayoutId: 9001 }),
{ onViewPayout },
);
fireEvent.click(screen.getByText('view_payout'));
expect(onViewPayout).toHaveBeenCalledWith(9001);
});
it('deep-links every row to its booking detail', () => {
const onViewBooking = jest.fn();
renderRow(makeItem('eligible', { bookingId: 5002 }), { onViewBooking });
fireEvent.click(screen.getByText('view_booking'));
expect(onViewBooking).toHaveBeenCalledWith(5002);
});
});
@@ -0,0 +1,195 @@
'use client';
import { FunctionComponent, useMemo } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Paper, Stack, Typography } from '@mui/material';
import StatusChip, { StatusKind } from '@/components/StatusChip';
import PriceBreakdown from '@/components/PriceBreakdown';
import CountdownTimer from '@/components/CountdownTimer';
import AppButton from '@/components/common/AppButton';
import AppIcon from '@/components/common/AppIcon';
import { formatShamsiDate, parseIrr } from '@/utils';
import type { EarningsState, NurseEarningsItem } from '@/services/payouts/types';
export interface EarningsRowProps {
item: NurseEarningsItem;
/** Deep-link to the f8 nurse booking detail (`/nurse/visits/{bookingId}`) — never rebuilt here. */
onViewBooking: (bookingId: number) => void;
/** Deep-link to this earning's payout detail (paid rows only). */
onViewPayout: (payoutId: number) => void;
}
/** Each earnings state maps to a distinct semantic chip so the four states read instantly (§5). */
const EARNINGS_STATE_KIND: Record<EarningsState, StatusKind> = {
pending: 'pending',
eligible: 'info',
paid: 'verified',
clawback_applied: 'rejected',
};
/**
* One earnings row a completed booking's contribution to the nurse's pay. Shows the booking reference,
* the **three-amount breakdown** framed for the nurse (`gross commission = your payout`, reconciled by
* `PriceBreakdown`), the **earnings-state chip**, and the state-specific affordance:
* - `pending` "in escrow · dispute window open" + a **display-only** countdown off `disputeWindowEndsAt`
* (the server owns eligibility this never gates anything, only renders time remaining);
* - `eligible` "cleared · awaiting the next weekly batch";
* - `paid` `paidAt` (Shamsi) + `transferReference`, links to the payout detail;
* - `clawback_applied` the net explanation (`original clawback = net`) so a lower paid total is explained.
*
* Money is display-only IRR digit-strings through the money util (no float, no client computation). Every
* row deep-links to the booking detail. Read-only: no transfer/retry/eligibility action lives here.
* @component EarningsRow
*/
const EarningsRow: FunctionComponent<EarningsRowProps> = ({ item, onViewBooking, onViewPayout }) => {
const t = useTranslations('payouts');
const locale = useLocale();
// gross + (commission) = payout — the invariant PriceBreakdown reconciles against.
const negativeCommissionIrr = useMemo(
() => String(-parseIrr(item.balinyaarCommissionIrr)),
[item.balinyaarCommissionIrr],
);
return (
<Paper
elevation={0}
data-earnings-state={item.state}
sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
>
<Stack sx={{ gap: 1.75 }}>
<Stack
direction="row"
sx={{ justifyContent: 'space-between', alignItems: 'flex-start', gap: 1.5, flexWrap: 'wrap' }}
>
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('booking_ref', { id: item.bookingId })}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{item.patientName} · {formatShamsiDate(item.scheduledDate, locale)}
</Typography>
</Stack>
<StatusChip status={EARNINGS_STATE_KIND[item.state]} label={t(`estate_${item.state}`)} />
</Stack>
<PriceBreakdown
rows={[
{ key: 'gross', label: t('amount_gross'), amountIrr: item.grossPriceIrr },
{ key: 'commission', label: t('amount_commission'), amountIrr: negativeCommissionIrr },
]}
totalLabel={t('amount_your_payout')}
totalAmountIrr={item.nursePayoutAmount}
/>
{item.state === 'clawback_applied' && item.clawbackAppliedIrr != null && item.netAmountIrr != null ? (
<Stack sx={{ gap: 0.75 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('clawback_explainer')}
</Typography>
<PriceBreakdown
rows={[
{ key: 'original', label: t('clawback_original'), amountIrr: item.nursePayoutAmount },
{ key: 'clawback', label: t('clawback_amount'), amountIrr: String(-parseIrr(item.clawbackAppliedIrr)) },
]}
totalLabel={t('clawback_net')}
totalAmountIrr={item.netAmountIrr}
/>
</Stack>
) : null}
<StateAffordance item={item} onViewPayout={onViewPayout} />
<Stack direction="row" sx={{ justifyContent: 'flex-end' }}>
<AppButton
variant="text"
color="primary"
endIcon="visits"
onClick={() => onViewBooking(item.bookingId)}
sx={{ m: 0 }}
>
{t('view_booking')}
</AppButton>
</Stack>
</Stack>
</Paper>
);
};
/** The state-specific explanation block below the money breakdown. */
const StateAffordance: FunctionComponent<{
item: NurseEarningsItem;
onViewPayout: (payoutId: number) => void;
}> = ({ item, onViewPayout }) => {
const t = useTranslations('payouts');
const locale = useLocale();
if (item.state === 'pending') {
return (
<Stack
direction="row"
sx={{ gap: 1.5, alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap' }}
>
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center' }}>
<AppIcon icon="lock" size={18} color="var(--bal-warning)" />
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('pending_affordance')}
</Typography>
</Stack>
{item.disputeWindowEndsAt ? (
<CountdownTimer
deadlineIso={item.disputeWindowEndsAt}
label={t('dispute_window_label')}
elapsedText={t('dispute_window_elapsed')}
/>
) : null}
</Stack>
);
}
if (item.state === 'eligible') {
return (
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center' }}>
<AppIcon icon="schedule" size={18} color="var(--bal-info)" />
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('eligible_affordance')}
</Typography>
</Stack>
);
}
if (item.state === 'paid') {
return (
<Stack sx={{ gap: 0.75 }}>
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center', flexWrap: 'wrap' }}>
<AppIcon icon="verified" size={18} color="var(--bal-success)" />
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{item.paidAt ? t('paid_on', { date: formatShamsiDate(item.paidAt, locale) }) : t('estate_paid')}
</Typography>
</Stack>
{item.transferReference ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }} dir="ltr">
{t('transfer_reference_label')}: {item.transferReference}
</Typography>
) : null}
{item.nursePayoutId != null ? (
<Stack direction="row" sx={{ justifyContent: 'flex-start' }}>
<AppButton
variant="text"
color="primary"
endIcon="wallet"
onClick={() => onViewPayout(item.nursePayoutId as number)}
sx={{ m: 0 }}
>
{t('view_payout')}
</AppButton>
</Stack>
) : null}
</Stack>
);
}
// clawback_applied — the net explanation block above already carries the "why"; nothing more here.
return null;
};
export default EarningsRow;
@@ -0,0 +1,2 @@
export { default } from './EarningsRow';
export type { EarningsRowProps } from './EarningsRow';
@@ -0,0 +1,39 @@
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
import faMessages from '../../../messages/fa.json';
// next-intl mocked to read the REAL fa message file, so the test pins the product-mandated escrow copy
// as it actually ships — a rewording in fa.json (or a broken key) fails here as a conscious change.
jest.mock('next-intl', () => ({
useTranslations: (namespace: string) => (key: string) => {
const messages = jest.requireActual('../../../messages/fa.json') as Record<string, Record<string, string>>;
return messages[namespace][key];
},
}));
import EscrowNotice from './EscrowNotice';
function renderNotice() {
return render(
<ThemeProvider>
<EscrowNotice />
</ThemeProvider>,
);
}
describe('<EscrowNotice/> component', () => {
it('renders the mandated escrow copy verbatim from fa.json', () => {
renderNotice();
expect(
screen.getByText('مبلغ به‌صورت امانی نزد بالین‌یار می‌ماند و پس از پایان ویزیت آزاد می‌شود'),
).toBeInTheDocument();
expect(screen.getByText(faMessages.payment.escrow_notice)).toBeInTheDocument();
});
it('renders as an info (trust) surface, never an error tone', () => {
renderNotice();
const alert = screen.getByTestId('escrow-notice');
expect(alert.className).toContain('MuiAlert-colorInfo');
expect(alert.className).not.toContain('MuiAlert-colorError');
});
});
@@ -0,0 +1,37 @@
'use client';
import { FunctionComponent } from 'react';
import { useTranslations } from 'next-intl';
import AppAlert from '@/components/common/AppAlert';
import AppIcon from '@/components/common/AppIcon';
/**
* The escrow trust callout product-mandated copy («مبلغ بهصورت امانی نزد بالینیار میماند و پس از
* پایان ویزیت آزاد میشود»), rendered **verbatim in fa** as an info/trust surface (teal `--bal-info`
* tokens, a lock never an error tone). It is *why* the family pays on-platform, so f10/f11 must reuse
* this exact component rather than re-writing the message. Self-translating (the copy is fixed), no props.
* @component EscrowNotice
*/
const EscrowNotice: FunctionComponent = () => {
const t = useTranslations('payment');
return (
<AppAlert
severity="info"
variant="outlined"
icon={<AppIcon icon="lock" size={20} color="var(--bal-primary)" />}
data-testid="escrow-notice"
sx={{
marginY: 0,
// --bal-primary, not --bal-info: the info token is an alert *background* color and is too dark
// to read as text on the dark scheme; primary is the same deep teal in light and lifts in dark.
borderColor: 'var(--bal-primary)',
color: 'var(--bal-primary)',
backgroundColor: 'var(--bal-primary-soft)',
fontWeight: 500,
}}
>
{t('escrow_notice')}
</AppAlert>
);
};
export default EscrowNotice;
@@ -0,0 +1 @@
export { default } from './EscrowNotice';
@@ -0,0 +1,65 @@
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
// next-intl mocked to echo keys (+ params); locale = en so the money util groups with ASCII digits.
jest.mock('next-intl', () => ({
useTranslations: () => (key: string, params?: Record<string, unknown>) =>
params ? `${key}:${JSON.stringify(params)}` : key,
useLocale: () => 'en',
}));
import InstallmentScheduleRow from './InstallmentScheduleRow';
import type { BnplInstallmentRow } from '@/services/bnpl/types';
const DOWN_PAYMENT: BnplInstallmentRow = {
sequence: 0,
kind: 'down_payment',
dueDate: '2026-07-10',
amountIrr: '4660000', // 466,000 Toman
};
const INSTALLMENT: BnplInstallmentRow = {
sequence: 2,
kind: 'installment',
dueDate: '2026-09-01',
amountIrr: '4040000', // 404,000 Toman
status: 'paid',
};
function renderRow(props: React.ComponentProps<typeof InstallmentScheduleRow>) {
return render(
<ThemeProvider>
<InstallmentScheduleRow {...props} />
</ThemeProvider>,
);
}
describe('<InstallmentScheduleRow/> component', () => {
it('renders the down-payment row: label, «today» due, and served amount', () => {
renderRow({ row: DOWN_PAYMENT });
expect(screen.getByText('down_payment')).toBeInTheDocument();
expect(screen.getByText('today')).toBeInTheDocument();
expect(screen.getByText(/466,000/)).toBeInTheDocument();
expect(screen.getByText('down_payment')).toBeInTheDocument();
});
it('renders an installment row with the numbered label and no status chip by default', () => {
const { container } = renderRow({ row: INSTALLMENT });
expect(screen.getByText('installment_n:{"n":2}')).toBeInTheDocument();
expect(screen.getByText(/404,000/)).toBeInTheDocument();
// showStatus defaults to false → no chip even when a status is present.
expect(container.querySelector('[data-status]')).toBeNull();
});
it('renders the provider-reported status chip when showStatus is set', () => {
const { container } = renderRow({ row: INSTALLMENT, showStatus: true });
// status 'paid' → StatusChip kind 'verified' + label key 'status_paid'.
expect(container.querySelector('[data-status="verified"]')).not.toBeNull();
expect(screen.getByText('status_paid')).toBeInTheDocument();
});
it('exposes the row kind for automation', () => {
const { container } = renderRow({ row: DOWN_PAYMENT });
expect(container.querySelector('[data-row-kind="down_payment"]')).not.toBeNull();
});
});

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