ui phase 7

This commit is contained in:
hamid
2026-07-19 11:56:59 +03:30
parent a438edeeaa
commit edc38543fd
39 changed files with 1538 additions and 208 deletions
+8 -5
View File
@@ -164,8 +164,10 @@ client/
│ │ ├── nurse/ # Nurse app (/nurse/…) — sidebar shell │ │ ├── nurse/ # Nurse app (/nurse/…) — sidebar shell
│ │ │ ├── layout.tsx # 'use client' — RoleGuard(expected=nurse) → NurseLayout │ │ │ ├── layout.tsx # 'use client' — RoleGuard(expected=nurse) → NurseLayout
│ │ │ ├── loading.tsx # → ../_chrome/SidebarShellSkeleton │ │ │ ├── loading.tsx # → ../_chrome/SidebarShellSkeleton
│ │ │ ├── page.tsx # /nurse (dashboard) — RSC; generateMetadata (nav.dashboard) inline (no split — already a Server Component) │ │ │ ├── page.tsx # /nurse (dashboard) — thin RSC (generateMetadata nav.dashboard) rendering NurseDashboardScreen.tsx
│ │ │ ├── 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) │ │ │ ├── NurseDashboardScreen.tsx # ui-phase-7 — the «امروز» operational home replacing the old PlaceholderScreen: greeting+TrustBadge, NextVisitCard (useTodaySessions), RequestsStrip (useNurseRequestInbox, the most time-critical widget — sorts above earnings), EarningsSnapshotCard (useNurseEarningsBalance, signed net + eligible), DashboardActivationSlot, NotificationsEntryRow (useUnreadCount) — every widget is a read of an already-cached query, four-state pattern throughout
│ │ │ ├── DashboardActivationSlot.tsx # ui-phase-7 — the named composition point Phase 8's activation checklist plugs into; filled for now with only the existing verification-status banner (nothing once approved)
│ │ │ ├── requests/ # /nurse/requests — f7 incoming booking-requests inbox, ui-phase-7 redesign: page.tsx = decision-first cards (service+price headline when served — REQ-050, mock-tolerant) + an urgency-tinted CountdownTimer pill (teal/amber/terracotta tiers) + three tabs (در انتظار/پاسخ‌داده merged-client-side page-1-only/منقضی, shared Pager) ↔ requests/[id]/page.tsx detail (only customerNotes + masked city/district; accept now behind a ConfirmDialog + a post-accept payment-window countdown; reject-with-reason invalidate inbox+detail)
│ │ │ ├── profile/page.tsx # /nurse/profile — B7 profile bootstrap (avatar+bio+years; unverified placeholder) │ │ │ ├── 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 + PublishGate co-located; PublishGate is the f5 verification-gated go-live) │ │ │ ├── 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) │ │ │ ├── coverage/page.tsx # /nurse/coverage — F3 coverage-area editor (whole-city/district areas, dup-blocked)
@@ -177,8 +179,8 @@ client/
│ │ │ │ ├── review/page.tsx # B6 — under-review (same status query, condensed mini-checklist) │ │ │ │ ├── review/page.tsx # B6 — under-review (same status query, condensed mini-checklist)
│ │ │ │ ├── VerificationChecklist.tsx # B3 body: meter + step rows (co-located, page-only) │ │ │ │ ├── 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) │ │ │ │ └── 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) │ │ │ ├── visits/ # /nurse/visits — f8 EVV, ui-phase-7 day-surface + detail pass: page.tsx = Shamsi «امروز، …» date anchor + ویزیت امروز today-sessions feed (per-session check-in/out via useEvvController + advisory EvvStatusBanner; 60s refetchInterval; SessionCard's EVV CTA is now the full-width hero action + a confirm step on check-out) ↔ visits/[id]/page.tsx nurse booking detail (BookingDetailView viewerRole="nurse": address card + geo: map link, an in-visit «در حال ویزیت» banner promoting check-out, 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) │ │ │ ├── earnings/ # /nurse/earnings — f12 nurse earnings (read-only), ui-phase-7 pass: page.tsx = EarningsBalanceHeader (net payable balance + 4 buckets, negative "owed back") + a «برداشت بعدی» ForecastLine (server-served only) + an accessible ButtonBase ExplainerCard (aria-expanded, registered `expand` chevron) + state-segmented EarningsRow list (deep-links to /nurse/visits/[id], shared Pager) ↔ payouts/page.tsx (PayoutHistoryRow list) → payouts/[id]/page.tsx (payout/batch reconciliation detail: money decomposition + masked IBAN + booking links); failed-payout reasons now map through `services/payouts/failureReasons.ts` (mapped label headline, raw code demoted to a secondary LTR caption)
│ │ │ ├── support/tickets/ # /nurse/support/tickets — f14 nurse "My Tickets" (same TicketInboxScreen/TicketThreadScreen, role="nurse") ↔ support/tickets/[id]/page.tsx │ │ │ ├── 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 │ │ │ └── notifications/page.tsx # /nurse/notifications — f14 notification center (role="nurse"); the nurse-shell bell deep-links here
│ │ ├── admin/ # Admin/backoffice (/admin/…) — desktop sidebar shell (f15). Every screen is role-gated via useAdminCapabilities(); the sidebar hides a console the current admin role can't act on (server still enforces). │ │ ├── admin/ # Admin/backoffice (/admin/…) — desktop sidebar shell (f15). Every screen is role-gated via useAdminCapabilities(); the sidebar hides a console the current admin role can't act on (server still enforces).
@@ -237,6 +239,7 @@ client/
│ │ ├── JalaliDateIntentPicker/ # ui-phase-5 — extracted from C1's local date-intent widget (near-day chip strip + a calendar-icon Popover entry into the full grid) so C4's real required date field reuses it too, not just C1's intent-only one; caller-owned today/tomorrow/pick-other labels (tested) │ │ ├── JalaliDateIntentPicker/ # ui-phase-5 — extracted from C1's local date-intent widget (near-day chip strip + a calendar-icon Popover entry into the full grid) so C4's real required date field reuses it too, not just C1's intent-only one; caller-owned today/tomorrow/pick-other labels (tested)
│ │ ├── StickyActionBar/ # ui-phase-4 — a `position:sticky` bottom-pinned action-bar shell for a scrolling screen's primary CTA (C1's live-count CTA, C3's booking CTA); composes with the shell's existing BottomBar safe-area padding rather than reimplementing `env(safe-area-inset-bottom)` │ │ ├── StickyActionBar/ # ui-phase-4 — a `position:sticky` bottom-pinned action-bar shell for a scrolling screen's primary CTA (C1's live-count CTA, C3's booking CTA); composes with the shell's existing BottomBar safe-area padding rather than reimplementing `env(safe-area-inset-bottom)`
│ │ ├── LocaleSwitcher/ # ui-2 fa/en toggle preserving the current route (`router.replace(pathname, {locale})` via `@/i18n/navigation`); sidebar footers, the customer profile hub, the public shell (tested) │ │ ├── LocaleSwitcher/ # ui-2 fa/en toggle preserving the current route (`router.replace(pathname, {locale})` via `@/i18n/navigation`); sidebar footers, the customer profile hub, the public shell (tested)
│ │ ├── Pager/ # ui-phase-7 — the shared prev/next "page X of Y" control (common namespace i18n) replacing the near-identical inline pagers hand-rolled per list screen (nurse inbox tabs, payout history/earnings) (tested)
│ │ └── index.tsx # barrel — keep next-intl-importing primitives (Money) below the presentational ones so the poisoning risk stays visible in review │ │ └── index.tsx # barrel — keep next-intl-importing primitives (Money) below the presentational ones so the poisoning risk stays visible in review
│ ├── PlaceholderScreen/ # Empty-state scaffold for not-yet-built screens │ ├── PlaceholderScreen/ # Empty-state scaffold for not-yet-built screens
│ ├── OtpInput/ # OTP code input (auto-advance, paste, RTL-safe) │ ├── OtpInput/ # OTP code input (auto-advance, paste, RTL-safe)
@@ -278,7 +281,7 @@ client/
│ ├── 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) │ ├── 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) │ ├── PatientHeader/ # f13 patient identity block (name + relation chip + "age · gender" meta + condition chips) extracted from PatientCard so the E1 card and the E2 record viewer share one header; tolerates null relation / empty conditions (tested)
│ ├── ProfileSummary/ # ui-2 the one identity card for chrome: avatar+name+masked phone+role label+optional TrustBadge, vertical (nurse sidebar) or `compact` horizontal chip (admin/partner TopBar); presentational — callers source data from useMe/profiles; replaces the starter UserInfo (tested) │ ├── ProfileSummary/ # ui-2 the one identity card for chrome: avatar+name+masked phone+role label+optional TrustBadge, vertical (nurse sidebar) or `compact` horizontal chip (admin/partner TopBar); presentational — callers source data from useMe/profiles; replaces the starter UserInfo (tested)
│ ├── booking/ # f8 post-payment engagement composites (import from @/components/booking). BookingDetailView (both-roles smart container; ui-phase-5 hero: next-upcoming-session headline off the embedded sessions, the frozen visit address, a nurse-identity row, a client-only `.ics` add-to-calendar download (ics.ts, no backend seam), an EVV "پرستار در محل است" presence headline while checked in, role-conditioned EVV+gated care), BookingStatusTimeline (server-truth 7-status timeline over the shared vertical StatusTimeline — the ui-phase-1 swap off StepperHeader), SessionList→SessionCard (per-session schedule/status/EVV CTA; ui-phase-5 aligned its card shell to SurfaceCard), 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 + ics.ts helpers (kept internal — not in the barrel). Each composite tested; the BookingDetailView test proves the customer never fires the care query (two-stage-disclosure gate) │ ├── booking/ # f8 post-payment engagement composites (import from @/components/booking). BookingDetailView (both-roles smart container; ui-phase-5 hero: next-upcoming-session headline off the embedded sessions, a nurse-identity row, a client-only `.ics` add-to-calendar download (ics.ts, no backend seam), an EVV "پرستار در محل است" presence headline while checked in, role-conditioned EVV+gated care; ui-phase-7 added a standalone AddressCard below the hero — a `geo:`/Neshan-web map deep-link when the frozen snapshot carries lat/lng, a quiet nurse-only fallback note otherwise (REQ-051) — and a nurse-only in-visit «در حال ویزیت» banner promoting the check-out action), BookingStatusTimeline (server-truth 7-status timeline over the shared vertical StatusTimeline — the ui-phase-1 swap off StepperHeader), SessionList→SessionCard (per-session schedule/status/EVV CTA; ui-phase-5 aligned its card shell to SurfaceCard; ui-phase-7 made the EVV CTA the full-width hero action + an optional `serviceLabel` line, REQ-052), EvvStatusBanner (advisory in/out-of-range/no-gps), CareInstructionsCard (decrypted clinical read), CheckOutConfirmButton (ui-phase-7 — the shared check-out action + lightweight confirm dialog, used by both SessionCard and BookingDetailView's in-visit banner so "check-out ends the visit and starts the payout clock" always gets one confirm step), BookingMoneySummary (gross/commission/payout display-only); useEvvController (GPS-capture + check-in/out orchestration), format.ts + statusKind.ts + ics.ts helpers (kept internal — not in the barrel). Each composite tested; the BookingDetailView test proves the customer never fires the care query (two-stage-disclosure gate)
│ ├── geography/ # F3 geo composites: CascadingRegionSelect, AddressMapPicker (map-pin stand-in), AddressForm, AddressCard (each tested) │ ├── geography/ # F3 geo composites: CascadingRegionSelect, 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 │ ├── 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 │ ├── 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
+52 -3
View File
@@ -63,7 +63,10 @@
"brand": "Balinyaar", "brand": "Balinyaar",
"brand_tagline": "Home care you can trust", "brand_tagline": "Home care you can trust",
"open_sidebar": "Open menu", "open_sidebar": "Open menu",
"switch_locale": "Switch to {locale}" "switch_locale": "Switch to {locale}",
"page_prev": "Previous",
"page_next": "Next",
"page_indicator": "Page {page} of {total}"
}, },
"shell": { "shell": {
"customer_app": "Family app", "customer_app": "Family app",
@@ -640,7 +643,49 @@
"active_empty_body": "Your ongoing bookings appear here.", "active_empty_body": "Your ongoing bookings appear here.",
"past_empty_title": "No past bookings", "past_empty_title": "No past bookings",
"past_empty_body": "Your completed or cancelled bookings appear here.", "past_empty_body": "Your completed or cancelled bookings appear here.",
"load_more": "Show more" "load_more": "Show more",
"address_title": "Address",
"address_open_map": "Directions",
"address_open_web_map": "Show on map",
"address_pending_note": "The address will be available once the booking is confirmed.",
"in_visit_title": "Visit in progress",
"in_visit_elapsed": "On site {duration}",
"evv_checkout_confirm_title": "End this visit?",
"evv_checkout_confirm_body": "Checking out ends this visit and starts the payout clock.",
"evv_checkout_confirm_cta": "Yes, check out",
"evv_checkout_confirm_cancel": "Cancel",
"today_date_prefix": "Today, {date}",
"evv_visits_error": "Couldn't load today's visits.",
"nurse_inbox_tab_pending": "Awaiting response",
"nurse_inbox_tab_answered": "Answered",
"nurse_inbox_tab_expired": "Expired",
"inbox_countdown_pill_label": "Respond by",
"accept_confirm_title": "Accept this request?",
"accept_confirm_body": "Accepting invites the family to pay; the booking is confirmed once they do.",
"accept_confirm_cta": "Yes, accept the request"
},
"dashboard": {
"greeting": "Hi, {name}",
"retry": "Retry",
"next_visit_title": "Next visit",
"next_visit_empty": "No visits scheduled for today.",
"next_visit_starts_in": "starts {relative}",
"next_visit_cta": "View and check in",
"next_visit_error": "Couldn't load today's visits.",
"requests_strip_title": "{count, plural, =0 {No pending requests} one {# request awaiting your response} other {# requests awaiting your response}}",
"requests_strip_empty": "You have no new requests.",
"requests_strip_cta": "View all",
"requests_strip_open": "View",
"requests_strip_error": "Couldn't load your requests.",
"earnings_snapshot_title": "Earnings",
"earnings_snapshot_cta": "View details",
"earnings_snapshot_error": "Couldn't load your balance.",
"activation_title": "Finish activating your profile",
"activation_body": "Complete verification to appear in search and receive booking requests.",
"activation_cta": "Continue verification",
"notifications_entry_title": "Notifications",
"notifications_entry_unread": "{count} unread",
"notifications_entry_empty": "You're all caught up"
}, },
"payment": { "payment": {
"title_checkout": "Confirm & pay", "title_checkout": "Confirm & pay",
@@ -1157,7 +1202,11 @@
"net_amount_label": "Net amount", "net_amount_label": "Net amount",
"amount_transferred_label": "Transferred", "amount_transferred_label": "Transferred",
"detail_bookings_title": "Bookings covered", "detail_bookings_title": "Bookings covered",
"detail_bookings_hint": "This transfer paid for these visits." "detail_bookings_hint": "This transfer paid for these visits.",
"forecast_label": "Next payout",
"forecast_on_date": "on {date}",
"failure_code_invalid_sheba": "The account (Sheba) number is invalid.",
"failure_code_unknown": "The transfer failed."
}, },
"reviews": { "reviews": {
"title": "Leave a review", "title": "Leave a review",
+52 -3
View File
@@ -63,7 +63,10 @@
"brand": "بالین یار", "brand": "بالین یار",
"brand_tagline": "مراقبت مطمئن در خانه", "brand_tagline": "مراقبت مطمئن در خانه",
"open_sidebar": "باز کردن منو", "open_sidebar": "باز کردن منو",
"switch_locale": "تغییر به {locale}" "switch_locale": "تغییر به {locale}",
"page_prev": "قبلی",
"page_next": "بعدی",
"page_indicator": "صفحه {page} از {total}"
}, },
"shell": { "shell": {
"customer_app": "اپلیکیشن خانواده", "customer_app": "اپلیکیشن خانواده",
@@ -640,7 +643,49 @@
"active_empty_body": "رزروهای در حال انجام شما اینجا نمایش داده می‌شوند.", "active_empty_body": "رزروهای در حال انجام شما اینجا نمایش داده می‌شوند.",
"past_empty_title": "رزرو گذشته‌ای ندارید", "past_empty_title": "رزرو گذشته‌ای ندارید",
"past_empty_body": "رزروهای تکمیل‌شده یا لغوشدهٔ شما اینجا نمایش داده می‌شوند.", "past_empty_body": "رزروهای تکمیل‌شده یا لغوشدهٔ شما اینجا نمایش داده می‌شوند.",
"load_more": "نمایش بیشتر" "load_more": "نمایش بیشتر",
"address_title": "آدرس",
"address_open_map": "مسیریابی",
"address_open_web_map": "نمایش روی نقشه",
"address_pending_note": "آدرس پس از تایید رزرو در دسترس قرار می‌گیرد.",
"in_visit_title": "در حال ویزیت",
"in_visit_elapsed": "{duration} در محل",
"evv_checkout_confirm_title": "اتمام این ویزیت؟",
"evv_checkout_confirm_body": "با ثبت خروج، این ویزیت پایان می‌یابد و فرآیند واریز به حساب شما آغاز می‌شود.",
"evv_checkout_confirm_cta": "بله، ثبت خروج",
"evv_checkout_confirm_cancel": "انصراف",
"today_date_prefix": "امروز، {date}",
"evv_visits_error": "بارگذاری ویزیت‌های امروز ممکن نشد.",
"nurse_inbox_tab_pending": "در انتظار پاسخ",
"nurse_inbox_tab_answered": "پاسخ‌داده",
"nurse_inbox_tab_expired": "منقضی‌شده",
"inbox_countdown_pill_label": "پاسخ تا",
"accept_confirm_title": "پذیرش این درخواست؟",
"accept_confirm_body": "با پذیرش، خانواده برای پرداخت دعوت می‌شود؛ پس از پرداخت، رزرو قطعی می‌شود.",
"accept_confirm_cta": "بله، پذیرش درخواست"
},
"dashboard": {
"greeting": "سلام، {name}",
"retry": "تلاش مجدد",
"next_visit_title": "ویزیت بعدی",
"next_visit_empty": "امروز ویزیتی ندارید.",
"next_visit_starts_in": "شروع {relative}",
"next_visit_cta": "مشاهده و ثبت ورود",
"next_visit_error": "بارگذاری ویزیت‌های امروز ممکن نشد.",
"requests_strip_title": "{count, plural, =0 {درخواست در انتظاری ندارید} other {# درخواست منتظر پاسخ شما}}",
"requests_strip_empty": "درخواست جدیدی ندارید.",
"requests_strip_cta": "مشاهده همه",
"requests_strip_open": "مشاهده",
"requests_strip_error": "بارگذاری درخواست‌های شما ممکن نشد.",
"earnings_snapshot_title": "درآمد",
"earnings_snapshot_cta": "مشاهده جزئیات",
"earnings_snapshot_error": "بارگذاری موجودی شما ممکن نشد.",
"activation_title": "تکمیل فعال‌سازی پروفایل",
"activation_body": "برای دیده‌شدن در جستجو و دریافت درخواست رزرو، احراز هویت خود را تکمیل کنید.",
"activation_cta": "ادامهٔ احراز هویت",
"notifications_entry_title": "اعلان‌ها",
"notifications_entry_unread": "{count} خوانده‌نشده",
"notifications_entry_empty": "اعلان جدیدی ندارید"
}, },
"payment": { "payment": {
"title_checkout": "تایید و پرداخت", "title_checkout": "تایید و پرداخت",
@@ -1157,7 +1202,11 @@
"net_amount_label": "مبلغ خالص", "net_amount_label": "مبلغ خالص",
"amount_transferred_label": "واریزشده", "amount_transferred_label": "واریزشده",
"detail_bookings_title": "رزروهای دربرگرفته", "detail_bookings_title": "رزروهای دربرگرفته",
"detail_bookings_hint": "این واریز بابت این ویزیت‌ها پرداخت شده است." "detail_bookings_hint": "این واریز بابت این ویزیت‌ها پرداخت شده است.",
"forecast_label": "برداشت بعدی",
"forecast_on_date": "در {date}",
"failure_code_invalid_sheba": "شماره شبای حساب نامعتبر است.",
"failure_code_unknown": "این انتقال ناموفق بود."
}, },
"reviews": { "reviews": {
"title": "ثبت نظر", "title": "ثبت نظر",
@@ -0,0 +1,56 @@
'use client';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { Skeleton, Stack, Typography } from '@mui/material';
import { AccentCard, AppButton, AppIcon } from '@/components';
import { ROUTES } from '@/constants';
import { isApproved, type VerificationStatus } from '@/services/verification/types';
export interface DashboardActivationSlotProps {
status: VerificationStatus | undefined;
isLoading: boolean;
}
/**
* The dashboard's activation/go-live composition point — **named and exported so a later phase can find
* it**. This phase fills it only with the existing verification-status banner (rendered while the nurse
* isn't yet approved; nothing once approved). The fuller "go live" checklist (profile/services/coverage/
* bank all done) is **DEFERRED to ui-phase-8**, which owns this slot's content from here — extend this
* component in place rather than adding a second slot.
* @component DashboardActivationSlot
*/
export default function DashboardActivationSlot({ status, isLoading }: DashboardActivationSlotProps) {
const t = useTranslations('dashboard');
const locale = useLocale();
const router = useRouter();
if (isLoading) return <Skeleton variant="rounded" height={96} />;
if (isApproved(status)) return null;
return (
<AccentCard tone="warning">
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'flex-start', justifyContent: 'space-between', flexWrap: 'wrap' }}>
<Stack direction="row" sx={{ gap: 1.25, alignItems: 'flex-start' }}>
<AppIcon icon="verification" size={20} color="var(--bal-warning)" />
<Stack sx={{ gap: 0.25 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('activation_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('activation_body')}
</Typography>
</Stack>
</Stack>
<AppButton
variant="outlined"
color="primary"
size="small"
endIcon="verification"
onClick={() => router.push(`/${locale}${ROUTES.NURSE_VERIFICATION}`)}
>
{t('activation_cta')}
</AppButton>
</Stack>
</AccentCard>
);
}
@@ -0,0 +1,294 @@
'use client';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { Avatar, Box, Skeleton, Stack, Typography } from '@mui/material';
import {
AppButton,
AppIcon,
AppLink,
CountdownTimer,
EmptyState,
ErrorState,
Money,
SurfaceCard,
TrustBadge,
} from '@/components';
import { ROUTES } from '@/constants';
import { formatRelativeTime, formatShamsiDate, localeTag, parseIrr } from '@/utils';
import { useMe } from '@/services/auth';
import { useNurseRequestInbox } from '@/services/bookingRequests';
import { useTodaySessions } from '@/services/bookings';
import { useNurseEarningsBalance } from '@/services/payouts';
import { useUnreadCount } from '@/services/notifications';
import { useVerificationStatus } from '@/services/verification';
import { ownBadgeState } from '@/services/verification/types';
import { coarseResponseLabel } from '@/services/bookingRequests/format';
import DashboardActivationSlot from './DashboardActivationSlot';
const DASHBOARD_MAX_WIDTH = 960;
/** The pill's urgency tiers (ui-phase-7 §3.4): teal >2h · amber <2h · terracotta <30min. */
const URGENT_THRESHOLD_SECONDS = 30 * 60;
const WARN_THRESHOLD_SECONDS = 2 * 60 * 60;
/**
* The nurse "امروز" dashboard (ui-phase-7 §3.1) — the operational home replacing the `PlaceholderScreen`.
* Pure assembly: every widget reads an already-cached query. Order matters — the pending-requests strip
* is the most time-critical thing a nurse can miss, so it sits above the earnings snapshot.
*/
export default function NurseDashboardScreen() {
const t = useTranslations('dashboard');
const { data: me, isLoading: meLoading } = useMe();
const verification = useVerificationStatus();
const displayName = me ? [me.firstName, me.lastName].filter(Boolean).join(' ').trim() || me.phone : '';
return (
<Stack sx={{ gap: 3, maxWidth: DASHBOARD_MAX_WIDTH, mx: 'auto', width: '100%' }}>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
{meLoading ? (
<>
<Skeleton variant="circular" width={44} height={44} />
<Skeleton variant="text" width={160} height={32} />
</>
) : (
<>
<Avatar sx={{ width: 44, height: 44, bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 700 }}>
{(displayName || '؟').charAt(0)}
</Avatar>
<Stack sx={{ gap: 0.5 }}>
<Typography variant="h6" component="h1" sx={{ fontWeight: 700 }}>
{t('greeting', { name: displayName })}
</Typography>
{!verification.isLoading ? <TrustBadge state={ownBadgeState(verification.data)} /> : null}
</Stack>
</>
)}
</Stack>
<NextVisitCard />
<RequestsStrip />
<EarningsSnapshotCard />
<DashboardActivationSlot status={verification.data} isLoading={verification.isLoading} />
<NotificationsEntryRow />
</Stack>
);
}
/** First actionable session from `useTodaySessions` + a display-only "time until" line. */
function NextVisitCard() {
const t = useTranslations('dashboard');
const locale = useLocale();
const router = useRouter();
const { data, isLoading, isError, refetch } = useTodaySessions();
if (isLoading) return <Skeleton variant="rounded" height={140} />;
if (isError) {
return <ErrorState message={t('next_visit_error')} retryLabel={t('retry')} onRetry={() => refetch()} />;
}
const items = data?.items ?? [];
const next = items.find((item) => item.status === 'scheduled' || item.status === 'in_progress');
if (!next) {
return <EmptyState icon="visits" title={t('next_visit_empty')} />;
}
const timeFmt = new Intl.DateTimeFormat(localeTag(locale), { hour: '2-digit', minute: '2-digit' });
const timeRangeLabel = `${timeFmt.format(new Date(`${next.scheduledDate}T${next.scheduledTimeStart}`))} ${timeFmt.format(new Date(`${next.scheduledDate}T${next.scheduledTimeEnd}`))}`;
const timeUntil = formatRelativeTime(`${next.scheduledDate}T${next.scheduledTimeStart}`, locale, formatShamsiDate);
return (
<SurfaceCard data-widget="next-visit">
<Stack sx={{ gap: 1.25 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon="visits" size={20} color="var(--bal-primary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('next_visit_title')}
</Typography>
</Stack>
<Stack sx={{ gap: 0.25 }}>
<Typography variant="body1" sx={{ fontWeight: 500 }}>
{next.patientName}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
<Typography component="span" dir="ltr" sx={{ fontVariantNumeric: 'tabular-nums' }}>
{timeRangeLabel}
</Typography>
{timeUntil ? ` · ${t('next_visit_starts_in', { relative: timeUntil })}` : ''}
</Typography>
</Stack>
<AppButton
variant="contained"
color="secondary"
startIcon="check_in"
onClick={() => router.push(`/${locale}${ROUTES.NURSE_VISITS}`)}
sx={{ alignSelf: 'flex-start' }}
>
{t('next_visit_cta')}
</AppButton>
</Stack>
</SurfaceCard>
);
}
/** The most time-critical widget: pending-request count + the most urgent countdown, inline into detail. */
function RequestsStrip() {
const t = useTranslations('dashboard');
const tb = useTranslations('booking');
const locale = useLocale();
const router = useRouter();
const { data, isLoading, isError, refetch } = useNurseRequestInbox();
if (isLoading) return <Skeleton variant="rounded" height={140} />;
if (isError) {
return <ErrorState message={t('requests_strip_error')} retryLabel={t('retry')} onRetry={() => refetch()} />;
}
const items = data?.items ?? [];
const total = data?.total ?? 0;
if (items.length === 0) {
return <EmptyState icon="requests" title={t('requests_strip_empty')} />;
}
const mostUrgent = items[0];
return (
<SurfaceCard data-widget="requests-strip">
<Stack sx={{ gap: 1.25 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between' }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon="requests" size={20} color="var(--bal-secondary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('requests_strip_title', { count: total })}
</Typography>
</Stack>
<AppButton
variant="text"
color="primary"
endIcon="requests"
onClick={() => router.push(`/${locale}${ROUTES.NURSE_REQUESTS}`)}
>
{t('requests_strip_cta')}
</AppButton>
</Stack>
<Stack
direction="row"
sx={{ gap: 1.5, alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap' }}
>
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
<Typography variant="body1" sx={{ fontWeight: 500 }}>
{mostUrgent.counterpartyName}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{formatShamsiDate(mostUrgent.requestedDate, locale)}
</Typography>
</Stack>
<CountdownTimer
deadlineIso={mostUrgent.nurseResponseDeadlineAt}
elapsedText={tb('response_elapsed')}
warnThresholdSeconds={WARN_THRESHOLD_SECONDS}
urgentThresholdSeconds={URGENT_THRESHOLD_SECONDS}
coarseLabel={(minutes) => coarseResponseLabel(minutes, tb)}
size="sm"
/>
</Stack>
<AppButton
variant="outlined"
color="primary"
endIcon="requests"
onClick={() => router.push(`/${locale}${ROUTES.NURSE_REQUESTS}/${mostUrgent.id}`)}
sx={{ alignSelf: 'flex-start' }}
>
{t('requests_strip_open')}
</AppButton>
</Stack>
</SurfaceCard>
);
}
/** A compact two-stat row (net payable + eligible) — never clamps a negative net balance. */
function EarningsSnapshotCard() {
const t = useTranslations('dashboard');
const tp = useTranslations('payouts');
const locale = useLocale();
const router = useRouter();
const { data, isLoading, isError, refetch } = useNurseEarningsBalance();
if (isLoading) return <Skeleton variant="rounded" height={120} />;
if (isError) {
return <ErrorState message={t('earnings_snapshot_error')} retryLabel={t('retry')} onRetry={() => refetch()} />;
}
if (!data) return null;
const net = parseIrr(data.netPayableBalanceIrr);
const isOwed = net < BigInt(0);
const magnitude = isOwed ? -net : net;
return (
<SurfaceCard data-widget="earnings-snapshot">
<Stack sx={{ gap: 1.5 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between' }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon="earnings" size={20} color="var(--bal-primary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('earnings_snapshot_title')}
</Typography>
</Stack>
<AppButton
variant="text"
color="primary"
endIcon="earnings"
onClick={() => router.push(`/${locale}${ROUTES.NURSE_EARNINGS}`)}
>
{t('earnings_snapshot_cta')}
</AppButton>
</Stack>
<Box sx={{ display: 'grid', gap: 1.5, gridTemplateColumns: '1fr 1fr' }}>
<Stack sx={{ gap: 0.25 }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{isOwed ? tp('balance_owed_label') : tp('balance_net_label')}
</Typography>
<Money amountIrr={String(magnitude)} size="lg" tone={isOwed ? 'error' : 'emphasis'} sx={{ fontWeight: 800 }} />
</Stack>
<Stack sx={{ gap: 0.25 }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{tp('bucket_eligible')}
</Typography>
<Money amountIrr={data.eligibleTotalIrr} size="lg" sx={{ fontWeight: 800 }} />
</Stack>
</Box>
</Stack>
</SurfaceCard>
);
}
/** The unread-count entry row — the bell in the shell chrome is Phase 2's; this is a dashboard shortcut. */
function NotificationsEntryRow() {
const t = useTranslations('dashboard');
const locale = useLocale();
const unread = useUnreadCount();
return (
<AppLink to={`/${locale}${ROUTES.NURSE_NOTIFICATIONS}`} color="inherit" underline="none" sx={{ display: 'block' }}>
<SurfaceCard data-widget="notifications-entry">
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center', justifyContent: 'space-between' }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon="notifications" size={20} color="var(--bal-primary)" />
<Typography variant="body1" sx={{ fontWeight: 500 }}>
{t('notifications_entry_title')}
</Typography>
</Stack>
<Typography
variant="body2"
sx={{ color: unread > 0 ? 'var(--bal-secondary)' : 'text.secondary', fontWeight: unread > 0 ? 700 : 400 }}
>
{unread > 0 ? t('notifications_entry_unread', { count: unread }) : t('notifications_entry_empty')}
</Typography>
</Stack>
</SurfaceCard>
</AppLink>
);
}
@@ -2,10 +2,11 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Box, Collapse, Paper, Skeleton, Stack, Tab, Tabs, Typography } from '@mui/material'; import { Box, ButtonBase, Collapse, Paper, Skeleton, Stack, Tab, Tabs, Typography } from '@mui/material';
import { AppButton, AppIcon, EarningsBalanceHeader, EarningsRow, EmptyState, ErrorState } from '@/components'; import { AppIcon, EarningsBalanceHeader, EarningsRow, EmptyState, ErrorState, Money, Pager, SurfaceCard } from '@/components';
import { CONTENT_MAX_WIDTH } from '@/components/config';
import { nurseBookingDetailPath, nursePayoutDetailPath } from '@/constants'; import { nurseBookingDetailPath, nursePayoutDetailPath } from '@/constants';
import { formatNumber } from '@/utils'; import { formatShamsiDate } from '@/utils';
import { PAYOUTS_PAGE_SIZE } from '@/services/payouts/constants'; import { PAYOUTS_PAGE_SIZE } from '@/services/payouts/constants';
import { EARNINGS_STATES, type EarningsState } from '@/services/payouts/types'; import { EARNINGS_STATES, type EarningsState } from '@/services/payouts/types';
import { useNurseEarnings, useNurseEarningsBalance } from '@/services/payouts'; import { useNurseEarnings, useNurseEarningsBalance } from '@/services/payouts';
@@ -46,7 +47,7 @@ export default function NurseEarningsPage() {
}; };
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}>
<Box> <Box>
<Typography variant="h5" component="h1"> <Typography variant="h5" component="h1">
{t('title')} {t('title')}
@@ -61,7 +62,10 @@ export default function NurseEarningsPage() {
) : balance.isError ? ( ) : balance.isError ? (
<ErrorState message={t('balance_error')} retryLabel={t('retry')} onRetry={() => balance.refetch()} /> <ErrorState message={t('balance_error')} retryLabel={t('retry')} onRetry={() => balance.refetch()} />
) : balance.data ? ( ) : balance.data ? (
<>
<EarningsBalanceHeader summary={balance.data} /> <EarningsBalanceHeader summary={balance.data} />
<ForecastLine nextPayoutDate={balance.data.nextPayoutDate} nextPayoutEligibleAmountIrr={balance.data.nextPayoutEligibleAmountIrr} />
</>
) : null} ) : null}
<ExplainerCard open={explainerOpen} onToggle={() => setExplainerOpen((v) => !v)} /> <ExplainerCard open={explainerOpen} onToggle={() => setExplainerOpen((v) => !v)} />
@@ -108,7 +112,42 @@ export default function NurseEarningsPage() {
); );
} }
/** Collapsible "how payouts work" — the cadence + dispute-window + method-invariant copy (both locales). */ /** The «برداشت بعدی» forecast server-served only (REQ-053); renders nothing until the earnings read
* serves both fields (never computed client-side holiday shifting + eligibility are backend truth). */
function ForecastLine({
nextPayoutDate,
nextPayoutEligibleAmountIrr,
}: {
nextPayoutDate: string | null | undefined;
nextPayoutEligibleAmountIrr: string | null | undefined;
}) {
const t = useTranslations('payouts');
const locale = useLocale();
if (!nextPayoutDate || !nextPayoutEligibleAmountIrr) return null;
return (
<SurfaceCard padding="sm" data-widget="payout-forecast">
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap' }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon="calendar" size={18} color="var(--bal-primary)" />
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{t('forecast_label')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('forecast_on_date', { date: formatShamsiDate(nextPayoutDate, locale) })}
</Typography>
</Stack>
<Money amountIrr={nextPayoutEligibleAmountIrr} size="md" tone="emphasis" sx={{ fontWeight: 700 }} />
</Stack>
</SurfaceCard>
);
}
const EXPLAINER_CONTENT_ID = 'nurse-earnings-explainer-content';
/** Collapsible "how payouts work" the cadence + dispute-window + method-invariant copy (both locales).
* A real `ButtonBase` toggle (`aria-expanded` + `aria-controls`) replaces the bare `onClick` Stack, and the
* registered `expand` chevron (rotated when open) replaces the eye icons. */
function ExplainerCard({ open, onToggle }: { open: boolean; onToggle: () => void }) { function ExplainerCard({ open, onToggle }: { open: boolean; onToggle: () => void }) {
const t = useTranslations('payouts'); const t = useTranslations('payouts');
const points = useMemo(() => ['explainer_point_1', 'explainer_point_2', 'explainer_point_3'] as const, []); const points = useMemo(() => ['explainer_point_1', 'explainer_point_2', 'explainer_point_3'] as const, []);
@@ -125,10 +164,11 @@ function ExplainerCard({ open, onToggle }: { open: boolean; onToggle: () => void
borderInlineStartColor: 'var(--bal-info)', borderInlineStartColor: 'var(--bal-info)',
}} }}
> >
<Stack <ButtonBase
direction="row"
sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between', cursor: 'pointer' }}
onClick={onToggle} onClick={onToggle}
aria-expanded={open}
aria-controls={EXPLAINER_CONTENT_ID}
sx={{ width: '100%', justifyContent: 'space-between', gap: 1, borderRadius: 1 }}
> >
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center' }}> <Stack direction="row" sx={{ gap: 0.75, alignItems: 'center' }}>
<AppIcon icon="info" size={18} color="var(--bal-info)" /> <AppIcon icon="info" size={18} color="var(--bal-info)" />
@@ -136,9 +176,14 @@ function ExplainerCard({ open, onToggle }: { open: boolean; onToggle: () => void
{t('explainer_title')} {t('explainer_title')}
</Typography> </Typography>
</Stack> </Stack>
<AppIcon icon={open ? 'visibilityoff' : 'visibilityon'} size={18} color="var(--bal-text-secondary)" /> <AppIcon
</Stack> icon="expand"
<Collapse in={open}> size={18}
color="var(--bal-text-secondary)"
style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform var(--bal-motion-fast) var(--bal-easing-standard)' }}
/>
</ButtonBase>
<Collapse in={open} id={EXPLAINER_CONTENT_ID}>
<Stack component="ul" sx={{ gap: 0.75, mt: 1.5, mb: 0, pl: 2.5 }}> <Stack component="ul" sx={{ gap: 0.75, mt: 1.5, mb: 0, pl: 2.5 }}>
{points.map((key) => ( {points.map((key) => (
<Typography key={key} component="li" variant="body2" sx={{ color: 'text.secondary' }}> <Typography key={key} component="li" variant="body2" sx={{ color: 'text.secondary' }}>
@@ -150,35 +195,3 @@ function ExplainerCard({ open, onToggle }: { open: boolean; onToggle: () => void
</Paper> </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) => formatNumber(n, locale);
return (
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'center' }}>
<AppButton variant="text" color="primary" onClick={onPrev} disabled={page <= 1}>
{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}>
{t('page_next')}
</AppButton>
</Stack>
);
}
@@ -5,8 +5,10 @@ import { useLocale, useTranslations } from 'next-intl';
import { Box, Divider, Paper, Skeleton, Stack, Typography } from '@mui/material'; import { Box, Divider, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, Money, PriceBreakdown, StatusChip } from '@/components'; import { AppButton, AppIcon, Money, PriceBreakdown, StatusChip } from '@/components';
import type { StatusKind } from '@/components'; import type { StatusKind } from '@/components';
import { CONTENT_MAX_WIDTH } from '@/components/config';
import { nurseBookingDetailPath, ROUTES } from '@/constants'; import { nurseBookingDetailPath, ROUTES } from '@/constants';
import { formatShamsiDate, parseIrr } from '@/utils'; import { formatShamsiDate, parseIrr } from '@/utils';
import { failureReasonLabelKey } from '@/services/payouts/failureReasons';
import { useNursePayoutDetail } from '@/services/payouts'; import { useNursePayoutDetail } from '@/services/payouts';
import type { PayoutBatchStatus, PayoutStatus } from '@/services/payouts/types'; import type { PayoutBatchStatus, PayoutStatus } from '@/services/payouts/types';
@@ -42,7 +44,7 @@ export default function NursePayoutDetailPage() {
const { data, isLoading, isError } = useNursePayoutDetail(Number.isFinite(payoutId) ? payoutId : undefined); const { data, isLoading, isError } = useNursePayoutDetail(Number.isFinite(payoutId) ? payoutId : undefined);
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}>
<Stack sx={{ gap: 0.5 }}> <Stack sx={{ gap: 0.5 }}>
<AppButton <AppButton
variant="text" variant="text"
@@ -123,9 +125,12 @@ export default function NursePayoutDetailPage() {
{t('failure_title')} {t('failure_title')}
</Typography> </Typography>
</Stack> </Stack>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t(failureReasonLabelKey(data.failureReason))}
</Typography>
{data.failureReason ? ( {data.failureReason ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }} dir="ltr"> <Typography variant="caption" sx={{ color: 'text.secondary' }} dir="ltr">
{t('failure_reason_label')}: {data.failureReason} {data.failureReason}
</Typography> </Typography>
) : null} ) : null}
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}> <Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
@@ -3,9 +3,9 @@ import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Box, Skeleton, Stack, Typography } from '@mui/material'; import { Box, Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, EmptyState, ErrorState, PayoutHistoryRow } from '@/components'; import { AppButton, EmptyState, ErrorState, Pager, PayoutHistoryRow } from '@/components';
import { CONTENT_MAX_WIDTH } from '@/components/config';
import { nursePayoutDetailPath, ROUTES } from '@/constants'; import { nursePayoutDetailPath, ROUTES } from '@/constants';
import { formatNumber } from '@/utils';
import { PAYOUTS_PAGE_SIZE } from '@/services/payouts/constants'; import { PAYOUTS_PAGE_SIZE } from '@/services/payouts/constants';
import { useNursePayoutHistory } from '@/services/payouts'; import { useNursePayoutHistory } from '@/services/payouts';
@@ -26,10 +26,9 @@ export default function NursePayoutHistoryPage() {
const items = history.data?.items ?? []; const items = history.data?.items ?? [];
const total = history.data?.total ?? 0; const total = history.data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / PAYOUTS_PAGE_SIZE)); const pageCount = Math.max(1, Math.ceil(total / PAYOUTS_PAGE_SIZE));
const fmt = (n: number) => formatNumber(n, locale);
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}>
<Stack sx={{ gap: 0.5 }}> <Stack sx={{ gap: 0.5 }}>
<AppButton <AppButton
variant="text" variant="text"
@@ -70,19 +69,12 @@ export default function NursePayoutHistoryPage() {
</Stack> </Stack>
)} )}
{pageCount > 1 ? ( <Pager
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'center' }}> page={page}
<AppButton variant="text" color="primary" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page <= 1}> pageCount={pageCount}
{t('page_prev')} onPrev={() => setPage((p) => Math.max(1, p - 1))}
</AppButton> onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
<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}>
{t('page_next')}
</AppButton>
</Stack>
) : null}
</Box> </Box>
); );
} }
@@ -1,6 +1,6 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server'; import { getTranslations } from 'next-intl/server';
import { PlaceholderScreen } from '@/components'; import NurseDashboardScreen from './NurseDashboardScreen';
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> { export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
const { locale } = await params; const { locale } = await params;
@@ -8,8 +8,6 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
return { title: t('dashboard') }; return { title: t('dashboard') };
} }
export default async function NurseDashboardPage() { export default function NurseDashboardPage() {
const t = await getTranslations('nav'); return <NurseDashboardScreen />;
const tShell = await getTranslations('shell');
return <PlaceholderScreen icon="dashboard" title={t('dashboard')} description={tShell('placeholder_body')} />;
} }
@@ -17,7 +17,8 @@ import {
TextField, TextField,
Typography, Typography,
} from '@mui/material'; } from '@mui/material';
import { AppButton, AppIcon, CountdownTimer, PriceDisplay, StatusChip } from '@/components'; import { AppButton, AppIcon, ConfirmDialog, CountdownTimer, PriceDisplay, StatusChip } from '@/components';
import { CONTENT_MAX_WIDTH } from '@/components/config';
import { ROUTES } from '@/constants'; import { ROUTES } from '@/constants';
import { ApiError } from '@/lib/api/errors'; import { ApiError } from '@/lib/api/errors';
import { formatShamsiDate, localeTag } from '@/utils'; import { formatShamsiDate, localeTag } from '@/utils';
@@ -54,12 +55,13 @@ export default function NurseRequestDetailPage() {
const [rejectOpen, setRejectOpen] = useState(false); const [rejectOpen, setRejectOpen] = useState(false);
const [reason, setReason] = useState(''); const [reason, setReason] = useState('');
const [reasonError, setReasonError] = useState(false); const [reasonError, setReasonError] = useState(false);
const [acceptConfirmOpen, setAcceptConfirmOpen] = useState(false);
if (isLoading) return <DetailSkeleton />; if (isLoading) return <DetailSkeleton />;
if (isError || !request) { if (isError || !request) {
return ( return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2, maxWidth: 640 }}> <Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1 }}> <Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1 }}>
{t('not_found_title')} {t('not_found_title')}
</Typography> </Typography>
@@ -121,7 +123,7 @@ export default function NurseRequestDetailPage() {
const whenLabel = `${formatShamsiDate(startDate, locale)} · ${timeFmt.format(startDate)} ${timeFmt.format(endDate)}`; const whenLabel = `${formatShamsiDate(startDate, locale)} · ${timeFmt.format(startDate)} ${timeFmt.format(endDate)}`;
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 640 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}> <Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}>
<Typography variant="h5" component="h1"> <Typography variant="h5" component="h1">
{t('detail_title')} {t('detail_title')}
@@ -201,7 +203,7 @@ export default function NurseRequestDetailPage() {
variant="contained" variant="contained"
startIcon="verified" startIcon="verified"
disabled={acceptRequest.isPending} disabled={acceptRequest.isPending}
onClick={handleAccept} onClick={() => setAcceptConfirmOpen(true)}
sx={{ flex: 1, py: 1.25 }} sx={{ flex: 1, py: 1.25 }}
> >
{acceptRequest.isPending ? t('accepting') : t('accept')} {acceptRequest.isPending ? t('accepting') : t('accept')}
@@ -230,12 +232,36 @@ export default function NurseRequestDetailPage() {
borderInlineStartColor: isTerminal ? 'var(--bal-text-secondary)' : 'var(--bal-secondary)', borderInlineStartColor: isTerminal ? 'var(--bal-text-secondary)' : 'var(--bal-secondary)',
}} }}
> >
<Stack sx={{ gap: 1.5 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}> <Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t(`status_${request.status}`)} {t(`status_${request.status}`)}
</Typography> </Typography>
{request.status === 'accepted_awaiting_payment' && request.paymentDeadlineAt ? (
<CountdownTimer
deadlineIso={request.paymentDeadlineAt}
label={t('payment_countdown_label')}
elapsedText={t('payment_elapsed')}
urgent
/>
) : null}
</Stack>
</Paper> </Paper>
)} )}
<ConfirmDialog
open={acceptConfirmOpen}
title={t('accept_confirm_title')}
body={t('accept_confirm_body')}
confirmLabel={t('accept_confirm_cta')}
cancelLabel={t('cancel_request')}
loading={acceptRequest.isPending}
onClose={() => setAcceptConfirmOpen(false)}
onConfirm={() => {
setAcceptConfirmOpen(false);
handleAccept();
}}
/>
<Dialog open={rejectOpen} onClose={() => setRejectOpen(false)} fullWidth maxWidth="xs"> <Dialog open={rejectOpen} onClose={() => setRejectOpen(false)} fullWidth maxWidth="xs">
<DialogTitle>{t('reject_dialog_title')}</DialogTitle> <DialogTitle>{t('reject_dialog_title')}</DialogTitle>
<DialogContent> <DialogContent>
@@ -307,7 +333,7 @@ function DetailRow({ caption, children }: { caption: string; children: React.Rea
function DetailSkeleton() { function DetailSkeleton() {
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 640 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}>
<Skeleton variant="text" width="40%" height={36} /> <Skeleton variant="text" width="40%" height={36} />
<Skeleton variant="rounded" height={180} /> <Skeleton variant="rounded" height={180} />
<Skeleton variant="rounded" height={120} /> <Skeleton variant="rounded" height={120} />
@@ -1,53 +1,125 @@
'use client'; 'use client';
import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Box, Chip, Paper, Skeleton, Stack, Typography } from '@mui/material'; import { Box, Chip, Skeleton, Stack, Tab, Tabs, Typography } from '@mui/material';
import { AppButton, CountdownTimer, EmptyState, ErrorState } from '@/components'; import { AppLink, CountdownTimer, EmptyState, ErrorState, Pager, PageHeader, SurfaceCard } from '@/components';
import { CONTENT_MAX_WIDTH } from '@/components/config';
import { ROUTES } from '@/constants'; import { ROUTES } from '@/constants';
import { formatShamsiDate, localeTag } from '@/utils'; import { formatShamsiDate, localeTag } from '@/utils';
import { useNurseRequestInbox } from '@/services/bookingRequests'; import { useNurseRequestInbox } from '@/services/bookingRequests';
import { coarseResponseLabel } from '@/services/bookingRequests/format';
import type { BookingRequestListItem } from '@/services/bookingRequests/types'; import type { BookingRequestListItem } from '@/services/bookingRequests/types';
type InboxTab = 'pending' | 'answered' | 'expired';
/** The pill's urgency tiers (§3.4): teal >2h · amber <2h · terracotta <30min. */
const URGENT_THRESHOLD_SECONDS = 30 * 60;
const WARN_THRESHOLD_SECONDS = 2 * 60 * 60;
/** /**
* Nurse incoming-requests inbox (نمای پرستار). Lists pending requests each a card with the family's * Nurse incoming-requests inbox (نمای پرستار), ui-phase-7 redesign. Decision-first cards (service + price
* patient name, the requested time (Shamsi), the **required-caregiver-gender** chip, a notes preview, and * headline when the list DTO serves them REQ-050, mock-tolerant), an urgency-tinted countdown pill, and
* a **per-request countdown** to that request's response deadline. Two-stage disclosure: the row shows * three tabs: «در انتظار» (a single `pending_nurse_response` query) / «پاسخداده» (merged, page-1-only
* only `customerNotes` never an address or any clinical field. Lightly polled so new requests appear. * the API filters by a *single* status and there's no status-group filter yet, so this tab concatenates
* three page-1 queries; a documented limitation until REQ-050's status-group filter lands) / «منقضی»
* (`expired_no_response`). Two-stage disclosure unchanged: the row shows only `customerNotes` never an
* address or any clinical field.
*/ */
export default function NurseRequestsPage() { export default function NurseRequestsPage() {
const t = useTranslations('booking'); const t = useTranslations('booking');
const tc = useTranslations('common'); const [tab, setTab] = useState<InboxTab>('pending');
const { data, isLoading, isError, refetch } = useNurseRequestInbox(); const [pendingPage, setPendingPage] = useState(1);
const items = data?.items ?? []; const [expiredPage, setExpiredPage] = useState(1);
const pendingQuery = useNurseRequestInbox('pending_nurse_response', pendingPage, { enabled: tab === 'pending' });
const expiredQuery = useNurseRequestInbox('expired_no_response', expiredPage, { enabled: tab === 'expired' });
const acceptedQuery = useNurseRequestInbox('accepted_awaiting_payment', 1, { enabled: tab === 'answered' });
const convertedQuery = useNurseRequestInbox('converted', 1, { enabled: tab === 'answered' });
const rejectedQuery = useNurseRequestInbox('rejected_by_nurse', 1, { enabled: tab === 'answered' });
const onTabChange = (_event: React.SyntheticEvent, next: InboxTab) => setTab(next);
const answeredLoading = acceptedQuery.isLoading || convertedQuery.isLoading || rejectedQuery.isLoading;
const answeredError = acceptedQuery.isError || convertedQuery.isError || rejectedQuery.isError;
const answeredItems = [
...(acceptedQuery.data?.items ?? []),
...(convertedQuery.data?.items ?? []),
...(rejectedQuery.data?.items ?? []),
];
const retryAnswered = () => {
acceptedQuery.refetch();
convertedQuery.refetch();
rejectedQuery.refetch();
};
const active =
tab === 'pending'
? {
items: pendingQuery.data?.items ?? [],
isLoading: pendingQuery.isLoading,
isError: pendingQuery.isError,
refetch: pendingQuery.refetch,
page: pendingPage,
pageCount: Math.max(1, Math.ceil((pendingQuery.data?.total ?? 0) / (pendingQuery.data?.pageSize || 1))),
onPageChange: setPendingPage,
}
: tab === 'expired'
? {
items: expiredQuery.data?.items ?? [],
isLoading: expiredQuery.isLoading,
isError: expiredQuery.isError,
refetch: expiredQuery.refetch,
page: expiredPage,
pageCount: Math.max(1, Math.ceil((expiredQuery.data?.total ?? 0) / (expiredQuery.data?.pageSize || 1))),
onPageChange: setExpiredPage,
}
: {
items: answeredItems,
isLoading: answeredLoading,
isError: answeredError,
refetch: retryAnswered,
page: 1,
pageCount: 1,
onPageChange: () => {},
};
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 640 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}>
<Box> <PageHeader title={t('inbox_title')} subtitle={t('inbox_subtitle')} />
<Typography variant="h5" component="h1">
{t('inbox_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('inbox_subtitle')}
</Typography>
</Box>
{isLoading ? ( <Tabs value={tab} onChange={onTabChange} variant="scrollable" scrollButtons="auto" allowScrollButtonsMobile>
<Tab value="pending" label={t('nurse_inbox_tab_pending')} sx={{ textTransform: 'none' }} />
<Tab value="answered" label={t('nurse_inbox_tab_answered')} sx={{ textTransform: 'none' }} />
<Tab value="expired" label={t('nurse_inbox_tab_expired')} sx={{ textTransform: 'none' }} />
</Tabs>
{active.isLoading ? (
<Stack sx={{ gap: 2 }}> <Stack sx={{ gap: 2 }}>
{[0, 1].map((key) => ( {[0, 1].map((key) => (
<Skeleton key={key} variant="rounded" height={140} /> <Skeleton key={key} variant="rounded" height={140} />
))} ))}
</Stack> </Stack>
) : isError ? ( ) : active.isError ? (
<ErrorState message={t('inbox_error')} retryLabel={tc('retry')} onRetry={() => refetch()} /> <ErrorState message={t('inbox_error')} retryLabel={t('retry')} onRetry={() => active.refetch()} />
) : items.length === 0 ? ( ) : active.items.length === 0 ? (
<EmptyState icon="requests" title={t('inbox_empty')} /> <EmptyState icon="requests" title={t('inbox_empty')} />
) : ( ) : (
<Stack sx={{ gap: 2 }}> <Stack sx={{ gap: 2 }}>
{items.map((item) => ( {active.items.map((item) => (
<InboxCard key={item.id} item={item} /> <InboxCard key={item.id} item={item} />
))} ))}
</Stack> </Stack>
)} )}
{tab !== 'answered' ? (
<Pager
page={active.page}
pageCount={active.pageCount}
onPrev={() => active.onPageChange(Math.max(1, active.page - 1))}
onNext={() => active.onPageChange(Math.min(active.pageCount, active.page + 1))}
/>
) : null}
</Box> </Box>
); );
} }
@@ -55,37 +127,59 @@ export default function NurseRequestsPage() {
function InboxCard({ item }: { item: BookingRequestListItem }) { function InboxCard({ item }: { item: BookingRequestListItem }) {
const t = useTranslations('booking'); const t = useTranslations('booking');
const locale = useLocale(); const locale = useLocale();
const router = useRouter();
const startDate = new Date(`${item.requestedDate}T${item.requestedTimeStart}`); const startDate = new Date(`${item.requestedDate}T${item.requestedTimeStart}`);
const endDate = new Date(`${item.requestedDate}T${item.requestedTimeEnd}`); const endDate = new Date(`${item.requestedDate}T${item.requestedTimeEnd}`);
const timeFmt = new Intl.DateTimeFormat(localeTag(locale), { hour: '2-digit', minute: '2-digit' }); const timeFmt = new Intl.DateTimeFormat(localeTag(locale), { hour: '2-digit', minute: '2-digit' });
const whenLabel = `${formatShamsiDate(startDate, locale)} · ${timeFmt.format(startDate)} ${timeFmt.format(endDate)}`; const whenLabel = `${formatShamsiDate(startDate, locale)} · ${timeFmt.format(startDate)} ${timeFmt.format(endDate)}`;
const hasPricedService = Boolean(item.variantLabel);
return ( return (
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}> <AppLink to={`/${locale}${ROUTES.NURSE_REQUESTS}/${item.id}`} color="inherit" underline="none" sx={{ display: 'block' }}>
<SurfaceCard data-request-status={item.status}>
<Stack sx={{ gap: 1.5 }}> <Stack sx={{ gap: 1.5 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'flex-start', gap: 2 }}> <Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'flex-start', gap: 2 }}>
<Stack sx={{ gap: 0.5, minWidth: 0 }}> <Stack sx={{ gap: 0.5, minWidth: 0 }}>
{hasPricedService ? (
<>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{item.variantLabel}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{item.counterpartyName}
</Typography>
</>
) : (
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}> <Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{item.counterpartyName} {item.counterpartyName}
</Typography> </Typography>
)}
<Typography variant="body2" sx={{ color: 'text.secondary' }}> <Typography variant="body2" sx={{ color: 'text.secondary' }}>
{whenLabel} {whenLabel}
</Typography> </Typography>
</Stack> </Stack>
<CountdownTimer deadlineIso={item.nurseResponseDeadlineAt} elapsedText={t('response_elapsed')} /> {item.status === 'pending_nurse_response' ? (
<CountdownTimer
deadlineIso={item.nurseResponseDeadlineAt}
label={t('inbox_countdown_pill_label')}
elapsedText={t('response_elapsed')}
warnThresholdSeconds={WARN_THRESHOLD_SECONDS}
urgentThresholdSeconds={URGENT_THRESHOLD_SECONDS}
coarseLabel={(minutes) => coarseResponseLabel(minutes, t)}
size="sm"
/>
) : null}
</Stack> </Stack>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
{item.requiredCaregiverGender ? ( {item.requiredCaregiverGender ? (
<Box>
<Chip <Chip
size="small" size="small"
label={t('required_gender_chip', { gender: t(`gender_${item.requiredCaregiverGender}`) })} label={t('required_gender_chip', { gender: t(`gender_${item.requiredCaregiverGender}`) })}
sx={{ bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 500 }} sx={{ bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 500 }}
/> />
</Box>
) : null} ) : null}
</Stack>
{item.customerNotes ? ( {item.customerNotes ? (
<Box> <Box>
@@ -97,17 +191,8 @@ function InboxCard({ item }: { item: BookingRequestListItem }) {
</Typography> </Typography>
</Box> </Box>
) : null} ) : null}
<AppButton
variant="outlined"
color="primary"
endIcon="requests"
onClick={() => router.push(`/${locale}${ROUTES.NURSE_REQUESTS}/${item.id}`)}
sx={{ alignSelf: 'flex-start' }}
>
{t('open_detail')}
</AppButton>
</Stack> </Stack>
</Paper> </SurfaceCard>
</AppLink>
); );
} }
@@ -1,36 +1,36 @@
'use client'; 'use client';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Box, Skeleton, Stack, Typography } from '@mui/material'; import { Box, Skeleton, Stack } from '@mui/material';
import { AppButton, EmptyState } from '@/components'; import { AppButton, EmptyState, ErrorState, PageHeader } from '@/components';
import { SessionCard, useEvvController } from '@/components/booking'; import { SessionCard, useEvvController } from '@/components/booking';
import { CONTENT_MAX_WIDTH } from '@/components/config';
import { ROUTES } from '@/constants'; import { ROUTES } from '@/constants';
import { formatShamsiDate } from '@/utils';
import { useSessionEvv, useTodaySessions } from '@/services/bookings'; import { useSessionEvv, useTodaySessions } from '@/services/bookings';
import type { BookingSessionListItemDto } from '@/services/bookings/types'; import type { BookingSessionListItemDto } from '@/services/bookings/types';
const TODAY_HEADER_DATE_OPTIONS: Intl.DateTimeFormatOptions = { month: 'long', day: 'numeric' };
/** /**
* Nurse ویزیت امروز (E3 top) the day's operational surface. Lists today's sessions from * 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 * `useTodaySessions` (polled every 60s so a same-day schedule change surfaces without re-navigation);
* (driven by one `useEvvController`) and the advisory EVV banner once checked in. A GPS mismatch is * each renders the shared `SessionCard` with the per-session EVV check-in/out control (driven by one
* advisory, never a block. Each card also deep-links to the full booking detail (`/nurse/visits/{id}`), * `useEvvController`) and the advisory EVV banner once checked in. A GPS mismatch is advisory, never a
* where the gated care instructions live. Visit-note authoring + task checklist are deferred to f13. * 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() { export default function NurseVisitsPage() {
const t = useTranslations('booking'); const t = useTranslations('booking');
const { data, isLoading } = useTodaySessions(); const locale = useLocale();
const { data, isLoading, isError, refetch } = useTodaySessions();
const evv = useEvvController(); const evv = useEvvController();
const items = data?.items ?? []; const items = data?.items ?? [];
const dateAnchor = t('today_date_prefix', { date: formatShamsiDate(new Date(), locale, TODAY_HEADER_DATE_OPTIONS) });
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 640 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}>
<Box> <PageHeader title={dateAnchor} subtitle={t('evv_visits_subtitle')} />
<Typography variant="h5" component="h1">
{t('evv_visits_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('evv_visits_subtitle')}
</Typography>
</Box>
{isLoading ? ( {isLoading ? (
<Stack sx={{ gap: 2 }}> <Stack sx={{ gap: 2 }}>
@@ -38,6 +38,8 @@ export default function NurseVisitsPage() {
<Skeleton key={key} variant="rounded" height={150} /> <Skeleton key={key} variant="rounded" height={150} />
))} ))}
</Stack> </Stack>
) : isError ? (
<ErrorState message={t('evv_visits_error')} retryLabel={t('retry')} onRetry={() => refetch()} />
) : items.length === 0 ? ( ) : items.length === 0 ? (
<EmptyState icon="visits" title={t('evv_no_visits')} /> <EmptyState icon="visits" title={t('evv_no_visits')} />
) : ( ) : (
@@ -64,6 +66,7 @@ function TodayVisitCard({ item, evv }: { item: BookingSessionListItemDto; evv: R
<Stack sx={{ gap: 0.75 }}> <Stack sx={{ gap: 0.75 }}>
<SessionCard <SessionCard
title={item.patientName} title={item.patientName}
serviceLabel={item.variantLabel}
sessionIndex={item.sessionIndex} sessionIndex={item.sessionIndex}
scheduledDate={item.scheduledDate} scheduledDate={item.scheduledDate}
scheduledTimeStart={item.scheduledTimeStart} scheduledTimeStart={item.scheduledTimeStart}
@@ -12,10 +12,14 @@ export interface EarningsBalanceHeaderProps {
summary: NurseEarningsSummary; summary: NurseEarningsSummary;
} }
/** The four money-bearing (guaranteed non-null) roll-up fields the bucket grid renders narrower than
* `keyof NurseEarningsSummary` so it excludes the optional ui-phase-7 forecast fields. */
type EarningsBucketAmountKey = 'pendingTotalIrr' | 'eligibleTotalIrr' | 'paidTotalIrr' | 'clawbackOutstandingIrr';
/** The four roll-up buckets — each keyed to a semantic tone + icon so the states read at a glance. */ /** The four roll-up buckets — each keyed to a semantic tone + icon so the states read at a glance. */
const BUCKETS: ReadonlyArray<{ const BUCKETS: ReadonlyArray<{
key: 'pending' | 'eligible' | 'paid' | 'clawback'; key: 'pending' | 'eligible' | 'paid' | 'clawback';
amountKey: keyof NurseEarningsSummary; amountKey: EarningsBucketAmountKey;
tone: AccentTone; tone: AccentTone;
token: string; token: string;
icon: string; icon: string;
@@ -56,10 +56,13 @@ describe('<PayoutHistoryRow/> component', () => {
expect(screen.getByText('IR••••4821')).toBeInTheDocument(); expect(screen.getByText('IR••••4821')).toBeInTheDocument();
}); });
it('surfaces a failed payout reason as a read-only banner with no retry control', () => { it('surfaces a mapped failure label as the headline and the raw code as a secondary LTR caption', () => {
renderRow(makeItem('failed')); renderRow(makeItem('failed'));
expect(screen.getByText('failure_title')).toBeInTheDocument(); expect(screen.getByText('failure_title')).toBeInTheDocument();
expect(screen.getByText(/invalid_sheba/)).toBeInTheDocument(); // The headline is the mapped Persian/English label (never the raw vendor string).
expect(screen.getByText('failure_code_invalid_sheba')).toBeInTheDocument();
// The raw code still appears, demoted to a secondary dir="ltr" caption.
expect(screen.getByText('invalid_sheba')).toHaveAttribute('dir', 'ltr');
// No retry affordance for the nurse — only the "view detail" link exists. // No retry affordance for the nurse — only the "view detail" link exists.
expect(screen.queryByText('retry')).not.toBeInTheDocument(); expect(screen.queryByText('retry')).not.toBeInTheDocument();
}); });
@@ -9,6 +9,7 @@ import SurfaceCard from '@/components/common/SurfaceCard';
import AccentCard from '@/components/common/AccentCard'; import AccentCard from '@/components/common/AccentCard';
import Money from '@/components/common/Money'; import Money from '@/components/common/Money';
import { formatShamsiDate } from '@/utils'; import { formatShamsiDate } from '@/utils';
import { failureReasonLabelKey } from '@/services/payouts/failureReasons';
import type { NursePayoutHistoryItem, PayoutStatus } from '@/services/payouts/types'; import type { NursePayoutHistoryItem, PayoutStatus } from '@/services/payouts/types';
export interface PayoutHistoryRowProps { export interface PayoutHistoryRowProps {
@@ -82,9 +83,12 @@ const PayoutHistoryRow: FunctionComponent<PayoutHistoryRowProps> = ({ item, onOp
{t('failure_title')} {t('failure_title')}
</Typography> </Typography>
</Stack> </Stack>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t(failureReasonLabelKey(item.failureReason))}
</Typography>
{item.failureReason ? ( {item.failureReason ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }} dir="ltr"> <Typography variant="caption" sx={{ color: 'text.secondary' }} dir="ltr">
{t('failure_reason_label')}: {item.failureReason} {item.failureReason}
</Typography> </Typography>
) : null} ) : null}
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}> <Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
@@ -18,8 +18,10 @@ import BookingStatusTimeline from '../BookingStatusTimeline';
import SessionList from '../SessionList'; import SessionList from '../SessionList';
import BookingMoneySummary from '../BookingMoneySummary'; import BookingMoneySummary from '../BookingMoneySummary';
import CareInstructionsCard from '../CareInstructionsCard'; import CareInstructionsCard from '../CareInstructionsCard';
import CheckOutConfirmButton from '../CheckOutConfirmButton';
import { useEvvController } from '../useEvvController'; import { useEvvController } from '../useEvvController';
import { buildSessionIcs, downloadIcsFile } from '../ics'; import { buildSessionIcs, downloadIcsFile } from '../ics';
import { formatElapsed } from '../format';
export interface BookingDetailViewProps { export interface BookingDetailViewProps {
bookingId: number; bookingId: number;
@@ -64,11 +66,39 @@ function upcomingSession(sessions: BookingSessionDto[]): BookingSessionDto | und
return sessions.find((session) => session.status === 'scheduled' || session.status === 'in_progress'); return sessions.find((session) => session.status === 'scheduled' || session.status === 'in_progress');
} }
/** The session currently checked in (on-site) — drives the EVV presence headline. */ /** The session currently checked in (on-site) — drives the EVV presence headline + the in-visit banner. */
function checkedInSession(sessions: BookingSessionDto[]): BookingSessionDto | undefined { function checkedInSession(sessions: BookingSessionDto[]): BookingSessionDto | undefined {
return sessions.find((session) => session.evvStatus === 'checked_in'); return sessions.find((session) => session.evvStatus === 'checked_in');
} }
/**
* Best-effort lat/lng off the same untyped address snapshot `addressSnapshotLabel` reads (REQ-045 proposes
* a typed shape). `null` when absent the map link is a quiet enhancement, never required.
*/
function addressSnapshotCoords(snapshotJson: string | null): { lat: number; lng: number } | null {
if (!snapshotJson) return null;
try {
const parsed = JSON.parse(snapshotJson) as Record<string, unknown>;
const lat = Number(parsed.latitude ?? parsed.lat);
const lng = Number(parsed.longitude ?? parsed.lng);
return Number.isFinite(lat) && Number.isFinite(lng) ? { lat, lng } : null;
} catch {
return null;
}
}
const NESHAN_MAP_ZOOM = 16;
/** A `geo:` URI — the mobile-native map-app intent (Android resolves it directly). */
function buildGeoUri(lat: number, lng: number): string {
return `geo:${lat},${lng}?q=${lat},${lng}`;
}
/** The web fallback for platforms with no `geo:` handler (desktop, iOS Safari) — link only, no SDK/API key. */
function buildNeshanWebUrl(lat: number, lng: number): string {
return `https://neshan.org/maps/@${lat},${lng},${NESHAN_MAP_ZOOM}z`;
}
/** /**
* The both-roles booking detail the hinge screen. Fetches `useBookingDetail`, renders the server-truth * The both-roles booking detail the hinge screen. Fetches `useBookingDetail`, renders the server-truth
* `BookingStatusTimeline`, the `SessionList`, and the `BookingMoneySummary`. Role-conditioned: the * `BookingStatusTimeline`, the `SessionList`, and the `BookingMoneySummary`. Role-conditioned: the
@@ -97,7 +127,8 @@ const BookingDetailView: FunctionComponent<BookingDetailViewProps> = ({ bookingI
const service = variantName(booking.variantSnapshotJson); const service = variantName(booking.variantSnapshotJson);
const addressLabel = addressSnapshotLabel(booking.addressSnapshotJson, locale); const addressLabel = addressSnapshotLabel(booking.addressSnapshotJson, locale);
const nextSession = upcomingSession(booking.sessions); const nextSession = upcomingSession(booking.sessions);
const onSiteCheckInAt = checkedInSession(booking.sessions)?.checkInAt ?? null; const checkedIn = checkedInSession(booking.sessions);
const onSiteCheckInAt = checkedIn?.checkInAt ?? null;
const clockFmt = new Intl.DateTimeFormat(localeTag(locale), { hour: '2-digit', minute: '2-digit' }); const clockFmt = new Intl.DateTimeFormat(localeTag(locale), { hour: '2-digit', minute: '2-digit' });
const nextSessionDayLabel = nextSession const nextSessionDayLabel = nextSession
@@ -175,15 +206,6 @@ const BookingDetailView: FunctionComponent<BookingDetailViewProps> = ({ bookingI
</Stack> </Stack>
) : null} ) : null}
{addressLabel ? (
<Stack direction="row" sx={{ gap: 1, alignItems: 'flex-start' }}>
<AppIcon icon="location" size={18} color="var(--bal-text-secondary)" />
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{addressLabel}
</Typography>
</Stack>
) : null}
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center', flexWrap: 'wrap' }}> <Stack direction="row" sx={{ gap: 1.5, alignItems: 'center', flexWrap: 'wrap' }}>
<Avatar sx={{ width: 36, height: 36, bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 700 }}> <Avatar sx={{ width: 36, height: 36, bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 700 }}>
{(booking.nurseName.trim() || t('unnamed_nurse')).charAt(0)} {(booking.nurseName.trim() || t('unnamed_nurse')).charAt(0)}
@@ -207,6 +229,18 @@ const BookingDetailView: FunctionComponent<BookingDetailViewProps> = ({ bookingI
</Stack> </Stack>
</Paper> </Paper>
<AddressCard addressSnapshotJson={booking.addressSnapshotJson} viewerRole={viewerRole} locale={locale} />
{isNurse && checkedIn ? (
<InVisitBanner
checkInAt={checkedIn.checkInAt as string}
locale={locale}
busy={evv.busySessionId === checkedIn.id}
acquiringLocation={evv.acquiringSessionId === checkedIn.id}
onCheckOut={() => evv.checkOut({ sessionId: checkedIn.id, bookingId: booking.id })}
/>
) : null}
<SurfaceCard> <SurfaceCard>
<BookingStatusTimeline status={booking.status} /> <BookingStatusTimeline status={booking.status} />
</SurfaceCard> </SurfaceCard>
@@ -240,6 +274,124 @@ const BookingDetailView: FunctionComponent<BookingDetailViewProps> = ({ bookingI
); );
}; };
/**
* The frozen visit address a map deep-link when the snapshot carries coordinates, a quiet fallback note
* on the (currently masked, REQ-051-pending) nurse view instead of a hard error, and nothing at all when
* there is genuinely no snapshot on the customer side (mock-tolerant: renders when present, degrades
* quietly otherwise). Never sourced from the pre-payment (b8) request stage.
*/
function AddressCard({
addressSnapshotJson,
viewerRole,
locale,
}: {
addressSnapshotJson: string | null;
viewerRole: BookingViewerRole;
locale: string;
}) {
const t = useTranslations('booking');
const label = addressSnapshotLabel(addressSnapshotJson, locale);
const coords = addressSnapshotCoords(addressSnapshotJson);
const isNurse = viewerRole === 'nurse';
if (!label) {
if (!isNurse) return null;
return (
<AccentCard tone="neutral">
<Stack direction="row" sx={{ gap: 1, alignItems: 'flex-start' }}>
<AppIcon icon="location" size={18} color="var(--bal-text-secondary)" />
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('address_pending_note')}
</Typography>
</Stack>
</AccentCard>
);
}
return (
<SurfaceCard>
<Stack sx={{ gap: 1 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon="location" size={18} color="var(--bal-primary)" />
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('address_title')}
</Typography>
</Stack>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{label}
</Typography>
{coords ? (
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
<AppButton
variant="outlined"
color="primary"
size="small"
startIcon="navigate"
href={buildGeoUri(coords.lat, coords.lng)}
sx={{ alignSelf: 'flex-start' }}
>
{t('address_open_map')}
</AppButton>
<AppButton
variant="text"
color="primary"
size="small"
href={buildNeshanWebUrl(coords.lat, coords.lng)}
openInNewTab
sx={{ alignSelf: 'flex-start' }}
>
{t('address_open_web_map')}
</AppButton>
</Stack>
) : null}
</Stack>
</SurfaceCard>
);
}
/**
* The nurse-only "on site now" state header promotes the check-out action above the session list (it
* ends the visit and starts the payout clock) alongside elapsed on-site time. `checkInAt` is server truth;
* the elapsed duration is computed against the render-time clock (never a guessed start).
*/
function InVisitBanner({
checkInAt,
locale,
busy,
acquiringLocation,
onCheckOut,
}: {
checkInAt: string;
locale: string;
busy: boolean;
acquiringLocation: boolean;
onCheckOut: () => void;
}) {
const t = useTranslations('booking');
const elapsed = formatElapsed(checkInAt, new Date().toISOString(), locale);
return (
<AccentCard tone="secondary">
<Stack sx={{ gap: 1.5 }}>
<Stack direction="row" sx={{ gap: 1.25, alignItems: 'center' }}>
<AppIcon icon="check_in" size={20} color="var(--bal-secondary)" />
<Stack sx={{ gap: 0.25 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('in_visit_title')}
</Typography>
{elapsed ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('in_visit_elapsed', { duration: elapsed })}
</Typography>
) : null}
</Stack>
</Stack>
<CheckOutConfirmButton onConfirm={onCheckOut} busy={busy} acquiringLocation={acquiringLocation} fullWidth />
</Stack>
</AccentCard>
);
}
function HeaderFact({ label, value }: { label: string; value: string }) { function HeaderFact({ label, value }: { label: string; value: string }) {
return ( return (
<Stack sx={{ gap: 0.25 }}> <Stack sx={{ gap: 0.25 }}>
@@ -0,0 +1,34 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
jest.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
}));
import CheckOutConfirmButton from './CheckOutConfirmButton';
describe('<CheckOutConfirmButton/>', () => {
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
it('does not fire onConfirm on the first tap — it opens a confirm dialog', () => {
const onConfirm = jest.fn();
wrap(<CheckOutConfirmButton onConfirm={onConfirm} />);
fireEvent.click(screen.getByText('evv_check_out'));
expect(onConfirm).not.toHaveBeenCalled();
expect(screen.getByText('evv_checkout_confirm_title')).toBeInTheDocument();
});
it('fires onConfirm only after the dialog is confirmed', () => {
const onConfirm = jest.fn();
wrap(<CheckOutConfirmButton onConfirm={onConfirm} />);
fireEvent.click(screen.getByText('evv_check_out'));
fireEvent.click(screen.getByText('evv_checkout_confirm_cta'));
expect(onConfirm).toHaveBeenCalledTimes(1);
});
it('disables the button while busy and shows the checking-out label', () => {
wrap(<CheckOutConfirmButton onConfirm={jest.fn()} busy />);
expect(screen.getByText('evv_checking_out')).toBeInTheDocument();
expect(screen.getByText('evv_checking_out').closest('button')).toBeDisabled();
});
});
@@ -0,0 +1,69 @@
'use client';
import { FunctionComponent, useState } from 'react';
import { useTranslations } from 'next-intl';
import AppButton from '@/components/common/AppButton';
import ConfirmDialog from '@/components/common/ConfirmDialog';
export interface CheckOutConfirmButtonProps {
/** Fires the real check-out mutation — called only after the nurse confirms. */
onConfirm: () => void;
/** The check-out mutation is in flight for this session. */
busy?: boolean;
/** Acquiring GPS for this session (pre-mutation). */
acquiringLocation?: boolean;
/** Renders as the full-width thumb-reach hero action (the day surface + the in-visit banner). */
fullWidth?: boolean;
variant?: 'contained' | 'outlined';
}
/**
* The EVV check-out action, everywhere it appears (`SessionCard`'s hero CTA and `BookingDetailView`'s
* promoted in-visit banner): a single tap opens a lightweight confirm check-out ends the visit and
* starts the payout clock, so a mis-tap is consequential enough to warrant one extra step, but never a
* multi-field form. `onConfirm` fires the real mutation only after the nurse confirms.
* @component CheckOutConfirmButton
*/
const CheckOutConfirmButton: FunctionComponent<CheckOutConfirmButtonProps> = ({
onConfirm,
busy = false,
acquiringLocation = false,
fullWidth = false,
variant = 'contained',
}) => {
const t = useTranslations('booking');
const [confirmOpen, setConfirmOpen] = useState(false);
const disabled = busy || acquiringLocation;
return (
<>
<AppButton
color="secondary"
variant={variant}
startIcon={acquiringLocation ? 'gps' : 'check_out'}
disabled={disabled}
onClick={() => setConfirmOpen(true)}
sx={
fullWidth
? { width: '100%', minHeight: 48, py: 1.5 }
: { alignSelf: 'flex-start', py: 1 }
}
>
{acquiringLocation ? t('evv_acquiring_location') : busy ? t('evv_checking_out') : t('evv_check_out')}
</AppButton>
<ConfirmDialog
open={confirmOpen}
title={t('evv_checkout_confirm_title')}
body={t('evv_checkout_confirm_body')}
confirmLabel={t('evv_checkout_confirm_cta')}
cancelLabel={t('evv_checkout_confirm_cancel')}
onClose={() => setConfirmOpen(false)}
onConfirm={() => {
setConfirmOpen(false);
onConfirm();
}}
/>
</>
);
};
export default CheckOutConfirmButton;
@@ -61,4 +61,24 @@ describe('<SessionCard/> component', () => {
renderCard({ showEvvControls: true, acquiringLocation: true }); renderCard({ showEvvControls: true, acquiringLocation: true });
expect(screen.getByText('evv_acquiring_location')).toBeInTheDocument(); expect(screen.getByText('evv_acquiring_location')).toBeInTheDocument();
}); });
it('requires a confirm step before firing check-out', () => {
const onCheckOut = jest.fn();
renderCard({
showEvvControls: true,
status: 'in_progress',
evvStatus: 'checked_in',
checkInAt: '2026-08-01T09:02:00.000Z',
onCheckOut,
});
fireEvent.click(screen.getByText('evv_check_out'));
expect(onCheckOut).not.toHaveBeenCalled();
fireEvent.click(screen.getByText('evv_checkout_confirm_cta'));
expect(onCheckOut).toHaveBeenCalledTimes(1);
});
it('renders the service label under the title when present', () => {
renderCard({ title: 'حاج‌آقا موسوی', serviceLabel: 'مراقبت سالمند — شیفت روز' });
expect(screen.getByText('مراقبت سالمند — شیفت روز')).toBeInTheDocument();
});
}); });
@@ -6,13 +6,21 @@ import { AppButton, Money, SurfaceCard } from '@/components/common';
import StatusChip from '@/components/StatusChip'; import StatusChip from '@/components/StatusChip';
import { formatShamsiDate } from '@/utils'; import { formatShamsiDate } from '@/utils';
import type { BookingSessionStatus, VisitVerificationStatus } from '@/services/bookings/types'; import type { BookingSessionStatus, VisitVerificationStatus } from '@/services/bookings/types';
import CheckOutConfirmButton from '../CheckOutConfirmButton';
import EvvStatusBanner from '../EvvStatusBanner'; import EvvStatusBanner from '../EvvStatusBanner';
import { formatClock, formatElapsed, formatTimeRange } from '../format'; import { formatClock, formatElapsed, formatTimeRange } from '../format';
import { SESSION_STATUS_KIND } from '../statusKind'; import { SESSION_STATUS_KIND } from '../statusKind';
/** `onCheckOut` is optional on the props (the customer view never wires it) falls back to a no-op so
* `CheckOutConfirmButton` always has a callable `onConfirm`, matching the prior bare-button behavior. */
function noop(): void {}
export interface SessionCardProps { export interface SessionCardProps {
/** Optional heading above the visit row — the patient name on the nurse's day feed (omitted in the detail). */ /** Optional heading above the visit row — the patient name on the nurse's day feed (omitted in the detail). */
title?: string; title?: string;
/** Optional service/variant label under the title the day surface's decision-first context (ui-phase-7,
* REQ-052); omit where the caller has no variant field (mock-tolerant, never fetched per-row). */
serviceLabel?: string | null;
sessionIndex: number; sessionIndex: number;
/** ISO date `YYYY-MM-DD`. */ /** ISO date `YYYY-MM-DD`. */
scheduledDate: string; scheduledDate: string;
@@ -49,6 +57,7 @@ export interface SessionCardProps {
*/ */
const SessionCard: FunctionComponent<SessionCardProps> = ({ const SessionCard: FunctionComponent<SessionCardProps> = ({
title, title,
serviceLabel,
sessionIndex, sessionIndex,
scheduledDate, scheduledDate,
scheduledTimeStart, scheduledTimeStart,
@@ -79,9 +88,16 @@ const SessionCard: FunctionComponent<SessionCardProps> = ({
<SurfaceCard padding="sm" data-session-status={status}> <SurfaceCard padding="sm" data-session-status={status}>
<Stack sx={{ gap: 1.25 }}> <Stack sx={{ gap: 1.25 }}>
{title ? ( {title ? (
<Stack sx={{ gap: 0.25 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}> <Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{title} {title}
</Typography> </Typography>
{serviceLabel ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{serviceLabel}
</Typography>
) : null}
</Stack>
) : null} ) : null}
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1 }}> <Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: title ? 500 : 700, color: title ? 'text.secondary' : 'text.primary' }}> <Typography variant="subtitle2" sx={{ fontWeight: title ? 500 : 700, color: title ? 'text.secondary' : 'text.primary' }}>
@@ -116,23 +132,19 @@ const SessionCard: FunctionComponent<SessionCardProps> = ({
startIcon={acquiringLocation ? 'gps' : 'check_in'} startIcon={acquiringLocation ? 'gps' : 'check_in'}
disabled={busy} disabled={busy}
onClick={onCheckIn} onClick={onCheckIn}
sx={{ alignSelf: 'flex-start', py: 1 }} sx={{ width: '100%', minHeight: 48, py: 1.5 }}
> >
{acquiringLocation ? t('evv_acquiring_location') : evvPending ? t('evv_checking_in') : t('evv_check_in')} {acquiringLocation ? t('evv_acquiring_location') : evvPending ? t('evv_checking_in') : t('evv_check_in')}
</AppButton> </AppButton>
) : null} ) : null}
{showEvvControls && status === 'in_progress' && evvStatus === 'checked_in' ? ( {showEvvControls && status === 'in_progress' && evvStatus === 'checked_in' ? (
<AppButton <CheckOutConfirmButton
color="secondary" onConfirm={onCheckOut ?? noop}
variant="outlined" busy={evvPending}
startIcon={acquiringLocation ? 'gps' : 'check_out'} acquiringLocation={acquiringLocation}
disabled={busy} fullWidth
onClick={onCheckOut} />
sx={{ alignSelf: 'flex-start', py: 1 }}
>
{acquiringLocation ? t('evv_acquiring_location') : evvPending ? t('evv_checking_out') : t('evv_check_out')}
</AppButton>
) : null} ) : null}
</Stack> </Stack>
</SurfaceCard> </SurfaceCard>
+2
View File
@@ -16,6 +16,8 @@ export { default as EvvStatusBanner } from './EvvStatusBanner';
export type { EvvStatusBannerProps, EvvMatchState } from './EvvStatusBanner'; export type { EvvStatusBannerProps, EvvMatchState } from './EvvStatusBanner';
export { default as CareInstructionsCard } from './CareInstructionsCard'; export { default as CareInstructionsCard } from './CareInstructionsCard';
export type { CareInstructionsCardProps } from './CareInstructionsCard'; export type { CareInstructionsCardProps } from './CareInstructionsCard';
export { default as CheckOutConfirmButton } from './CheckOutConfirmButton';
export type { CheckOutConfirmButtonProps } from './CheckOutConfirmButton';
export { default as BookingMoneySummary } from './BookingMoneySummary'; export { default as BookingMoneySummary } from './BookingMoneySummary';
export type { BookingMoneySummaryProps } from './BookingMoneySummary'; export type { BookingMoneySummaryProps } from './BookingMoneySummary';
export { useEvvController } from './useEvvController'; export { useEvvController } from './useEvvController';
@@ -0,0 +1,35 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { ThemeProvider } from '../../../theme';
jest.mock('next-intl', () => ({
useTranslations: () => (key: string, values?: Record<string, unknown>) =>
values ? `${key}:${JSON.stringify(values)}` : key,
useLocale: () => 'en',
}));
import Pager from './Pager';
describe('<Pager/>', () => {
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
it('renders nothing for a single page', () => {
const { container } = wrap(<Pager page={1} pageCount={1} onPrev={jest.fn()} onNext={jest.fn()} />);
expect(container).toBeEmptyDOMElement();
});
it('disables prev on the first page and next on the last', () => {
wrap(<Pager page={1} pageCount={3} onPrev={jest.fn()} onNext={jest.fn()} />);
expect(screen.getByText('page_prev')).toBeDisabled();
expect(screen.getByText('page_next')).not.toBeDisabled();
});
it('fires onPrev/onNext', () => {
const onPrev = jest.fn();
const onNext = jest.fn();
wrap(<Pager page={2} pageCount={3} onPrev={onPrev} onNext={onNext} />);
fireEvent.click(screen.getByText('page_prev'));
fireEvent.click(screen.getByText('page_next'));
expect(onPrev).toHaveBeenCalledTimes(1);
expect(onNext).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,43 @@
'use client';
import { FunctionComponent } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import AppButton from '../AppButton';
import { formatNumber } from '@/utils';
export interface PagerProps {
page: number;
pageCount: number;
onPrev: () => void;
onNext: () => void;
}
/**
* The shared prev/next pager for a paginated list locale-digit "page X of Y", disabled at either edge,
* renders nothing for a single page. Replaces the near-identical inline pagers hand-rolled per list
* screen (earnings, payout history, the nurse inbox tabs).
* @component Pager
*/
const Pager: FunctionComponent<PagerProps> = ({ page, pageCount, onPrev, onNext }) => {
const t = useTranslations('common');
const locale = useLocale();
if (pageCount <= 1) return null;
const fmt = (n: number) => formatNumber(n, locale);
return (
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'center' }}>
<AppButton variant="text" color="primary" onClick={onPrev} disabled={page <= 1}>
{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}>
{t('page_next')}
</AppButton>
</Stack>
);
};
export default Pager;
@@ -0,0 +1,4 @@
import Pager from './Pager';
export default Pager;
export type { PagerProps } from './Pager';
+3
View File
@@ -19,6 +19,7 @@ import JalaliDateField from './JalaliDateField';
import JalaliDateIntentPicker from './JalaliDateIntentPicker'; import JalaliDateIntentPicker from './JalaliDateIntentPicker';
import LocaleSwitcher from './LocaleSwitcher'; import LocaleSwitcher from './LocaleSwitcher';
import StickyActionBar from './StickyActionBar'; import StickyActionBar from './StickyActionBar';
import Pager from './Pager';
export { export {
ErrorBoundary, ErrorBoundary,
@@ -42,6 +43,7 @@ export {
JalaliDateIntentPicker, JalaliDateIntentPicker,
LocaleSwitcher, LocaleSwitcher,
StickyActionBar, StickyActionBar,
Pager,
}; };
export type { EmptyStateProps } from './EmptyState'; export type { EmptyStateProps } from './EmptyState';
export type { ErrorStateProps } from './ErrorState'; export type { ErrorStateProps } from './ErrorState';
@@ -56,3 +58,4 @@ export type { JalaliDatePickerProps } from './JalaliDatePicker';
export type { JalaliDateFieldProps } from './JalaliDateField'; export type { JalaliDateFieldProps } from './JalaliDateField';
export type { JalaliDateIntentPickerProps } from './JalaliDateIntentPicker'; export type { JalaliDateIntentPickerProps } from './JalaliDateIntentPicker';
export type { StickyActionBarProps } from './StickyActionBar'; export type { StickyActionBarProps } from './StickyActionBar';
export type { PagerProps } from './Pager';
@@ -132,6 +132,11 @@ function toListItem(dto: BookingRequestDto, role: RequestRole): BookingRequestLi
nurseResponseDeadlineAt: dto.nurseResponseDeadlineAt, nurseResponseDeadlineAt: dto.nurseResponseDeadlineAt,
paymentDeadlineAt: dto.paymentDeadlineAt, paymentDeadlineAt: dto.paymentDeadlineAt,
customerNotes: role === 'nurse' ? dto.customerNotes : null, customerNotes: role === 'nurse' ? dto.customerNotes : null,
// ui-phase-7 (REQ-050): the nurse inbox is decision-first — service + price on the row itself. The
// real list DTO doesn't carry these yet; only the mock stamps them (the detail DTO always has them).
variantLabel: dto.variantLabel,
variantPrice: dto.variantPrice,
variantPriceUnit: dto.variantPriceUnit,
}; };
} }
@@ -0,0 +1,18 @@
const MINUTES_PER_HOUR = 60;
/**
* Humanized minutes-remaining copy above `CountdownTimer`'s coarse threshold («حدود ۳ ساعت» / «حدود ۲۵
* دقیقه»). Shared by the customer C5 response countdown and the nurse inbox's urgency-tinted countdown
* pill (ui-phase-7) both count down the same `nurseResponseDeadlineAt`, so the humanized framing must
* read identically in both places. `t` is the `booking` namespace translator (`countdown_about_hours`/
* `countdown_about_minutes`).
*/
export function coarseResponseLabel(
minutes: number,
t: (key: string, values?: Record<string, number>) => string,
): string {
if (minutes >= MINUTES_PER_HOUR) {
return t('countdown_about_hours', { hours: Math.round(minutes / MINUTES_PER_HOUR) });
}
return t('countdown_about_minutes', { minutes });
}
@@ -13,15 +13,21 @@ import type { BookingRequestStatus } from '../types';
/** /**
* The nurse incoming-requests inbox (`list?role=nurse`), defaulting to `pending_nurse_response`. Lightly * The nurse incoming-requests inbox (`list?role=nurse`), defaulting to `pending_nurse_response`. Lightly
* polls so newly-arrived requests appear without a manual refresh; every accept/reject invalidates this * polls so newly-arrived requests appear without a manual refresh; every accept/reject invalidates this
* list so an actioned request leaves the pending inbox immediately. * list so an actioned request leaves the pending inbox immediately. `options.enabled` (default `true`)
* lets a multi-tab inbox (ui-phase-7) mount every status query up front without polling tabs the nurse
* isn't currently viewing.
*/ */
export function useNurseRequestInbox(status: BookingRequestStatus | undefined = 'pending_nurse_response', page = 1) { export function useNurseRequestInbox(
status: BookingRequestStatus | undefined = 'pending_nurse_response',
page = 1,
options?: { enabled?: boolean },
) {
const isAuthenticated = useIsAuthenticated(); const isAuthenticated = useIsAuthenticated();
const params = { role: 'nurse' as const, status, page, pageSize: BOOKING_REQUEST_PAGE_SIZE }; const params = { role: 'nurse' as const, status, page, pageSize: BOOKING_REQUEST_PAGE_SIZE };
return useQuery({ return useQuery({
queryKey: bookingRequestKeys.nurseInbox(params), queryKey: bookingRequestKeys.nurseInbox(params),
queryFn: () => bookingRequestsApi.list(params), queryFn: () => bookingRequestsApi.list(params),
enabled: isAuthenticated, enabled: isAuthenticated && (options?.enabled ?? true),
staleTime: BOOKING_REQUEST_STALE_TIME, staleTime: BOOKING_REQUEST_STALE_TIME,
gcTime: BOOKING_REQUEST_GC_TIME, gcTime: BOOKING_REQUEST_GC_TIME,
refetchInterval: BOOKING_REQUEST_POLL_MS, refetchInterval: BOOKING_REQUEST_POLL_MS,
@@ -129,6 +129,14 @@ export interface BookingRequestListItem {
paymentDeadlineAt: string | null; paymentDeadlineAt: string | null;
/** Nurse view only (stage-1 plaintext); `null` in the customer inbox. */ /** Nurse view only (stage-1 plaintext); `null` in the customer inbox. */
customerNotes: string | null; customerNotes: string | null;
/**
* Client-augmented (ui-phase-7, REQ-050) the list DTO carries no variant fields today. `undefined` on
* the real path until the field lands; the nurse-inbox card renders the decision-first headline when
* present and degrades to the patient-name headline otherwise (mock-tolerant, never fetched per-row).
*/
variantLabel?: string | null;
variantPrice?: string | null;
variantPriceUnit?: PriceUnit | null;
} }
/** The `booking_requests/create` body (contract). Money-free; ids come from search/patients/addresses. */ /** The `booking_requests/create` body (contract). Money-free; ids come from search/patients/addresses. */
+24 -2
View File
@@ -21,6 +21,7 @@ import type {
TodaySessionsParams, TodaySessionsParams,
VisitVerificationDto, VisitVerificationDto,
} from '../types'; } from '../types';
import { isBookingConfirmedOrBeyond } from '../types';
const MOCK_LATENCY_MS = 350; const MOCK_LATENCY_MS = 350;
@@ -37,6 +38,16 @@ function isoDate(daysFromToday: number): string {
return d.toISOString().slice(0, 10); return d.toISOString().slice(0, 10);
} }
/** Best-effort read of the frozen variant display name from a booking's variant snapshot (mock-only). */
function variantDisplayName(snapshotJson: string): string | null {
try {
const parsed = JSON.parse(snapshotJson) as { displayName?: string };
return parsed?.displayName ?? null;
} catch {
return null;
}
}
/** Haversine distance in metres — the mock stand-in for the server's address-match math. */ /** Haversine distance in metres — the mock stand-in for the server's address-match math. */
function distanceMeters(lat1: number, lng1: number, lat2: number, lng2: number): number { function distanceMeters(lat1: number, lng1: number, lat2: number, lng2: number): number {
const R = 6_371_000; const R = 6_371_000;
@@ -80,6 +91,10 @@ function seed(): void {
district: 'سعادت‌آباد', district: 'سعادت‌آباد',
line: 'خیابان نمونه، کوچه دوم، پلاک ۱۲', line: 'خیابان نمونه، کوچه دوم، پلاک ۱۲',
postalCode: '1998887766', postalCode: '1998887766',
// Matches the EVV mock's reference point (MOCK_EVV_REFERENCE_LAT/LNG) so the address-card map link and
// the in-range check-in demo point at the same spot (ui-phase-7 — REQ-051's map deep-link).
latitude: MOCK_EVV_REFERENCE_LAT,
longitude: MOCK_EVV_REFERENCE_LNG,
}); });
const addr5002 = JSON.stringify({ const addr5002 = JSON.stringify({
title: 'آپارتمان', title: 'آپارتمان',
@@ -352,10 +367,15 @@ function findSession(sessionId: number): { booking: BookingDetailDto; session: B
throw new ApiError(404, 'Session not found', 'not_found'); throw new ApiError(404, 'Session not found', 'not_found');
} }
/** The nurse view omits the full address snapshot (two-stage disclosure — coarse context only). */ /**
* The nurse view gets the full address snapshot only once the booking is `confirmed`+ (ui-phase-7,
* REQ-051 a deliberate b9 contract change the mock simulates ahead of the real endpoint; the real
* `clientApi` still masks it until the backend delivers the request, so the nurse UI stays mock-tolerant).
* Pre-confirmation there is no address to leak anyway (two-stage disclosure coarse context only).
*/
function forViewer(b: BookingDetailDto, viewerRole: BookingViewerRole | undefined): BookingDetailDto { function forViewer(b: BookingDetailDto, viewerRole: BookingViewerRole | undefined): BookingDetailDto {
const clone = cloneBooking(b); const clone = cloneBooking(b);
if (viewerRole === 'nurse') clone.addressSnapshotJson = null; if (viewerRole === 'nurse' && !isBookingConfirmedOrBeyond(b.status)) clone.addressSnapshotJson = null;
return clone; return clone;
} }
@@ -430,6 +450,8 @@ export const bookingsMockApi: BookingsApi = {
scheduledTimeEnd: session.scheduledTimeEnd, scheduledTimeEnd: session.scheduledTimeEnd,
status: session.status, status: session.status,
evvStatus: session.evvStatus, evvStatus: session.evvStatus,
// ui-phase-7 (REQ-052): the real `booking_sessions/today` row carries no service field yet.
variantLabel: variantDisplayName(booking.variantSnapshotJson),
}); });
} }
} }
@@ -27,6 +27,13 @@ export const BOOKING_LIST_STALE_TIME = 30 * 1000;
/** A nurse's "today" feed changes as they clock in/out — kept fresh, invalidated on every EVV mutation. */ /** A nurse's "today" feed changes as they clock in/out — kept fresh, invalidated on every EVV mutation. */
export const TODAY_SESSIONS_STALE_TIME = 15 * 1000; export const TODAY_SESSIONS_STALE_TIME = 15 * 1000;
/**
* A same-day schedule change (a new request converts, an admin reschedules) otherwise never appears on
* the day surface without a manual re-navigation the EVV-mutation invalidation only covers the nurse's
* own check-in/out. A modest poll closes that gap without hammering the endpoint (ui-phase-7 §3.2).
*/
export const TODAY_SESSIONS_REFETCH_MS = 60 * 1000;
/** Per-session EVV detail is immutable once completed; a short window covers the checked-in interval. */ /** Per-session EVV detail is immutable once completed; a short window covers the checked-in interval. */
export const SESSION_EVV_STALE_TIME = 15 * 1000; export const SESSION_EVV_STALE_TIME = 15 * 1000;
@@ -1,7 +1,7 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { bookingsApi } from '../apis'; import { bookingsApi } from '../apis';
import { bookingKeys } from '../keys'; import { bookingKeys } from '../keys';
import { BOOKINGS_PAGE_SIZE, TODAY_SESSIONS_STALE_TIME } from '../constants'; import { BOOKINGS_PAGE_SIZE, TODAY_SESSIONS_REFETCH_MS, TODAY_SESSIONS_STALE_TIME } from '../constants';
import type { TodaySessionsParams } from '../types'; import type { TodaySessionsParams } from '../types';
/** /**
@@ -19,5 +19,6 @@ export function useTodaySessions(options?: { date?: string; page?: number; pageS
queryKey: bookingKeys.today(params), queryKey: bookingKeys.today(params),
queryFn: () => bookingsApi.listTodaySessions(params), queryFn: () => bookingsApi.listTodaySessions(params),
staleTime: TODAY_SESSIONS_STALE_TIME, staleTime: TODAY_SESSIONS_STALE_TIME,
refetchInterval: TODAY_SESSIONS_REFETCH_MS,
}); });
} }
+7
View File
@@ -180,6 +180,13 @@ export interface BookingSessionListItemDto {
scheduledTimeEnd: string; scheduledTimeEnd: string;
status: BookingSessionStatus; status: BookingSessionStatus;
evvStatus: VisitVerificationStatus; evvStatus: VisitVerificationStatus;
/**
* Client-augmented (ui-phase-7, REQ-052) `BookingSessionListItemDto` carries no service/variant field
* today. `undefined` on the real path until the field lands; the day surface renders it when present and
* degrades to patient name + visit index otherwise (mock-tolerant, never fetched per-row that would be
* an N+1 against the booking detail).
*/
variantLabel?: string | null;
} }
/** /**
@@ -296,6 +296,11 @@ const DETAILS: Record<number, NursePayoutDetail> = {
* `pending + eligible clawbackOutstanding` (accrued-unpaid earnings minus receivables), computed with * `pending + eligible clawbackOutstanding` (accrued-unpaid earnings minus receivables), computed with
* BigInt and **not clamped** under `clawback_heavy` it goes negative ("owed back"). `paid` never enters * BigInt and **not clamped** under `clawback_heavy` it goes negative ("owed back"). `paid` never enters
* the net balance (it already left the ledger). * the net balance (it already left the ledger).
*
* `nextPayoutDate`/`nextPayoutEligibleAmountIrr` (ui-phase-7, REQ-053) stand in for the server-computed
* «برداشت بعدی» forecast the nurse read doesn't serve yet: the mock picks a plausible next-batch date
* (3 days out real holiday shifting is backend truth, not modelled here) and the currently-`eligible`
* bucket as the amount that batch would pay.
*/ */
function buildSummary(): NurseEarningsSummary { function buildSummary(): NurseEarningsSummary {
const pending = BigInt(4_250_000); const pending = BigInt(4_250_000);
@@ -309,6 +314,8 @@ function buildSummary(): NurseEarningsSummary {
paidTotalIrr: String(paid), paidTotalIrr: String(paid),
clawbackOutstandingIrr: String(clawbackOutstanding), clawbackOutstandingIrr: String(clawbackOutstanding),
netPayableBalanceIrr: String(net), netPayableBalanceIrr: String(net),
nextPayoutDate: new Date(Date.now() + 3 * DAY_MS).toISOString().slice(0, 10),
nextPayoutEligibleAmountIrr: String(eligible),
}; };
} }
@@ -0,0 +1,13 @@
/**
* Bank-rail `failureReason` codes the UI has a mapped Persian/English label for (i18n keys
* `payouts.failure_code_{code}`). A `failed` payout must never show the raw vendor string as its
* headline known codes get the mapped label; anything else falls back to a generic message with the
* raw code demoted to a secondary `dir="ltr"` caption (never hidden it's still useful for support).
*/
const KNOWN_FAILURE_REASON_CODES = new Set(['invalid_sheba']);
/** The `payouts` namespace i18n key for a payout's `failureReason` — unknown/`null` codes fall back. */
export function failureReasonLabelKey(code: string | null): string {
if (code && KNOWN_FAILURE_REASON_CODES.has(code)) return `failure_code_${code}`;
return 'failure_code_unknown';
}
+9
View File
@@ -77,6 +77,15 @@ export interface NurseEarningsSummary {
* negative** when outstanding clawbacks exceed accrued-unpaid earnings ("owed back"). Never clamp. * negative** when outstanding clawbacks exceed accrued-unpaid earnings ("owed back"). Never clamp.
*/ */
netPayableBalanceIrr: string; netPayableBalanceIrr: string;
/**
* Client-augmented (ui-phase-7, REQ-053) the «برداشت بعدی» forecast: the next weekly batch date
* (holiday-shifted server-side) and the amount expected to be eligible in it. Both `undefined`/`null` on
* the real path until the earnings read serves them; the dashboard/earnings forecast line renders only
* when both are present **never computed client-side** (holiday shifting + eligibility are backend
* truth).
*/
nextPayoutDate?: string | null;
nextPayoutEligibleAmountIrr?: string | null;
} }
/** One completed booking contributing to earnings (REQ-025). Enough fields to deep-link + explain each state. */ /** One completed booking contributing to earnings (REQ-025). Enough fields to deep-link + explain each state. */
@@ -776,3 +776,87 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
- **Proposed shape:** as above. - **Proposed shape:** as above.
- **Status:** open — invoice renders the reconciling money breakdown + مودیان status unconditionally; the - **Status:** open — invoice renders the reconciling money breakdown + مودیان status unconditionally; the
three new rows render only when served. three new rows render only when served.
## REQ-050 — Nurse inbox decision data: `variantLabel`/`variantPrice`/`variantPriceUnit` on `BookingRequestListItemDto` + an `answered` status-group filter — filed by ui-phase-7 — 2026-07-19
- **Need:** Two additions to the nurse's `booking_requests/list` row:
1. `variantLabel: string`, `variantPrice: string` (IRR digit-string), `variantPriceUnit: PriceUnit` — the
same three fields the single-request `BookingRequestDto` already carries, just missing from the list
row.
2. Optionally, a status-**group** filter value (e.g. `status=answered`) that server-side unions
`accepted_awaiting_payment | converted | rejected_by_nurse` in one paginated query.
- **Why:** The redesigned inbox (phase §3.4) leads every card with the service + price — the decision-critical
facts — instead of only patient/time/gender. The list DTO has neither field today (confirmed against
`services/bookingRequests/types.ts`), so the client falls back to the patient-name headline when absent
(mock-tolerant; only the mock's `toListItem` stamps them for now). Separately, the «پاسخ‌داده» tab wants
"every request I've already acted on" in one page, but the API filters by a *single* status — the client
currently works around this by firing three page-1 queries (`accepted_awaiting_payment`/`converted`/
`rejected_by_nurse`) and concatenating them client-side, which is both an N+1-ish fan-out and page-1-only
(a nurse with >20 answered-per-status rows can't page into the rest). A `status=answered` value would
collapse this to one real paginated query.
- **Proposed shape:** `BookingRequestListItemDto { …, variantLabel: string, variantPrice: string,
variantPriceUnit: PriceUnit }`; `booking_requests/list?status=answered` as an accepted alias alongside the
existing single-status values.
- **Status:** open — the mock demonstrates the redesigned card (flip `USE_BOOKING_REQUESTS_MOCK=true`); the
real path degrades to the patient-name headline and the three-query «پاسخ‌داده» workaround (documented,
page-1-only) until delivered.
## REQ-051 — Nurse-view address on confirmed+ bookings — filed by ui-phase-7 — 2026-07-19
- **Need:** Serve `addressSnapshotJson` (or a nurse-shaped subset including `latitude`/`longitude`) to the
**assigned nurse** on `BookingDetailDto` once `status ∈ {confirmed, in_progress, completed, disputed,
closed}` — today it is unconditionally masked to `null` for the nurse view (two-stage disclosure correctly
keeps it masked pre-confirmation, but the mask never lifts post-confirmation either).
- **Why:** The visit-detail address card (phase §3.3) — the "where do I go" a field nurse needs on a
confirmed visit, with a `geo:`-URI map deep-link — has nothing to render on the real path once the booking
is paid and assigned. The mock now simulates the delivered behavior (`isBookingConfirmedOrBeyond` gates the
masking instead of an unconditional nurse-view null) so the card is demonstrable
(`USE_BOOKINGS_MOCK=true`); the real `bookingsClientApi` still receives `null` from the server regardless
of status, so the nurse UI shows a quiet "available after confirmation" note instead of a hard error or a
fabricated address. Also open: whether `recipientPhone` should join the same post-confirmation nurse view
(a "call the family" affordance beyond the emergency-contact `tel:` the care-instructions read already
provides via `BookingSupportEntry`/`EmergencyBanner`) — flagging for a product decision, not assuming yes.
- **Proposed shape:** no DTO shape change — just relax the server-side masking rule on the existing
`addressSnapshotJson` field, conditioned on `status` + assigned-nurse tenancy. If a typed shape lands per
REQ-045, include `latitude`/`longitude` in it (the map link is presently a best-effort parse of whatever
keys the snapshot JSON happens to carry).
- **Status:** open — mock-verified only; the real nurse view stays masked (quiet fallback note, no crash)
until delivered.
## REQ-052 — Service label on the today feed — filed by ui-phase-7 — 2026-07-19
- **Need:** A `variantLabel: string` field on each `booking_sessions/today` row (`BookingSessionListItemDto`).
- **Why:** The day surface (phase §3.2) now renders the service under the patient name on every session card
— the "what job is this" a nurse currently only learns by opening the full booking detail. The row has no
service/variant field today (confirmed against `services/bookings/types.ts`); the client renders it only
when present (mock-tolerant) and deliberately does **not** fetch the booking detail per row to fake it (that
would be an N+1 against a list endpoint). The mock stamps it from the seeded booking's frozen variant
snapshot.
- **Proposed shape:** `BookingSessionListItemDto { …, variantLabel: string }`.
- **Status:** open — the mock demonstrates it; the real today feed renders patient name + visit index only
until delivered.
## REQ-053 — Payout forecast: next batch date + expected eligible amount — filed by ui-phase-7 — 2026-07-19
- **Need:** Two additions to the nurse earnings-balance read (the REQ-025 summary shape, or a new field on
whichever endpoint eventually serves it): `nextPayoutDate: string` (`YYYY-MM-DD`, holiday-shifted
server-side) and `nextPayoutEligibleAmountIrr: string` (the amount that batch would pay, ledger-derived).
- **Why:** The earnings screen's «برداشت بعدی» line (phase §3.5) and the dashboard's earnings snapshot answer
the one question nurses actually ask ("when do I get paid, how much") instead of only the four abstract
buckets. Holiday shifting and eligibility are backend truth (per the domain's own load-bearing rule — the
client must never compute them); the mock picks a plausible next-batch date (+3 days) and reuses the
currently-`eligible` bucket as the forecast amount, both marked optional on the type so the real path
(which doesn't serve them yet) renders nothing rather than a fabricated forecast.
- **Proposed shape:** `NurseEarningsSummaryDto { …, nextPayoutDate: string, nextPayoutEligibleAmountIrr:
string }` (nullable until the first batch is computed, e.g. a brand-new nurse with no eligible earnings).
- **Status:** open — mock-only; the forecast line renders nothing on the real path (never computed
client-side) until delivered. Folds into REQ-025's existing summary gap.
## REQ-054 — Web-push for new requests (DEFERRED, non-blocking) — filed by ui-phase-7 — 2026-07-19
- **Need:** Push-notification infrastructure (a service worker + a backend push rail behind
`INotificationDispatcher`'s deferred SMS/push channels) so a new booking request reaches a nurse whose tab
isn't open.
- **Why:** The nurse inbox polls every 15s (`BOOKING_REQUEST_POLL_MS`) but the response window is measured in
hours — a nurse who isn't looking at the tab when a request arrives can miss it entirely. This is a real,
acknowledged product gap (deadline-driven marketplaces live or die on this), but push infra (service-worker
registration, VAPID/FCM keys, the backend dispatch rail) is a substantial cross-cutting build in its own
right and explicitly out of scope for this phase.
- **Proposed shape:** n/a — filed to put the need on record, not to propose an endpoint shape yet.
- **Status:** deferred, non-blocking — build nothing for it this phase; the 15s poll remains the only
freshness mechanism.
@@ -119,14 +119,14 @@ the frontend can build before the backend phase merges, and swap to the real HTT
| `AddressMapPicker` (map stand-in) | `client/src/components/geography/AddressMapPicker.tsx` | **Not a real map** — a bounded, tappable/draggable marker canvas (CSS grid, no Neshan/Google tiles, no network) that maps the pointer position to `{ latitude, longitude }` around the chosen city's centroid (`CITY_CENTROIDS`/`IRAN_CENTROID` in `services/geography/constants.ts`). Emits real coordinates for the create/update request | _none (component boundary)_ | Replace the canvas internals with a real map widget (Neshan/Google, inlined per the client CSP) that emits the same `{ latitude, longitude }` via `onChange``AddressForm` and every caller stay unchanged | 🟡 | | `AddressMapPicker` (map stand-in) | `client/src/components/geography/AddressMapPicker.tsx` | **Not a real map** — a bounded, tappable/draggable marker canvas (CSS grid, no Neshan/Google tiles, no network) that maps the pointer position to `{ latitude, longitude }` around the chosen city's centroid (`CITY_CENTROIDS`/`IRAN_CENTROID` in `services/geography/constants.ts`). Emits real coordinates for the create/update request | _none (component boundary)_ | Replace the canvas internals with a real map widget (Neshan/Google, inlined per the client CSP) that emits the same `{ latitude, longitude }` via `onChange``AddressForm` and every caller stay unchanged | 🟡 |
| `CatalogApi` | `client/src/services/catalog/apis/mockApi.ts` (+ `apis/seed.ts`) | The catalog skeleton + nurse pricing layer. **Categories mirror the b5 seed exactly** (5 categories, ids 15, `sortOrder` 04). Seeds representative **option groups/values** the fresh backend does **not** (an admin authors them per category) — incl. required + optional groups and one **cross-category** (`serviceCategoryId=null`) group — so the builder's required-option gate + cross-category rendering demo. Enforces the server's create validation in-memory: `400` missing required dimension / bad price, and the `(nurse, category, option-set)` duplicate **`409`** (via `optionSetSignature`). Variant store seeded **empty** so the offerings empty-state demos; the nurse builds variants live (across price units). `create`/`update`/`set_active`/`list`(active-first, paginated)/`get`. Money stays an **IRR digit-string** end-to-end | `USE_CATALOG_MOCK` (`services/catalog/constants.ts`, default `true`) | b5 `catalog/*` + `nurse_variants/*` are live; set flag `false``catalogClientApi` is wired to the action-style routes (camelCase bodies, `pageSize` pagination per REQ-010, `category_id` snake_case filter). **When swapped, categories will have NO option groups until an admin authors them** (the mock's groups were illustrative). No hook/component change | 🟢 (real, refinement-phase-4) | | `CatalogApi` | `client/src/services/catalog/apis/mockApi.ts` (+ `apis/seed.ts`) | The catalog skeleton + nurse pricing layer. **Categories mirror the b5 seed exactly** (5 categories, ids 15, `sortOrder` 04). Seeds representative **option groups/values** the fresh backend does **not** (an admin authors them per category) — incl. required + optional groups and one **cross-category** (`serviceCategoryId=null`) group — so the builder's required-option gate + cross-category rendering demo. Enforces the server's create validation in-memory: `400` missing required dimension / bad price, and the `(nurse, category, option-set)` duplicate **`409`** (via `optionSetSignature`). Variant store seeded **empty** so the offerings empty-state demos; the nurse builds variants live (across price units). `create`/`update`/`set_active`/`list`(active-first, paginated)/`get`. Money stays an **IRR digit-string** end-to-end | `USE_CATALOG_MOCK` (`services/catalog/constants.ts`, default `true`) | b5 `catalog/*` + `nurse_variants/*` are live; set flag `false``catalogClientApi` is wired to the action-style routes (camelCase bodies, `pageSize` pagination per REQ-010, `category_id` snake_case filter). **When swapped, categories will have NO option groups until an admin authors them** (the mock's groups were illustrative). No hook/component change | 🟢 (real, refinement-phase-4) |
| `VerificationApi` | `client/src/services/verification/apis/mockApi.ts` | The whole nurse trust journey (b6). Seeds the six required steps on `start` (idempotent); `runIdentityKyc` passes any well-formed 10-digit id **except** `0000000000` (→ `failed`/`kyc_no_match`, matches backend `MockIdentityKycProvider`); `runShahkarMatch` requires identity passed, fails **shared-SIM** when the bound national id is `1111111111` (→ `failed`/`shared_sim`); `runBankVerification` passes (assumes a primary bank account); `uploadStepDocument` simulates signed-URL PUT progress then moves the step to `in_review` (metadata only); `submitCredentialDetails` validates the INO number. Re-aggregates like the server (`approved` only when every step passes). **Dev-only** `__mockApproveAll()`/`__mockRejectStep(code,reason)` stand in for the deferred (f15) admin review queue so a human can watch `is_verified`/the trust badge/the publish gate flip — reachable from B3/B6 only while the flag is true | `USE_VERIFICATION_MOCK` (`services/verification/constants.ts`, default `true`) | b6 `nurse_verification/*` + `nurses/{id}/trust_badge` are live; set flag `false``verificationClientApi` is wired (action-style routes, camelCase, XHR signed-URL PUT for upload progress + SHA-256 integrity hash). **Caveat:** the real `submitCredentialDetails` no-ops pending REQ-011 (no nurse-facing endpoint for the structured INO/specialties fields yet) — the document uploads it accompanies are contract-backed. No hook/component change | 🟡 | | `VerificationApi` | `client/src/services/verification/apis/mockApi.ts` | The whole nurse trust journey (b6). Seeds the six required steps on `start` (idempotent); `runIdentityKyc` passes any well-formed 10-digit id **except** `0000000000` (→ `failed`/`kyc_no_match`, matches backend `MockIdentityKycProvider`); `runShahkarMatch` requires identity passed, fails **shared-SIM** when the bound national id is `1111111111` (→ `failed`/`shared_sim`); `runBankVerification` passes (assumes a primary bank account); `uploadStepDocument` simulates signed-URL PUT progress then moves the step to `in_review` (metadata only); `submitCredentialDetails` validates the INO number. Re-aggregates like the server (`approved` only when every step passes). **Dev-only** `__mockApproveAll()`/`__mockRejectStep(code,reason)` stand in for the deferred (f15) admin review queue so a human can watch `is_verified`/the trust badge/the publish gate flip — reachable from B3/B6 only while the flag is true | `USE_VERIFICATION_MOCK` (`services/verification/constants.ts`, default `true`) | b6 `nurse_verification/*` + `nurses/{id}/trust_badge` are live; set flag `false``verificationClientApi` is wired (action-style routes, camelCase, XHR signed-URL PUT for upload progress + SHA-256 integrity hash). **Caveat:** the real `submitCredentialDetails` no-ops pending REQ-011 (no nurse-facing endpoint for the structured INO/specialties fields yet) — the document uploads it accompanies are contract-backed. No hook/component change | 🟡 |
| `BookingsApi` | `client/src/services/bookings/apis/mockApi.ts` | The post-payment engagement (b9). Seeds **2 confirmed bookings** (one 3-session multi-day, one single-visit) + `booking_care_instructions` + a per-session **EVV state machine**`checkInVisit` flips the session→`in_progress`/`checked_in` (booking→`in_progress`) and computes the **advisory** `checkInAddressMatch` (haversine vs the seeded address ± `MOCK_EVV_TOLERANCE_METERS`, `null` when GPS was absent); `checkOutVisit` requires an open check-in (**`400 no_open_check_in`** otherwise), completes the session (stamps `payoutEligibleAt`), and completes the booking + opens the dispute window once **all** sessions settle. `getCareInstructions` **404s any viewer but the assigned nurse** (the two-stage-disclosure boundary; the UI `enabled` gate means the customer never even calls it). Money stays IRR digit-strings with `gross = commission + payout` and `Σ visitPayout = payout` | `USE_BOOKINGS_MOCK` (`services/bookings/constants.ts`, default `true`) | b9 `bookings/*` + `booking_sessions/*` are live, but a booking only exists after `bookings/convert` runs on a **paid** request — both upstreams (`bookingRequests` mock, card capture b10) aren't real client-side yet. Once conversion is live, set flag `false``bookingsClientApi` maps the routes 1:1 (+ `bookingsServerApi` for the RSC prefetch). No hook/component change | 🟢 (real, refinement-phase-4) | | `BookingsApi` | `client/src/services/bookings/apis/mockApi.ts` | The post-payment engagement (b9). Seeds **2 confirmed bookings** (one 3-session multi-day, one single-visit) + `booking_care_instructions` + a per-session **EVV state machine**`checkInVisit` flips the session→`in_progress`/`checked_in` (booking→`in_progress`) and computes the **advisory** `checkInAddressMatch` (haversine vs the seeded address ± `MOCK_EVV_TOLERANCE_METERS`, `null` when GPS was absent); `checkOutVisit` requires an open check-in (**`400 no_open_check_in`** otherwise), completes the session (stamps `payoutEligibleAt`), and completes the booking + opens the dispute window once **all** sessions settle. `getCareInstructions` **404s any viewer but the assigned nurse** (the two-stage-disclosure boundary; the UI `enabled` gate means the customer never even calls it). Money stays IRR digit-strings with `gross = commission + payout` and `Σ visitPayout = payout`. **ui-phase-7:** `forViewer` now unmasks `addressSnapshotJson` for the nurse once the booking is `confirmed`+ (simulating REQ-051, still delivered) instead of unconditionally nulling it — `addr5001` gained `latitude`/`longitude` matching the EVV reference point so the new address-card map link is demoable; `listTodaySessions` stamps a `variantLabel` off the booking's frozen variant snapshot (REQ-052) | `USE_BOOKINGS_MOCK` (`services/bookings/constants.ts`, default `false`) | b9 `bookings/*` + `booking_sessions/*` are live, but a booking only exists after `bookings/convert` runs on a **paid** request — both upstreams (`bookingRequests` mock, card capture b10) aren't real client-side yet. Once conversion is live, set flag `false``bookingsClientApi` maps the routes 1:1 (+ `bookingsServerApi` for the RSC prefetch). No hook/component change. Deliver **REQ-051** (nurse-view address post-confirmation) + **REQ-052** (today-feed `variantLabel`) | 🟢 (real, refinement-phase-4) |
| `ILocationProvider` | `client/src/services/bookings/evv/locationProvider.ts` | **EVV GPS capture** — the only client seam f8 introduces. `getCurrentPosition()` never rejects (denied/unavailable → `null`, so a GPS problem is **advisory, never a block**). The **real** provider wraps `navigator.geolocation.getCurrentPosition`; the **mock** returns canned coordinates per mode so the in-range / advisory-out-of-range / denied paths are all demoable without a device (the mock `BookingsApi` computes the match against the same seeded reference point) | `NEXT_PUBLIC_EVV_MOCK_GPS` = `in_range` \| `out_of_range` \| `denied` \| `off` (default `in_range` while `USE_BOOKINGS_MOCK`, else `off`) | Set `NEXT_PUBLIC_EVV_MOCK_GPS=off` (or flip `USE_BOOKINGS_MOCK`) → the real `navigator.geolocation` provider is selected. Real **address-match math** stays server-side (backend geocoding seam), not here — this seam only *captures* the position | 🟢 (real, refinement-phase-4) | | `ILocationProvider` | `client/src/services/bookings/evv/locationProvider.ts` | **EVV GPS capture** — the only client seam f8 introduces. `getCurrentPosition()` never rejects (denied/unavailable → `null`, so a GPS problem is **advisory, never a block**). The **real** provider wraps `navigator.geolocation.getCurrentPosition`; the **mock** returns canned coordinates per mode so the in-range / advisory-out-of-range / denied paths are all demoable without a device (the mock `BookingsApi` computes the match against the same seeded reference point) | `NEXT_PUBLIC_EVV_MOCK_GPS` = `in_range` \| `out_of_range` \| `denied` \| `off` (default `in_range` while `USE_BOOKINGS_MOCK`, else `off`) | Set `NEXT_PUBLIC_EVV_MOCK_GPS=off` (or flip `USE_BOOKINGS_MOCK`) → the real `navigator.geolocation` provider is selected. Real **address-match math** stays server-side (backend geocoding seam), not here — this seam only *captures* the position | 🟢 (real, refinement-phase-4) |
| `PaymentApi` | `client/src/services/payment/apis/mockApi.ts` | **The f9 checkout money path** — plays the PSP + webhook roles the client can't reach: `getCheckoutSummary` serves the unserved C6 breakdown (REQ-016; commission-net/VAT/service split via **integer parts-per-10000 BigInt math**, 12% fee / 10% VAT, reconciles to the rial); `initiatePayment` enforces b10 idempotency (same `Idempotency-Key` → same attempt; repeat after capture / lapsed window → **`409`**) and returns a `redirectUrl` into the local mock-gateway harness; `confirmGatewayReturn` on success is the **webhook-confirm stand-in and the missing f7↔f8 bridge** — flips the request `converted` (+ client-augmented `bookingId`, via `mockMarkBookingRequestConverted` in the f7 mock), inserts a **confirmed** booking into the f8 store (`mockInsertConvertedBooking`), and auto-issues the b11-shaped invoice (`moadianStatus: pending`, `pdfUrl: null` so the print path exercises); replayed returns converge idempotently; `getInvoice` 404s until issued. **ui-phase-6:** `initiatePayment`'s `redirectUrl` is now `null` (was a stale pointer to the deleted card-gateway harness page — a latent bug, since the harness itself was already removed in refinement-phase-4; the checkout page's `!redirectUrl` branch already reads the outcome directly, no behavior change); `getCheckoutSummary` adds mock `nurseAvatarUrl: null`/`nurseVerified: true` (REQ-046, the C6 identity moment); the capture path stamps `capturedAt`/`createdAt` on the transaction so `PaymentOutcomeDto` serves `trackingCode`/`paidAt` (REQ-046, the confirmation receipt) and the new `getPaymentHistory` reads the same transaction list (REQ-047, wallet «پرداخت‌ها»); invoice creation adds mock `paymentMethod: 'card'`/`transactionReference`/`sellerFiscalIdentity` (REQ-049, fiscal-grade invoice) | `USE_PAYMENT_MOCK` (`services/payment/constants.ts`, default `true`) | b10 initiate + b11 invoice are live and `paymentClientApi` maps them 1:1 (`Idempotency-Key` header, `GET invoices/{bookingId}`); deliver **REQ-016** (checkout summary — the real client already targets the proposed `booking_requests/checkout_summary/{id}` slug) + **REQ-017** (transaction status / `bookingId`; until then the real outcome poll maps `booking_requests/get` statuses and can't distinguish declined from slow) + **REQ-018** (invoice reachable post-capture) + **REQ-046** (nurse identity + tracking code/paid-at) + **REQ-047** (payment history) + **REQ-049** (invoice fiscal fields), make the upstream `bookingRequests` flow real, then set flag `false`. No hook/component change | 🟢 (real, refinement-phase-4) | | `PaymentApi` | `client/src/services/payment/apis/mockApi.ts` | **The f9 checkout money path** — plays the PSP + webhook roles the client can't reach: `getCheckoutSummary` serves the unserved C6 breakdown (REQ-016; commission-net/VAT/service split via **integer parts-per-10000 BigInt math**, 12% fee / 10% VAT, reconciles to the rial); `initiatePayment` enforces b10 idempotency (same `Idempotency-Key` → same attempt; repeat after capture / lapsed window → **`409`**) and returns a `redirectUrl` into the local mock-gateway harness; `confirmGatewayReturn` on success is the **webhook-confirm stand-in and the missing f7↔f8 bridge** — flips the request `converted` (+ client-augmented `bookingId`, via `mockMarkBookingRequestConverted` in the f7 mock), inserts a **confirmed** booking into the f8 store (`mockInsertConvertedBooking`), and auto-issues the b11-shaped invoice (`moadianStatus: pending`, `pdfUrl: null` so the print path exercises); replayed returns converge idempotently; `getInvoice` 404s until issued. **ui-phase-6:** `initiatePayment`'s `redirectUrl` is now `null` (was a stale pointer to the deleted card-gateway harness page — a latent bug, since the harness itself was already removed in refinement-phase-4; the checkout page's `!redirectUrl` branch already reads the outcome directly, no behavior change); `getCheckoutSummary` adds mock `nurseAvatarUrl: null`/`nurseVerified: true` (REQ-046, the C6 identity moment); the capture path stamps `capturedAt`/`createdAt` on the transaction so `PaymentOutcomeDto` serves `trackingCode`/`paidAt` (REQ-046, the confirmation receipt) and the new `getPaymentHistory` reads the same transaction list (REQ-047, wallet «پرداخت‌ها»); invoice creation adds mock `paymentMethod: 'card'`/`transactionReference`/`sellerFiscalIdentity` (REQ-049, fiscal-grade invoice) | `USE_PAYMENT_MOCK` (`services/payment/constants.ts`, default `true`) | b10 initiate + b11 invoice are live and `paymentClientApi` maps them 1:1 (`Idempotency-Key` header, `GET invoices/{bookingId}`); deliver **REQ-016** (checkout summary — the real client already targets the proposed `booking_requests/checkout_summary/{id}` slug) + **REQ-017** (transaction status / `bookingId`; until then the real outcome poll maps `booking_requests/get` statuses and can't distinguish declined from slow) + **REQ-018** (invoice reachable post-capture) + **REQ-046** (nurse identity + tracking code/paid-at) + **REQ-047** (payment history) + **REQ-049** (invoice fiscal fields), make the upstream `bookingRequests` flow real, then set flag `false`. No hook/component change | 🟢 (real, refinement-phase-4) |
| Mock-gateway page (test harness) | `client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/gateway/page.tsx` | **Not a product feature** — a dev stand-in for the PSP's hosted payment page so the initiate → redirect → return round-trip is exercisable without a gateway: the mock `redirectUrl` points here, and its success/failure buttons drive both branches of the return surface (`?outcome=success\|failure`). Clearly labelled «درگاه پرداخت آزمایشی», dashed border | _none — only reachable via the mock's `redirectUrl`_ | On the real path b10's `redirectUrl` is the PSP's **absolute** URL (the checkout does a full `window.location.assign` for `http(s)` URLs), so this page is simply never linked; delete it when `USE_PAYMENT_MOCK` retires. The PSP's return deep-link into `/bookings/checkout/return` is backend/PSP config | 🗑 removed in refinement-phase-4 (payment flipped real) | | Mock-gateway page (test harness) | `client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/gateway/page.tsx` | **Not a product feature** — a dev stand-in for the PSP's hosted payment page so the initiate → redirect → return round-trip is exercisable without a gateway: the mock `redirectUrl` points here, and its success/failure buttons drive both branches of the return surface (`?outcome=success\|failure`). Clearly labelled «درگاه پرداخت آزمایشی», dashed border | _none — only reachable via the mock's `redirectUrl`_ | On the real path b10's `redirectUrl` is the PSP's **absolute** URL (the checkout does a full `window.location.assign` for `http(s)` URLs), so this page is simply never linked; delete it when `USE_PAYMENT_MOCK` retires. The PSP's return deep-link into `/bookings/checkout/return` is backend/PSP config | 🗑 removed in refinement-phase-4 (payment flipped real) |
| `RefundsApi` | `client/src/services/refunds/apis/mockApi.ts` | **The f10 customer cancel + refund surface** b11 doesn't serve (refunds are admin-only; no customer cancel command, no policy preview, no refund-by-booking, no fee-leg decomposition on the customer status → REQ-019/020/021). Reads the shared **f8 bookings store** (`mockGetBookingForRefund`) to resolve the tier by lead time (`free_24h` >24h / `partial_under_24h` <24h / `customer_no_show` started — client-invented codes → i18n keys) and the per-session refundable(un-started)/locked(completed-and-verified) breakdown, decomposing the refund across the two fee legs via **integer parts-per-10000 BigInt math** (`refundAmount + fee = refundableGross` to the rial). `cancelBooking` flips the booking → `cancelled` (`mockMarkBookingCancelled` stamps the b9 snapshot + cancels only un-started sessions) and creates a refund: **card → `succeeded`** immediately (no ETA); **BNPL → `approved`→`processing`→`succeeded`** over status polls with a `expected_customer_refund_eta` ~10 business days out (Fridays skipped) so the ~710-day banner renders. Enforces the outside-policy **`409`** (already-cancelled / nothing-refundable / non-refundable session). Seeds a **`failed`** refund on the cancelled booking 5004 so the contact-support state demos; booking 5002 is pinned to the BNPL channel; booking 5003 (new, mid-engagement) demos the mixed refundable/locked breakdown. Also adds bookings-store seeds 5003/5004 + the two non-seam exports. **ui-phase-6:** `getMyRefunds` (REQ-048) returns every in-memory refund newest-first — the wallet «استردادها» tab | `USE_REFUNDS_MOCK` (`services/refunds/constants.ts`, default `true`) | Deliver **REQ-019** (customer cancel command — the real `refundsClientApi.cancelBooking` already targets `POST bookings/{id}/cancel`) + **REQ-020** (cancellation-policy preview → `GET bookings/{id}/cancellation_policy`, incl. the canonical `cancellation_policy_code` set) + **REQ-021** (`GET refunds/by_booking/{id}` + the decomposition fields on the customer `refunds/{id}/status`) + **REQ-048** (`GET refunds/my`), then set flag `false` — the real client maps the published `refunds/{id}/status` 1:1 and targets the proposed slugs for the rest. No hook/component change | 🟡 | | `RefundsApi` | `client/src/services/refunds/apis/mockApi.ts` | **The f10 customer cancel + refund surface** b11 doesn't serve (refunds are admin-only; no customer cancel command, no policy preview, no refund-by-booking, no fee-leg decomposition on the customer status → REQ-019/020/021). Reads the shared **f8 bookings store** (`mockGetBookingForRefund`) to resolve the tier by lead time (`free_24h` >24h / `partial_under_24h` <24h / `customer_no_show` started — client-invented codes → i18n keys) and the per-session refundable(un-started)/locked(completed-and-verified) breakdown, decomposing the refund across the two fee legs via **integer parts-per-10000 BigInt math** (`refundAmount + fee = refundableGross` to the rial). `cancelBooking` flips the booking → `cancelled` (`mockMarkBookingCancelled` stamps the b9 snapshot + cancels only un-started sessions) and creates a refund: **card → `succeeded`** immediately (no ETA); **BNPL → `approved`→`processing`→`succeeded`** over status polls with a `expected_customer_refund_eta` ~10 business days out (Fridays skipped) so the ~710-day banner renders. Enforces the outside-policy **`409`** (already-cancelled / nothing-refundable / non-refundable session). Seeds a **`failed`** refund on the cancelled booking 5004 so the contact-support state demos; booking 5002 is pinned to the BNPL channel; booking 5003 (new, mid-engagement) demos the mixed refundable/locked breakdown. Also adds bookings-store seeds 5003/5004 + the two non-seam exports. **ui-phase-6:** `getMyRefunds` (REQ-048) returns every in-memory refund newest-first — the wallet «استردادها» tab | `USE_REFUNDS_MOCK` (`services/refunds/constants.ts`, default `true`) | Deliver **REQ-019** (customer cancel command — the real `refundsClientApi.cancelBooking` already targets `POST bookings/{id}/cancel`) + **REQ-020** (cancellation-policy preview → `GET bookings/{id}/cancellation_policy`, incl. the canonical `cancellation_policy_code` set) + **REQ-021** (`GET refunds/by_booking/{id}` + the decomposition fields on the customer `refunds/{id}/status`) + **REQ-048** (`GET refunds/my`), then set flag `false` — the real client maps the published `refunds/{id}/status` 1:1 and targets the proposed slugs for the rest. No hook/component change | 🟡 |
| `BnplApi` | `client/src/services/bnpl/apis/mockApi.ts` | **The f11 BNPL installment checkout (D1D5)** b12 doesn't serve client-side (b12 is order-centric — eligibility/initiate/status/webhook — and **explicitly does not model the repayment schedule**; no provider/plan options, no wallet installment status → REQ-022/023/024). Reads the frozen request gross from the shared **f7 store** and plays the provider: `getBnplOptions` builds the provider set as **data** (دیجی‌پی 3/6/12 · اسنپ‌پی ۴ · اقساط بالین‌یار; per-plan monthly/down-payment/total via **integer parts-per-10000 BigInt math**, never a hardcoded fee in the UI); `checkEligibility` returns `eligible` unless the national-id last digit is `0` (→`not_eligible`) or the order exceeds `MOCK_CREDIT_CEILING_IRR` (→`ceiling_exceeded`) so both declined paths demo; `getBnplSchedule` serves the down-payment + N-installment rows (last absorbs the remainder → rows sum to total); `issueBnplToken` enforces b12 idempotency (same key → same token; repeat after settle / lapsed window → **`409`**) + a `redirectUrl` into the local provider-handoff harness; `acceptBnplSchedule` on success is the **settle stand-in and reuses the f9 conversion bridge** — flips the request `converted` (`mockMarkBookingRequestConverted`), inserts a **confirmed** booking (`mockInsertConvertedBooking`; a settled BNPL order = a card payment net-of-fee, payout invariant to method), and **seeds a provider-reported Wallet plan**; `getWalletInstallments` serves D5 (seeded active دیجی‌پی ۶-ماهه with paid/due-soon/upcoming rows + each settled checkout's plan). Money = served IRR digit-strings end-to-end (components only format) | `USE_BNPL_MOCK` (`services/bnpl/constants.ts`, default `true`) | Deliver **REQ-022** (options + schedule — real `bnplClientApi` targets `checkout_bnpl/options/{id}` + `checkout_bnpl/schedule/{id}`), **REQ-023** (eligibility accepts the D3 national-id/mobile/consent), **REQ-024** (`checkout_bnpl/wallet_installments` provider-reported status + a customer `bookingId` on the settled order), and make the upstream `bookingRequests` flow real, then set flag `false``checkEligibility`/`issueBnplToken`(`Idempotency-Key`)/`getBnplOrder` already map the live b12 routes 1:1; the settle-on-return reads the order (the real settle is the provider webhook). No hook/component change | 🟡 | | `BnplApi` | `client/src/services/bnpl/apis/mockApi.ts` | **The f11 BNPL installment checkout (D1D5)** b12 doesn't serve client-side (b12 is order-centric — eligibility/initiate/status/webhook — and **explicitly does not model the repayment schedule**; no provider/plan options, no wallet installment status → REQ-022/023/024). Reads the frozen request gross from the shared **f7 store** and plays the provider: `getBnplOptions` builds the provider set as **data** (دیجی‌پی 3/6/12 · اسنپ‌پی ۴ · اقساط بالین‌یار; per-plan monthly/down-payment/total via **integer parts-per-10000 BigInt math**, never a hardcoded fee in the UI); `checkEligibility` returns `eligible` unless the national-id last digit is `0` (→`not_eligible`) or the order exceeds `MOCK_CREDIT_CEILING_IRR` (→`ceiling_exceeded`) so both declined paths demo; `getBnplSchedule` serves the down-payment + N-installment rows (last absorbs the remainder → rows sum to total); `issueBnplToken` enforces b12 idempotency (same key → same token; repeat after settle / lapsed window → **`409`**) + a `redirectUrl` into the local provider-handoff harness; `acceptBnplSchedule` on success is the **settle stand-in and reuses the f9 conversion bridge** — flips the request `converted` (`mockMarkBookingRequestConverted`), inserts a **confirmed** booking (`mockInsertConvertedBooking`; a settled BNPL order = a card payment net-of-fee, payout invariant to method), and **seeds a provider-reported Wallet plan**; `getWalletInstallments` serves D5 (seeded active دیجی‌پی ۶-ماهه with paid/due-soon/upcoming rows + each settled checkout's plan). Money = served IRR digit-strings end-to-end (components only format) | `USE_BNPL_MOCK` (`services/bnpl/constants.ts`, default `true`) | Deliver **REQ-022** (options + schedule — real `bnplClientApi` targets `checkout_bnpl/options/{id}` + `checkout_bnpl/schedule/{id}`), **REQ-023** (eligibility accepts the D3 national-id/mobile/consent), **REQ-024** (`checkout_bnpl/wallet_installments` provider-reported status + a customer `bookingId` on the settled order), and make the upstream `bookingRequests` flow real, then set flag `false``checkEligibility`/`issueBnplToken`(`Idempotency-Key`)/`getBnplOrder` already map the live b12 routes 1:1; the settle-on-return reads the order (the real settle is the provider webhook). No hook/component change | 🟡 |
| BNPL provider-handoff harness (test harness) | `client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/gateway/page.tsx` | **Not a product feature** — a dev stand-in for the provider's hosted BNPL page so the initiate → redirect → return round-trip is exercisable without a provider: the mock `redirectUrl` points here, and its pay/cancel buttons drive both branches of the return surface (`?outcome=success\|failure`). Clearly labelled «در حال انتقال به ارائه‌دهنده», dashed border. **ui-phase-6:** was reachable by direct URL in a production build (the equivalent card-gateway harness was already deleted, this one never got the same guard since BNPL stays mock-primary) — now `notFound()`-gated outside `NODE_ENV=development`, still fully reachable in `next dev` | _none — only reachable via the mock's `redirectUrl`_ | On the real path b12's `redirectUrl` is the provider's **absolute** URL (the wizard does a full `window.location.assign` for `http(s)`), so this page is never linked; delete it when `USE_BNPL_MOCK` retires. The provider's return deep-link into `/bookings/checkout/bnpl/return` is backend/provider config | 🟡 | | BNPL provider-handoff harness (test harness) | `client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/gateway/page.tsx` | **Not a product feature** — a dev stand-in for the provider's hosted BNPL page so the initiate → redirect → return round-trip is exercisable without a provider: the mock `redirectUrl` points here, and its pay/cancel buttons drive both branches of the return surface (`?outcome=success\|failure`). Clearly labelled «در حال انتقال به ارائه‌دهنده», dashed border. **ui-phase-6:** was reachable by direct URL in a production build (the equivalent card-gateway harness was already deleted, this one never got the same guard since BNPL stays mock-primary) — now `notFound()`-gated outside `NODE_ENV=development`, still fully reachable in `next dev` | _none — only reachable via the mock's `redirectUrl`_ | On the real path b12's `redirectUrl` is the provider's **absolute** URL (the wizard does a full `window.location.assign` for `http(s)`), so this page is never linked; delete it when `USE_BNPL_MOCK` retires. The provider's return deep-link into `/bookings/checkout/bnpl/return` is backend/provider config | 🟡 |
| `PayoutsApi` | `client/src/services/payouts/apis/mockApi.ts` | **The f12 nurse earnings surface** b13 doesn't serve read-side for a nurse (b13's only nurse route is `GET nurse_payouts/history`; the four-bucket **earnings summary**, the per-booking **earnings list + money-state**, and a **nurse-readable payout detail** with batch context + booking links are gaps → **REQ-025**). Self-contained, money-correct fixtures exercising **every** UI state: all four earnings states (`pending`/`eligible`/`paid`/`clawback_applied`; booking ids 50015004 align with the f8 bookings-store seeds so "view booking" deep-links land), all four `PayoutStatus` values in history (`pending`/`submitted`/`paid`/`failed`, incl. a `failed` payout with `failureReason: 'invalid_sheba'` for the read-only failure banner), payout **details that reconcile** (`gross clawback = net = amount`, Σ booking-link amounts = `grossEarnings`), and a **signed net balance** computed with BigInt via a `MOCK_SCENARIO` toggle (`standard` = positive; **`clawback_heavy` = negative "owed back"** for phase §7 step 3). Timestamps are relative to `now` so the pending dispute-window countdown always ticks; money stays IRR digit-strings end-to-end (components only format). `getNurseEarnings` filters by `state` + paginates | `USE_PAYOUTS_MOCK` (`services/payouts/constants.ts`, default `true`) + `MOCK_SCENARIO` in `constants.ts` | Deliver **REQ-025** (earnings_balance + earnings list + nurse `nurse_payouts/{id}` detail + `failureReason` on the history DTO), then set flag `false``payoutsClientApi` already maps the live `GET nurse_payouts/history` 1:1 and targets the proposed slugs for the other three. No hook/component change | 🟡 | | `PayoutsApi` | `client/src/services/payouts/apis/mockApi.ts` | **The f12 nurse earnings surface** b13 doesn't serve read-side for a nurse (b13's only nurse route is `GET nurse_payouts/history`; the four-bucket **earnings summary**, the per-booking **earnings list + money-state**, and a **nurse-readable payout detail** with batch context + booking links are gaps → **REQ-025**). Self-contained, money-correct fixtures exercising **every** UI state: all four earnings states (`pending`/`eligible`/`paid`/`clawback_applied`; booking ids 50015004 align with the f8 bookings-store seeds so "view booking" deep-links land), all four `PayoutStatus` values in history (`pending`/`submitted`/`paid`/`failed`, incl. a `failed` payout with `failureReason: 'invalid_sheba'` for the read-only failure banner), payout **details that reconcile** (`gross clawback = net = amount`, Σ booking-link amounts = `grossEarnings`), and a **signed net balance** computed with BigInt via a `MOCK_SCENARIO` toggle (`standard` = positive; **`clawback_heavy` = negative "owed back"** for phase §7 step 3). Timestamps are relative to `now` so the pending dispute-window countdown always ticks; money stays IRR digit-strings end-to-end (components only format). `getNurseEarnings` filters by `state` + paginates. **ui-phase-7:** `buildSummary()` adds `nextPayoutDate` (+3 days) + `nextPayoutEligibleAmountIrr` (= the `eligible` bucket) for the earnings/dashboard «برداشت بعدی» forecast line (REQ-053); the client also gained a `failureReasonLabelKey()` helper (`services/payouts/failureReasons.ts`) mapping known `failureReason` codes (today: `invalid_sheba`) to i18n labels so `PayoutHistoryRow`/the payout detail never show the raw vendor string as the headline | `USE_PAYOUTS_MOCK` (`services/payouts/constants.ts`, default `true`) + `MOCK_SCENARIO` in `constants.ts` | Deliver **REQ-025** (earnings_balance + earnings list + nurse `nurse_payouts/{id}` detail + `failureReason` on the history DTO) + **REQ-053** (`nextPayoutDate`/`nextPayoutEligibleAmountIrr`), then set flag `false``payoutsClientApi` already maps the live `GET nurse_payouts/history` 1:1 and targets the proposed slugs for the rest. No hook/component change | 🟡 |
| `ReviewsApi` | `client/src/services/reviews/apis/mockApi.ts` | **The f13 moderated-review trust loop.** b14 serves the review **submit** (`POST bookings/{id}/review`), the public **nurse reviews** page (`GET nurses/{id}/reviews`), and the tag rollup — those are mapped 1:1 in `reviewsClientApi`. But there is **no review-eligibility read** and **no my-review-for-booking read** (**REQ-026**), and the whole moderation transition (`pending_moderation → published`) is **admin-only (f15)**. The mock reads a booking from the shared **f8 bookings store** (`mockGetBookingForReview`) to gate eligibility on a **completed/closed** booking (aligns with the new completed seed 5005 / nurse 1 / patient 905), tracks the customer's submission as `pending_moderation` so eligibility flips `already_reviewed` + `getMyReviewForBooking` returns the persistent "under review" state, and seeds a **published list per nurse** (nurse 1 has 7 → the profile tab paginates; nurses 5/6 empty → empty state). The aggregate is **recomputed from the published list** (never a stored sum). A submitted review **never** enters any public list. Dev-only `__mockPublishSubmittedReview(bookingId)` stands in for the deferred (f15) admin queue so a human can watch a review appear on the profile. Money-free | `USE_REVIEWS_MOCK` (`services/reviews/constants.ts`, default `true`) | Deliver **REQ-026** (`review_eligibility` + `my_review` reads; confirm masked-author omission), then set flag `false``reviewsClientApi.getNurseReviews`/`createReview` already map the live b14 routes 1:1 and target the two proposed slugs for the gaps. Moderation UI itself is **f15** (admin). No hook/component change | 🟢 (real, refinement-phase-4) | | `ReviewsApi` | `client/src/services/reviews/apis/mockApi.ts` | **The f13 moderated-review trust loop.** b14 serves the review **submit** (`POST bookings/{id}/review`), the public **nurse reviews** page (`GET nurses/{id}/reviews`), and the tag rollup — those are mapped 1:1 in `reviewsClientApi`. But there is **no review-eligibility read** and **no my-review-for-booking read** (**REQ-026**), and the whole moderation transition (`pending_moderation → published`) is **admin-only (f15)**. The mock reads a booking from the shared **f8 bookings store** (`mockGetBookingForReview`) to gate eligibility on a **completed/closed** booking (aligns with the new completed seed 5005 / nurse 1 / patient 905), tracks the customer's submission as `pending_moderation` so eligibility flips `already_reviewed` + `getMyReviewForBooking` returns the persistent "under review" state, and seeds a **published list per nurse** (nurse 1 has 7 → the profile tab paginates; nurses 5/6 empty → empty state). The aggregate is **recomputed from the published list** (never a stored sum). A submitted review **never** enters any public list. Dev-only `__mockPublishSubmittedReview(bookingId)` stands in for the deferred (f15) admin queue so a human can watch a review appear on the profile. Money-free | `USE_REVIEWS_MOCK` (`services/reviews/constants.ts`, default `true`) | Deliver **REQ-026** (`review_eligibility` + `my_review` reads; confirm masked-author omission), then set flag `false``reviewsClientApi.getNurseReviews`/`createReview` already map the live b14 routes 1:1 and target the two proposed slugs for the gaps. Moderation UI itself is **f15** (admin). No hook/component change | 🟢 (real, refinement-phase-4) |
| `PatientRecordsApi` | `client/src/services/patientRecords/apis/mockApi.ts` | **The f13 continuity-of-care surface.** Two very different things: (1) the **nurse-authored visit-note history** (`getPatientHistory`/`createVisitNote`) is **REAL b14** (`GET`/`POST patients/{id}/care_records`), mapped 1:1 in `patientRecordsClientApi` (the append composes the ticked task checklist into the note `body` since the wire has no structured task field); (2) the **family-owned editable record** (medications/routine/tasks — the داروها/روتین/وظایف tabs) and the **access check** have **NO backend at all** (neither the b14 contract nor `data-model/10-reviews-and-records.md` model them → **REQ-027**). The mock is **patient-scoped** and lazily seeds a coherent default per patient: a default family record (customer edits it), a **multi-nurse continuity history** (two prior notes from *different* nurses, proving the history persists across nurse changes; a nurse append prepends to the same patient's history), and a **foreign-patient access-denied** path (`MOCK_FOREIGN_PATIENT_ID = 8888``canView:false` + a `403` on every read) so the non-leaking access-denied card is demoable. Clinical text is fixture data (never logged) | `USE_PATIENT_RECORDS_MOCK` (`services/patientRecords/constants.ts`, default `true`) | Deliver **REQ-027** (family-owned `care_record` GET/PUT + `record_access` + structured `taskResults`), then set flag `false` — the history/append methods already map the real b14 routes; only the family-record/access methods flip. Confirm whether the family-owned record is a real MVP entity | 🟡 | | `PatientRecordsApi` | `client/src/services/patientRecords/apis/mockApi.ts` | **The f13 continuity-of-care surface.** Two very different things: (1) the **nurse-authored visit-note history** (`getPatientHistory`/`createVisitNote`) is **REAL b14** (`GET`/`POST patients/{id}/care_records`), mapped 1:1 in `patientRecordsClientApi` (the append composes the ticked task checklist into the note `body` since the wire has no structured task field); (2) the **family-owned editable record** (medications/routine/tasks — the داروها/روتین/وظایف tabs) and the **access check** have **NO backend at all** (neither the b14 contract nor `data-model/10-reviews-and-records.md` model them → **REQ-027**). The mock is **patient-scoped** and lazily seeds a coherent default per patient: a default family record (customer edits it), a **multi-nurse continuity history** (two prior notes from *different* nurses, proving the history persists across nurse changes; a nurse append prepends to the same patient's history), and a **foreign-patient access-denied** path (`MOCK_FOREIGN_PATIENT_ID = 8888``canView:false` + a `403` on every read) so the non-leaking access-denied card is demoable. Clinical text is fixture data (never logged) | `USE_PATIENT_RECORDS_MOCK` (`services/patientRecords/constants.ts`, default `true`) | Deliver **REQ-027** (family-owned `care_record` GET/PUT + `record_access` + structured `taskResults`), then set flag `false` — the history/append methods already map the real b14 routes; only the family-record/access methods flip. Confirm whether the family-owned record is a real MVP entity | 🟡 |
| f8 bookings mock — completed-booking seed 5005 + f13 cross-mock reads | `client/src/services/bookings/apis/mockApi.ts` | **Non-seam additions (mirrors the f10 refunds precedent).** The f8 seeds had **no `completed` booking** (only `confirmed`/`in_progress`/`cancelled`), so f13's review flow needs one: added **booking 5005** (`status: 'completed'`, nurse 1, patient 905, one completed EVV session) so the customer can open a completed booking and leave a review. Also added a **cross-mock read helper**`mockGetBookingForReview(id)` (single booking, clone) — imported by the reviews mock to gate eligibility and read the patient/nurse snapshot for a submission (the `listBookings` seam row omits `patientId`/`nurseId`). One-way edge INTO bookings (the bookings mock never imports f13), so no cycle | — (part of `USE_BOOKINGS_MOCK`) | When the bookings flow goes real (b9/b10 conversion live), 5005 stops being a static seed and the cross-mock helpers retire with the reviews/records mocks | 🟢 (real, refinement-phase-4) | | f8 bookings mock — completed-booking seed 5005 + f13 cross-mock reads | `client/src/services/bookings/apis/mockApi.ts` | **Non-seam additions (mirrors the f10 refunds precedent).** The f8 seeds had **no `completed` booking** (only `confirmed`/`in_progress`/`cancelled`), so f13's review flow needs one: added **booking 5005** (`status: 'completed'`, nurse 1, patient 905, one completed EVV session) so the customer can open a completed booking and leave a review. Also added a **cross-mock read helper**`mockGetBookingForReview(id)` (single booking, clone) — imported by the reviews mock to gate eligibility and read the patient/nurse snapshot for a submission (the `listBookings` seam row omits `patientId`/`nurseId`). One-way edge INTO bookings (the bookings mock never imports f13), so no cycle | — (part of `USE_BOOKINGS_MOCK`) | When the bookings flow goes real (b9/b10 conversion live), 5005 stops being a static seed and the cross-mock helpers retire with the reviews/records mocks | 🟢 (real, refinement-phase-4) |
@@ -0,0 +1,185 @@
# UI Phase 7 — Nurse Daily Ops — Report (2026-07-19)
## What was built
**The nurse "امروز" dashboard** (`nurse/page.tsx``NurseDashboardScreen.tsx`, new) — replaces the
`PlaceholderScreen` with pure assembly over already-cached queries:
- Greeting header (name off `useMe`) + own `TrustBadge` (`ownBadgeState`, one cached `useVerificationStatus`
query, not two).
- `NextVisitCard` — first actionable session from `useTodaySessions`, a display-only "starts in" line
(`formatRelativeTime`), a check-in shortcut to `/nurse/visits`; a calm «امروز ویزیتی ندارید» empty state.
- `RequestsStrip` — the most time-critical widget (sorts above earnings): pending count, the most urgent
request's urgency-tinted countdown, an inline open into the request detail.
- `EarningsSnapshotCard` — a compact two-stat row (signed net payable + eligible) via `useNurseEarningsBalance`
+ `<Money>`; never clamps a negative balance.
- `DashboardActivationSlot` (new, exported, page-local) — the **named composition point** for Phase 8. Filled
for now with only the existing verification-status banner when not yet approved; renders nothing once
approved. **Phase 8: extend `client/src/app/[locale]/(private-routes)/nurse/DashboardActivationSlot.tsx`
in place — don't add a second slot.**
- `NotificationsEntryRow` — unread count (`useUnreadCount`) linking to `ROUTES.NURSE_NOTIFICATIONS`.
- Every widget: skeleton → error-with-retry → empty → data, in that order.
**Visits day surface** (`visits/page.tsx`):
- Shamsi «امروز، {day month}» date anchor via `PageHeader` (replaces the static title).
- `useTodaySessions` gained a 60s `refetchInterval` (`TODAY_SESSIONS_REFETCH_MS`) so a same-day schedule
change surfaces without re-navigation; the EVV-mutation invalidation is untouched.
- `SessionCard` gained an optional `serviceLabel` prop, rendered under the title; the day surface passes
`item.variantLabel` (REQ-052, mock-tolerant — `undefined` on the real path just omits the line).
- A real error state (`ErrorState` + retry) replaces the old two-field `{ data, isLoading }` destructure that
rendered "no visits today" on a failed query.
**`SessionCard` EVV hero** (shared, both roles — gated on `showEvvControls`):
- Check-in/check-out are now the **full-width, ≥48px** primary action (`width:'100%', minHeight:48`),
replacing the small `alignSelf:'flex-start'` button visually equal to the "view booking" text link beside
it.
- Check-out now goes through the new shared `CheckOutConfirmButton` (`components/booking/`, tested) — a
lightweight `ConfirmDialog` before firing («اتمام ویزیت؟» — it ends the visit and starts the payout clock).
Reused by `BookingDetailView`'s in-visit banner so both entry points confirm the same way.
**Visit detail workspace** (`BookingDetailView.tsx`, shared both roles):
- **Address card** (new, standalone — replaces the old inline hero address line): renders the frozen
`addressSnapshotJson` when present (title/city/district/line, best-effort parse — REQ-045 still open), a
`geo:{lat},{lng}` map deep-link + a Neshan web-map fallback link when the snapshot carries coordinates
(REQ-051), and a quiet nurse-only "available after confirmation" note when masked. Never sourced from the
b8 request stage.
- **In-visit mode** (new, nurse-only): when a session is `checked_in`, an `AccentCard` state header —
«در حال ویزیت» + elapsed on-site time (`formatElapsed` against the server `checkInAt`, never a guessed
start) + the check-out CTA **promoted to the top**, via the same `CheckOutConfirmButton`.
- **Contact affordance — already delivered, verified, no changes needed.** `BookingSupportEntry`/
`EmergencyBanner` (messaging composites, already mounted on `visits/[id]/page.tsx`) already surface a
`tel:` click-to-call from the gated care-instructions read for the nurse on a confirmed+ booking. The
`phone`/`navigate` icons the phase doc flagged as missing are also already registered
(`AppIcon/config.ts`) — both audit findings were stale against current code.
- Notes placement: `NurseVisitNotesPanel`/`BookingSupportEntry`/`BookingDetailView` were already each
`maxWidth:640, mx:'auto', width:'100%'` and stacked under one `gap:3` — verified consistent, no change
needed.
**Request inbox redesign** (`requests/page.tsx`, `requests/[id]/page.tsx`):
- Decision-first `InboxCard`: service + price headline when the list item carries `variantLabel`/
`variantPrice` (REQ-050, mock-tolerant — degrades to the patient-name headline on the real path), patient/
time/gender as secondary facts. The whole card is now a tappable link (`AppLink`), matching the "fully
tappable AccentCard row" convention from the customer bookings screen.
- Urgency-tinted countdown pill: `CountdownTimer`'s existing v2 tier API (`warnThresholdSeconds`/
`urgentThresholdSeconds`) at teal >2h / amber <2h / terracotta <30min, a label, and `coarseLabel` (which
opts into `aria-live="polite"` humanized copy) — no extension to `CountdownTimer` was needed, its ui-phase-1
API already covers this.
- Tabs + pager: «در انتظار» (`pending_nurse_response`) / «پاسخ‌داده» (client-merged `accepted_awaiting_payment`
+ `converted` + `rejected_by_nurse`, **page-1-only — documented limitation**, filed as part of REQ-050's
status-group-filter ask) / «منقضی» (`expired_no_response`). `useNurseRequestInbox` gained an optional
`enabled` param so the four non-active tab queries don't poll in the background.
- Accept now requires a `ConfirmDialog` («با پذیرش، خانواده برای پرداخت دعوت می‌شود؛ پس از پرداخت، رزرو قطعی
می‌شود.») before firing — the payment-window duration is never hard-coded; after acceptance the detail page
renders a `CountdownTimer` against the server `paymentDeadlineAt`.
- The inbox's error handling (`isError``ErrorState`) was **already correct** in the current code — the
phase doc's cited line numbers were stale (a prior phase had already fixed the false-empty defect).
**Earnings clarity** (`earnings/page.tsx`, `PayoutHistoryRow`, payout detail page):
- «برداشت بعدی» `ForecastLine` above the tabs — server-served only (`nextPayoutDate`/
`nextPayoutEligibleAmountIrr`, both optional on `NurseEarningsSummary`), renders nothing until served
(REQ-053, never computed client-side).
- Failure-reason mapping (`services/payouts/failureReasons.ts`, new): known `failureReason` codes (today:
`invalid_sheba`) render a mapped Persian/English label as the headline; unknown codes get a generic message;
the raw code is always demoted to a secondary `dir="ltr"` caption — never the raw vendor string as the
headline. Applied to both `PayoutHistoryRow` and the payout detail page.
- `ExplainerCard` a11y: the bare `onClick` `Stack` is now a real `ButtonBase` (`aria-expanded`, `aria-controls`)
and the eye icons (`visibilityon`/`visibilityoff`) are replaced by the registered `expand` chevron (rotates
180° when open).
- Width normalization: adopted one page-level convention — `CONTENT_MAX_WIDTH` (800) + `mx:'auto'` — across
every page this phase touches (`requests/page.tsx`, `requests/[id]/page.tsx`, `visits/page.tsx`,
`earnings/page.tsx`, `earnings/payouts/page.tsx`, `earnings/payouts/[id]/page.tsx`). The dashboard uses a
wider `DASHBOARD_MAX_WIDTH` (960) per the phase's "the dashboard may go wider" allowance. `BookingDetailView`
(640) is a component-level width, untouched — a separate decision from the page-level convention.
**New shared component:** `Pager` (`components/common/Pager/`, tested) — the prev/next "page X of Y" control,
replacing three near-identical inline pagers (earnings, payout history, and the new inbox tabs). New `common`
namespace i18n keys (`page_prev`/`page_next`/`page_indicator`) so it doesn't depend on the `payouts`
namespace's copies (left in place, unused by the new code, but not deleted — out of scope to chase down every
call site).
**REQ-039…043 renumbered to REQ-050…054** — the phase doc was written assuming REQ-001…038 were taken, but
ui-phase-3 through ui-phase-6 have since filed REQ-039…049. Filed as REQ-050 (inbox decision data +
status-group filter), REQ-051 (nurse-view address post-confirmation), REQ-052 (today-feed service label),
REQ-053 (payout forecast), REQ-054 (web-push, deferred/non-blocking) — see `for-backend.md`.
## What is now testable (and exactly how)
1. Log in as the seeded verified nurse (refinement-phase-1 demo accounts) → `/nurse` shows a real dashboard:
greeting + trust badge, next visit (or the calm empty state), a pending-requests strip, an earnings
snapshot, and (if not yet approved) the activation banner.
2. `/nurse/visits` (mobile, `/fa`): a Shamsi «امروز، …» header; check-in is a full-width primary CTA; check-out
opens a confirm dialog first. Deny browser GPS → check-in still succeeds with the advisory warning toast.
3. `/nurse/requests`: three tabs; a pending card leads with service + price **only when
`USE_BOOKING_REQUESTS_MOCK=true`** (flip the flag in `services/bookingRequests/constants.ts` — the real
path degrades to the patient-name headline); the countdown pill is teal/amber/terracotta by remaining time
(seed/adjust a request's deadline to see the escalation). Open a request → «پذیرش» → confirm dialog →
accept → the detail shows a payment-window countdown.
4. Stop the API (or force a query error) on `/nurse/requests` or `/nurse/visits` → an `ErrorState` panel with
retry, never the empty state.
5. `/nurse/visits/[id]` with **`USE_BOOKINGS_MOCK=true`** on a confirmed booking (e.g. id `5001` or the
completed `5005`) → an address card with a working `geo:`/Neshan-web map link (booking 5001's seed now
carries lat/lng matching the EVV reference point); on the real (masked) path → the quiet "available after
confirmation" note, no crash. Check in on a today session, then open its booking detail → the «در حال
ویزیت» banner with elapsed time + a promoted check-out CTA.
6. `/nurse/earnings`: the forecast line always appears (payouts stays mock-primary,
`USE_PAYOUTS_MOCK=true` by default); a failed payout (seeded `invalid_sheba`) shows a mapped Persian/
English label as the headline with the raw code as a small LTR caption; the explainer header opens with
Enter/Space and reports `aria-expanded`.
7. Repeat 16 on `/en` and dark mode — no stock-MUI colors, no Latin digits in `fa` timers.
**Verification performed:** `npm run check` (tsc + eslint) is green; `npm run test:ci` is green — 106 suites /
455 tests, including the 2 new suites (`Pager`, `CheckOutConfirmButton`) and every touched shared component's
existing suite (`SessionCard`, `BookingDetailView`, `PayoutHistoryRow`). A dev-server smoke pass confirmed
every touched route (`/nurse`, `/nurse/requests[/1]`, `/nurse/visits[/5001]`, `/nurse/earnings[/payouts[/9001]]`,
`/en/nurse`) compiles and responds 200 with no server errors. **Not performed:** a full authenticated
click-through in a browser (no browser-automation tool available in this session, and exercising the OTP login
flow end-to-end needs the backend API running) — the "How to test" steps above are written for a human to run
that pass manually.
## What is mocked / waiting on a real service
- `services/bookingRequests`**real** by default (`USE_BOOKING_REQUESTS_MOCK=false`); the mock's
`toListItem` now also stamps `variantLabel`/`variantPrice`/`variantPriceUnit` (REQ-050) so the redesigned
inbox card is demonstrable when the flag is flipped for local testing. No registry row (bookingRequests was
already de-mocked in refinement-phase-4 and has none — consistent with its siblings).
- `services/bookings`**real** by default (`USE_BOOKINGS_MOCK=false`); mock changes: `forViewer` now
unmasks `addressSnapshotJson` for the nurse once `isBookingConfirmedOrBeyond` (simulating REQ-051 ahead of
the real endpoint), `addr5001` gained matching `latitude`/`longitude`, and `listTodaySessions` stamps
`variantLabel` (REQ-052). See the updated `mocks-registry.md` row.
- `services/payouts`**mock-primary** (`USE_PAYOUTS_MOCK=true`, unchanged — REQ-025 is still the root gap);
`buildSummary()` now also serves `nextPayoutDate`/`nextPayoutEligibleAmountIrr` (REQ-053). See the updated
`mocks-registry.md` row.
- No new seams introduced this phase — every change extends an existing `services/{domain}` mock behind its
existing seam.
## Contracts
- Consumed: `dev/contracts/domains/{booking-requests,bookings-evv,payouts}.md` (unchanged this phase — no
contract landed to consume).
- Filed to `for-backend.md`: **REQ-050** (nurse inbox `variantLabel`/`variantPrice`/`variantPriceUnit` + an
`answered` status-group filter), **REQ-051** (nurse-view address on confirmed+ bookings), **REQ-052**
(service label on the today feed), **REQ-053** (payout forecast), **REQ-054** (web-push, deferred).
## Docs updated
- `client/CLAUDE.md` "Project Structure": the nurse route tree (`page.tsx``NurseDashboardScreen.tsx` +
`DashboardActivationSlot.tsx`, `requests/`, `visits/`, `earnings/`) and the shared component tree
(`components/common/Pager/`, `components/booking/`'s `CheckOutConfirmButton` + the `BookingDetailView`/
`SessionCard` additions) updated in the same change.
- `dev/shared-working-context/reports/mocks-registry.md`: `BookingsApi` and `PayoutsApi` rows updated in place
with this phase's mock additions (also corrected `BookingsApi`'s stale "default `true`" config-flag note —
it's `false`, real-primary, since refinement-phase-4).
## Follow-ups for later phases
- **Phase 8** owns `DashboardActivationSlot`'s content — the fuller "go live" checklist (profile/services/
coverage/bank all done), per the phase doc's own hand-off note.
- REQ-050's status-group filter (`status=answered`) would collapse the inbox's three-query «پاسخ‌داده» merge
into one real paginated query — currently page-1-only, documented in the REQ and in code.
- REQ-051 (nurse-view address) is the highest-value of this phase's REQs — until delivered, the address card
is demoable only via the mock; REQ-045 (a typed address-snapshot shape) is still open from ui-phase-5 and
would let the address card + `.ics` export drop their best-effort JSON-key parsing.
- The `payouts` namespace's now-unused `failure_reason_label`/`page_prev`/`page_next`/`page_indicator` i18n
keys were left in place (superseded by `failure_code_*` and the shared `common.page_*` keys respectively) —
a future cleanup pass could remove them if nothing else references them.
- Web-push (REQ-054) remains genuinely deferred — the 15s poll is the only freshness mechanism for new
requests until a service-worker + backend push rail is built.