ui phase 7
This commit is contained in:
+8
-5
@@ -164,8 +164,10 @@ client/
|
||||
│ │ ├── nurse/ # Nurse app (/nurse/…) — sidebar shell
|
||||
│ │ │ ├── layout.tsx # 'use client' — RoleGuard(expected=nurse) → NurseLayout
|
||||
│ │ │ ├── loading.tsx # → ../_chrome/SidebarShellSkeleton
|
||||
│ │ │ ├── page.tsx # /nurse (dashboard) — RSC; generateMetadata (nav.dashboard) inline (no split — already a Server Component)
|
||||
│ │ │ ├── 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)
|
||||
│ │ │ ├── page.tsx # /nurse (dashboard) — thin RSC (generateMetadata nav.dashboard) rendering NurseDashboardScreen.tsx
|
||||
│ │ │ ├── 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)
|
||||
│ │ │ ├── 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)
|
||||
@@ -177,8 +179,8 @@ client/
|
||||
│ │ │ │ ├── review/page.tsx # B6 — under-review (same status query, condensed mini-checklist)
|
||||
│ │ │ │ ├── VerificationChecklist.tsx # B3 body: meter + step rows (co-located, page-only)
|
||||
│ │ │ │ └── verificationSteps.ts # step→label/chip/route helpers + synthetic mobile step (keeps rendering data-driven)
|
||||
│ │ │ ├── visits/ # /nurse/visits — f8 EVV: page.tsx = ویزیت امروز today-sessions feed (per-session check-in/out via useEvvController + advisory EvvStatusBanner) ↔ visits/[id]/page.tsx nurse booking detail (BookingDetailView viewerRole="nurse": EVV controls + gated care card) + f13 NurseVisitNotesPanel.tsx (co-located, BELOW the EVV banner: today's task checklist + free-text note composer + read-only continuity history — APPEND-ONLY, never wires useUpdateCareRecord; services/patientRecords)
|
||||
│ │ │ ├── earnings/ # /nurse/earnings — f12 nurse earnings (read-only): page.tsx = EarningsBalanceHeader (net payable balance + 4 buckets, negative "owed back") + cadence/dispute-window explainer + state-segmented EarningsRow list (deep-links to /nurse/visits/[id]) ↔ payouts/page.tsx (PayoutHistoryRow list) → payouts/[id]/page.tsx (payout/batch reconciliation detail: money decomposition + masked IBAN + booking links)
|
||||
│ │ │ ├── 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), ui-phase-7 pass: page.tsx = EarningsBalanceHeader (net payable balance + 4 buckets, negative "owed back") + a «برداشت بعدی» ForecastLine (server-served only) + an accessible ButtonBase ExplainerCard (aria-expanded, registered `expand` chevron) + state-segmented EarningsRow list (deep-links to /nurse/visits/[id], shared Pager) ↔ payouts/page.tsx (PayoutHistoryRow list) → payouts/[id]/page.tsx (payout/batch reconciliation detail: money decomposition + masked IBAN + booking links); failed-payout reasons now map through `services/payouts/failureReasons.ts` (mapped label headline, raw code demoted to a secondary LTR caption)
|
||||
│ │ │ ├── support/tickets/ # /nurse/support/tickets — f14 nurse "My Tickets" (same TicketInboxScreen/TicketThreadScreen, role="nurse") ↔ support/tickets/[id]/page.tsx
|
||||
│ │ │ └── notifications/page.tsx # /nurse/notifications — f14 notification center (role="nurse"); the nurse-shell bell deep-links here
|
||||
│ │ ├── admin/ # Admin/backoffice (/admin/…) — desktop sidebar shell (f15). Every screen is role-gated via useAdminCapabilities(); the sidebar hides a console the current admin role can't act on (server still enforces).
|
||||
@@ -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)
|
||||
│ │ ├── StickyActionBar/ # ui-phase-4 — a `position:sticky` bottom-pinned action-bar shell for a scrolling screen's primary CTA (C1's live-count CTA, C3's booking CTA); composes with the shell's existing BottomBar safe-area padding rather than reimplementing `env(safe-area-inset-bottom)`
|
||||
│ │ ├── LocaleSwitcher/ # ui-2 fa/en toggle preserving the current route (`router.replace(pathname, {locale})` via `@/i18n/navigation`); sidebar footers, the customer profile hub, the public shell (tested)
|
||||
│ │ ├── Pager/ # ui-phase-7 — the shared prev/next "page X of Y" control (common namespace i18n) replacing the near-identical inline pagers hand-rolled per list screen (nurse inbox tabs, payout history/earnings) (tested)
|
||||
│ │ └── index.tsx # barrel — keep next-intl-importing primitives (Money) below the presentational ones so the poisoning risk stays visible in review
|
||||
│ ├── PlaceholderScreen/ # Empty-state scaffold for not-yet-built screens
|
||||
│ ├── OtpInput/ # OTP code input (auto-advance, paste, RTL-safe)
|
||||
@@ -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)
|
||||
│ ├── 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)
|
||||
│ ├── 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)
|
||||
│ ├── 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
|
||||
|
||||
+52
-3
@@ -63,7 +63,10 @@
|
||||
"brand": "Balinyaar",
|
||||
"brand_tagline": "Home care you can trust",
|
||||
"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": {
|
||||
"customer_app": "Family app",
|
||||
@@ -640,7 +643,49 @@
|
||||
"active_empty_body": "Your ongoing bookings appear here.",
|
||||
"past_empty_title": "No past bookings",
|
||||
"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": {
|
||||
"title_checkout": "Confirm & pay",
|
||||
@@ -1157,7 +1202,11 @@
|
||||
"net_amount_label": "Net amount",
|
||||
"amount_transferred_label": "Transferred",
|
||||
"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": {
|
||||
"title": "Leave a review",
|
||||
|
||||
+52
-3
@@ -63,7 +63,10 @@
|
||||
"brand": "بالین یار",
|
||||
"brand_tagline": "مراقبت مطمئن در خانه",
|
||||
"open_sidebar": "باز کردن منو",
|
||||
"switch_locale": "تغییر به {locale}"
|
||||
"switch_locale": "تغییر به {locale}",
|
||||
"page_prev": "قبلی",
|
||||
"page_next": "بعدی",
|
||||
"page_indicator": "صفحه {page} از {total}"
|
||||
},
|
||||
"shell": {
|
||||
"customer_app": "اپلیکیشن خانواده",
|
||||
@@ -640,7 +643,49 @@
|
||||
"active_empty_body": "رزروهای در حال انجام شما اینجا نمایش داده میشوند.",
|
||||
"past_empty_title": "رزرو گذشتهای ندارید",
|
||||
"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": {
|
||||
"title_checkout": "تایید و پرداخت",
|
||||
@@ -1157,7 +1202,11 @@
|
||||
"net_amount_label": "مبلغ خالص",
|
||||
"amount_transferred_label": "واریزشده",
|
||||
"detail_bookings_title": "رزروهای دربرگرفته",
|
||||
"detail_bookings_hint": "این واریز بابت این ویزیتها پرداخت شده است."
|
||||
"detail_bookings_hint": "این واریز بابت این ویزیتها پرداخت شده است.",
|
||||
"forecast_label": "برداشت بعدی",
|
||||
"forecast_on_date": "در {date}",
|
||||
"failure_code_invalid_sheba": "شماره شبای حساب نامعتبر است.",
|
||||
"failure_code_unknown": "این انتقال ناموفق بود."
|
||||
},
|
||||
"reviews": {
|
||||
"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 { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Box, Collapse, Paper, Skeleton, Stack, Tab, Tabs, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, EarningsBalanceHeader, EarningsRow, EmptyState, ErrorState } from '@/components';
|
||||
import { Box, ButtonBase, Collapse, Paper, Skeleton, Stack, Tab, Tabs, Typography } from '@mui/material';
|
||||
import { AppIcon, EarningsBalanceHeader, EarningsRow, EmptyState, ErrorState, Money, Pager, SurfaceCard } from '@/components';
|
||||
import { CONTENT_MAX_WIDTH } from '@/components/config';
|
||||
import { nurseBookingDetailPath, nursePayoutDetailPath } from '@/constants';
|
||||
import { formatNumber } from '@/utils';
|
||||
import { formatShamsiDate } from '@/utils';
|
||||
import { PAYOUTS_PAGE_SIZE } from '@/services/payouts/constants';
|
||||
import { EARNINGS_STATES, type EarningsState } from '@/services/payouts/types';
|
||||
import { useNurseEarnings, useNurseEarningsBalance } from '@/services/payouts';
|
||||
@@ -46,7 +47,7 @@ export default function NurseEarningsPage() {
|
||||
};
|
||||
|
||||
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>
|
||||
<Typography variant="h5" component="h1">
|
||||
{t('title')}
|
||||
@@ -61,7 +62,10 @@ export default function NurseEarningsPage() {
|
||||
) : balance.isError ? (
|
||||
<ErrorState message={t('balance_error')} retryLabel={t('retry')} onRetry={() => balance.refetch()} />
|
||||
) : balance.data ? (
|
||||
<EarningsBalanceHeader summary={balance.data} />
|
||||
<>
|
||||
<EarningsBalanceHeader summary={balance.data} />
|
||||
<ForecastLine nextPayoutDate={balance.data.nextPayoutDate} nextPayoutEligibleAmountIrr={balance.data.nextPayoutEligibleAmountIrr} />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<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 }) {
|
||||
const t = useTranslations('payouts');
|
||||
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)',
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between', cursor: 'pointer' }}
|
||||
<ButtonBase
|
||||
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' }}>
|
||||
<AppIcon icon="info" size={18} color="var(--bal-info)" />
|
||||
@@ -136,9 +176,14 @@ function ExplainerCard({ open, onToggle }: { open: boolean; onToggle: () => void
|
||||
{t('explainer_title')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<AppIcon icon={open ? 'visibilityoff' : 'visibilityon'} size={18} color="var(--bal-text-secondary)" />
|
||||
</Stack>
|
||||
<Collapse in={open}>
|
||||
<AppIcon
|
||||
icon="expand"
|
||||
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 }}>
|
||||
{points.map((key) => (
|
||||
<Typography key={key} component="li" variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
@@ -150,35 +195,3 @@ function ExplainerCard({ open, onToggle }: { open: boolean; onToggle: () => void
|
||||
</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 { AppButton, AppIcon, Money, PriceBreakdown, StatusChip } from '@/components';
|
||||
import type { StatusKind } from '@/components';
|
||||
import { CONTENT_MAX_WIDTH } from '@/components/config';
|
||||
import { nurseBookingDetailPath, ROUTES } from '@/constants';
|
||||
import { formatShamsiDate, parseIrr } from '@/utils';
|
||||
import { failureReasonLabelKey } from '@/services/payouts/failureReasons';
|
||||
import { useNursePayoutDetail } from '@/services/payouts';
|
||||
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);
|
||||
|
||||
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 }}>
|
||||
<AppButton
|
||||
variant="text"
|
||||
@@ -123,9 +125,12 @@ export default function NursePayoutDetailPage() {
|
||||
{t('failure_title')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t(failureReasonLabelKey(data.failureReason))}
|
||||
</Typography>
|
||||
{data.failureReason ? (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }} dir="ltr">
|
||||
{t('failure_reason_label')}: {data.failureReason}
|
||||
{data.failureReason}
|
||||
</Typography>
|
||||
) : null}
|
||||
<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 { useRouter } from 'next/navigation';
|
||||
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 { formatNumber } from '@/utils';
|
||||
import { PAYOUTS_PAGE_SIZE } from '@/services/payouts/constants';
|
||||
import { useNursePayoutHistory } from '@/services/payouts';
|
||||
|
||||
@@ -26,10 +26,9 @@ export default function NursePayoutHistoryPage() {
|
||||
const items = history.data?.items ?? [];
|
||||
const total = history.data?.total ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / PAYOUTS_PAGE_SIZE));
|
||||
const fmt = (n: number) => formatNumber(n, locale);
|
||||
|
||||
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 }}>
|
||||
<AppButton
|
||||
variant="text"
|
||||
@@ -70,19 +69,12 @@ export default function NursePayoutHistoryPage() {
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{pageCount > 1 ? (
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'center' }}>
|
||||
<AppButton variant="text" color="primary" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page <= 1}>
|
||||
{t('page_prev')}
|
||||
</AppButton>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('page_indicator', { page: fmt(page), total: fmt(pageCount) })}
|
||||
</Typography>
|
||||
<AppButton variant="text" color="primary" onClick={() => setPage((p) => Math.min(pageCount, p + 1))} disabled={page >= pageCount}>
|
||||
{t('page_next')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
) : null}
|
||||
<Pager
|
||||
page={page}
|
||||
pageCount={pageCount}
|
||||
onPrev={() => setPage((p) => Math.max(1, p - 1))}
|
||||
onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Metadata } from 'next';
|
||||
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> {
|
||||
const { locale } = await params;
|
||||
@@ -8,8 +8,6 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
|
||||
return { title: t('dashboard') };
|
||||
}
|
||||
|
||||
export default async function NurseDashboardPage() {
|
||||
const t = await getTranslations('nav');
|
||||
const tShell = await getTranslations('shell');
|
||||
return <PlaceholderScreen icon="dashboard" title={t('dashboard')} description={tShell('placeholder_body')} />;
|
||||
export default function NurseDashboardPage() {
|
||||
return <NurseDashboardScreen />;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,8 @@ import {
|
||||
TextField,
|
||||
Typography,
|
||||
} 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 { ApiError } from '@/lib/api/errors';
|
||||
import { formatShamsiDate, localeTag } from '@/utils';
|
||||
@@ -54,12 +55,13 @@ export default function NurseRequestDetailPage() {
|
||||
const [rejectOpen, setRejectOpen] = useState(false);
|
||||
const [reason, setReason] = useState('');
|
||||
const [reasonError, setReasonError] = useState(false);
|
||||
const [acceptConfirmOpen, setAcceptConfirmOpen] = useState(false);
|
||||
|
||||
if (isLoading) return <DetailSkeleton />;
|
||||
|
||||
if (isError || !request) {
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2, maxWidth: 640 }}>
|
||||
<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 }}>
|
||||
{t('not_found_title')}
|
||||
</Typography>
|
||||
@@ -121,7 +123,7 @@ export default function NurseRequestDetailPage() {
|
||||
const whenLabel = `${formatShamsiDate(startDate, locale)} · ${timeFmt.format(startDate)} – ${timeFmt.format(endDate)}`;
|
||||
|
||||
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' }}>
|
||||
<Typography variant="h5" component="h1">
|
||||
{t('detail_title')}
|
||||
@@ -201,7 +203,7 @@ export default function NurseRequestDetailPage() {
|
||||
variant="contained"
|
||||
startIcon="verified"
|
||||
disabled={acceptRequest.isPending}
|
||||
onClick={handleAccept}
|
||||
onClick={() => setAcceptConfirmOpen(true)}
|
||||
sx={{ flex: 1, py: 1.25 }}
|
||||
>
|
||||
{acceptRequest.isPending ? t('accepting') : t('accept')}
|
||||
@@ -230,12 +232,36 @@ export default function NurseRequestDetailPage() {
|
||||
borderInlineStartColor: isTerminal ? 'var(--bal-text-secondary)' : 'var(--bal-secondary)',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t(`status_${request.status}`)}
|
||||
</Typography>
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t(`status_${request.status}`)}
|
||||
</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>
|
||||
)}
|
||||
|
||||
<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">
|
||||
<DialogTitle>{t('reject_dialog_title')}</DialogTitle>
|
||||
<DialogContent>
|
||||
@@ -307,7 +333,7 @@ function DetailRow({ caption, children }: { caption: string; children: React.Rea
|
||||
|
||||
function DetailSkeleton() {
|
||||
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="rounded" height={180} />
|
||||
<Skeleton variant="rounded" height={120} />
|
||||
|
||||
@@ -1,53 +1,125 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Box, Chip, Paper, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, CountdownTimer, EmptyState, ErrorState } from '@/components';
|
||||
import { Box, Chip, Skeleton, Stack, Tab, Tabs, Typography } from '@mui/material';
|
||||
import { AppLink, CountdownTimer, EmptyState, ErrorState, Pager, PageHeader, SurfaceCard } from '@/components';
|
||||
import { CONTENT_MAX_WIDTH } from '@/components/config';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { formatShamsiDate, localeTag } from '@/utils';
|
||||
import { useNurseRequestInbox } from '@/services/bookingRequests';
|
||||
import { coarseResponseLabel } from '@/services/bookingRequests/format';
|
||||
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
|
||||
* patient name, the requested time (Shamsi), the **required-caregiver-gender** chip, a notes preview, and
|
||||
* a **per-request countdown** to that request's response deadline. Two-stage disclosure: the row shows
|
||||
* only `customerNotes` — never an address or any clinical field. Lightly polled so new requests appear.
|
||||
* Nurse incoming-requests inbox (نمای پرستار), ui-phase-7 redesign. Decision-first cards (service + price
|
||||
* headline when the list DTO serves them — REQ-050, mock-tolerant), an urgency-tinted countdown pill, and
|
||||
* three tabs: «در انتظار» (a single `pending_nurse_response` query) / «پاسخداده» (merged, page-1-only —
|
||||
* 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() {
|
||||
const t = useTranslations('booking');
|
||||
const tc = useTranslations('common');
|
||||
const { data, isLoading, isError, refetch } = useNurseRequestInbox();
|
||||
const items = data?.items ?? [];
|
||||
const [tab, setTab] = useState<InboxTab>('pending');
|
||||
const [pendingPage, setPendingPage] = useState(1);
|
||||
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 (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 640 }}>
|
||||
<Box>
|
||||
<Typography variant="h5" component="h1">
|
||||
{t('inbox_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('inbox_subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}>
|
||||
<PageHeader title={t('inbox_title')} subtitle={t('inbox_subtitle')} />
|
||||
|
||||
{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 }}>
|
||||
{[0, 1].map((key) => (
|
||||
<Skeleton key={key} variant="rounded" height={140} />
|
||||
))}
|
||||
</Stack>
|
||||
) : isError ? (
|
||||
<ErrorState message={t('inbox_error')} retryLabel={tc('retry')} onRetry={() => refetch()} />
|
||||
) : items.length === 0 ? (
|
||||
) : active.isError ? (
|
||||
<ErrorState message={t('inbox_error')} retryLabel={t('retry')} onRetry={() => active.refetch()} />
|
||||
) : active.items.length === 0 ? (
|
||||
<EmptyState icon="requests" title={t('inbox_empty')} />
|
||||
) : (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
{items.map((item) => (
|
||||
{active.items.map((item) => (
|
||||
<InboxCard key={item.id} item={item} />
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -55,59 +127,72 @@ export default function NurseRequestsPage() {
|
||||
function InboxCard({ item }: { item: BookingRequestListItem }) {
|
||||
const t = useTranslations('booking');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
|
||||
const startDate = new Date(`${item.requestedDate}T${item.requestedTimeStart}`);
|
||||
const endDate = new Date(`${item.requestedDate}T${item.requestedTimeEnd}`);
|
||||
const timeFmt = new Intl.DateTimeFormat(localeTag(locale), { hour: '2-digit', minute: '2-digit' });
|
||||
const whenLabel = `${formatShamsiDate(startDate, locale)} · ${timeFmt.format(startDate)} – ${timeFmt.format(endDate)}`;
|
||||
const hasPricedService = Boolean(item.variantLabel);
|
||||
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'flex-start', gap: 2 }}>
|
||||
<Stack sx={{ gap: 0.5, minWidth: 0 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{item.counterpartyName}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{whenLabel}
|
||||
</Typography>
|
||||
<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 direction="row" sx={{ justifyContent: 'space-between', alignItems: 'flex-start', gap: 2 }}>
|
||||
<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 }}>
|
||||
{item.counterpartyName}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{whenLabel}
|
||||
</Typography>
|
||||
</Stack>
|
||||
{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>
|
||||
<CountdownTimer deadlineIso={item.nurseResponseDeadlineAt} elapsedText={t('response_elapsed')} />
|
||||
|
||||
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
|
||||
{item.requiredCaregiverGender ? (
|
||||
<Chip
|
||||
size="small"
|
||||
label={t('required_gender_chip', { gender: t(`gender_${item.requiredCaregiverGender}`) })}
|
||||
sx={{ bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 500 }}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
{item.customerNotes ? (
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 700 }}>
|
||||
{t('inbox_notes_label')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }} noWrap>
|
||||
{item.customerNotes}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
{item.requiredCaregiverGender ? (
|
||||
<Box>
|
||||
<Chip
|
||||
size="small"
|
||||
label={t('required_gender_chip', { gender: t(`gender_${item.requiredCaregiverGender}`) })}
|
||||
sx={{ bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 500 }}
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{item.customerNotes ? (
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 700 }}>
|
||||
{t('inbox_notes_label')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }} noWrap>
|
||||
{item.customerNotes}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
<AppButton
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
endIcon="requests"
|
||||
onClick={() => router.push(`/${locale}${ROUTES.NURSE_REQUESTS}/${item.id}`)}
|
||||
sx={{ alignSelf: 'flex-start' }}
|
||||
>
|
||||
{t('open_detail')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</SurfaceCard>
|
||||
</AppLink>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,36 +1,36 @@
|
||||
'use client';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Box, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, EmptyState } from '@/components';
|
||||
import { Box, Skeleton, Stack } from '@mui/material';
|
||||
import { AppButton, EmptyState, ErrorState, PageHeader } from '@/components';
|
||||
import { SessionCard, useEvvController } from '@/components/booking';
|
||||
import { CONTENT_MAX_WIDTH } from '@/components/config';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { formatShamsiDate } from '@/utils';
|
||||
import { useSessionEvv, useTodaySessions } from '@/services/bookings';
|
||||
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
|
||||
* `useTodaySessions`; each renders the shared `SessionCard` with the per-session EVV check-in/out control
|
||||
* (driven by one `useEvvController`) and the advisory EVV banner once checked in. A GPS mismatch is
|
||||
* advisory, never a block. Each card also deep-links to the full booking detail (`/nurse/visits/{id}`),
|
||||
* where the gated care instructions live. Visit-note authoring + task checklist are deferred to f13.
|
||||
* `useTodaySessions` (polled every 60s so a same-day schedule change surfaces without re-navigation);
|
||||
* each renders the shared `SessionCard` with the per-session EVV check-in/out control (driven by one
|
||||
* `useEvvController`) and the advisory EVV banner once checked in. A GPS mismatch is advisory, never a
|
||||
* block. Each card also deep-links to the full booking detail (`/nurse/visits/{id}`), where the gated
|
||||
* care instructions live. Visit-note authoring + task checklist are deferred to f13.
|
||||
*/
|
||||
export default function NurseVisitsPage() {
|
||||
const t = useTranslations('booking');
|
||||
const { data, isLoading } = useTodaySessions();
|
||||
const locale = useLocale();
|
||||
const { data, isLoading, isError, refetch } = useTodaySessions();
|
||||
const evv = useEvvController();
|
||||
const items = data?.items ?? [];
|
||||
const dateAnchor = t('today_date_prefix', { date: formatShamsiDate(new Date(), locale, TODAY_HEADER_DATE_OPTIONS) });
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 640 }}>
|
||||
<Box>
|
||||
<Typography variant="h5" component="h1">
|
||||
{t('evv_visits_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('evv_visits_subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}>
|
||||
<PageHeader title={dateAnchor} subtitle={t('evv_visits_subtitle')} />
|
||||
|
||||
{isLoading ? (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
@@ -38,6 +38,8 @@ export default function NurseVisitsPage() {
|
||||
<Skeleton key={key} variant="rounded" height={150} />
|
||||
))}
|
||||
</Stack>
|
||||
) : isError ? (
|
||||
<ErrorState message={t('evv_visits_error')} retryLabel={t('retry')} onRetry={() => refetch()} />
|
||||
) : items.length === 0 ? (
|
||||
<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 }}>
|
||||
<SessionCard
|
||||
title={item.patientName}
|
||||
serviceLabel={item.variantLabel}
|
||||
sessionIndex={item.sessionIndex}
|
||||
scheduledDate={item.scheduledDate}
|
||||
scheduledTimeStart={item.scheduledTimeStart}
|
||||
|
||||
@@ -12,10 +12,14 @@ export interface EarningsBalanceHeaderProps {
|
||||
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. */
|
||||
const BUCKETS: ReadonlyArray<{
|
||||
key: 'pending' | 'eligible' | 'paid' | 'clawback';
|
||||
amountKey: keyof NurseEarningsSummary;
|
||||
amountKey: EarningsBucketAmountKey;
|
||||
tone: AccentTone;
|
||||
token: string;
|
||||
icon: string;
|
||||
|
||||
@@ -56,10 +56,13 @@ describe('<PayoutHistoryRow/> component', () => {
|
||||
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'));
|
||||
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.
|
||||
expect(screen.queryByText('retry')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import SurfaceCard from '@/components/common/SurfaceCard';
|
||||
import AccentCard from '@/components/common/AccentCard';
|
||||
import Money from '@/components/common/Money';
|
||||
import { formatShamsiDate } from '@/utils';
|
||||
import { failureReasonLabelKey } from '@/services/payouts/failureReasons';
|
||||
import type { NursePayoutHistoryItem, PayoutStatus } from '@/services/payouts/types';
|
||||
|
||||
export interface PayoutHistoryRowProps {
|
||||
@@ -82,9 +83,12 @@ const PayoutHistoryRow: FunctionComponent<PayoutHistoryRowProps> = ({ item, onOp
|
||||
{t('failure_title')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t(failureReasonLabelKey(item.failureReason))}
|
||||
</Typography>
|
||||
{item.failureReason ? (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }} dir="ltr">
|
||||
{t('failure_reason_label')}: {item.failureReason}
|
||||
{item.failureReason}
|
||||
</Typography>
|
||||
) : null}
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
|
||||
|
||||
@@ -18,8 +18,10 @@ import BookingStatusTimeline from '../BookingStatusTimeline';
|
||||
import SessionList from '../SessionList';
|
||||
import BookingMoneySummary from '../BookingMoneySummary';
|
||||
import CareInstructionsCard from '../CareInstructionsCard';
|
||||
import CheckOutConfirmButton from '../CheckOutConfirmButton';
|
||||
import { useEvvController } from '../useEvvController';
|
||||
import { buildSessionIcs, downloadIcsFile } from '../ics';
|
||||
import { formatElapsed } from '../format';
|
||||
|
||||
export interface BookingDetailViewProps {
|
||||
bookingId: number;
|
||||
@@ -64,11 +66,39 @@ function upcomingSession(sessions: BookingSessionDto[]): BookingSessionDto | und
|
||||
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 {
|
||||
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
|
||||
* `BookingStatusTimeline`, the `SessionList`, and the `BookingMoneySummary`. Role-conditioned: the
|
||||
@@ -97,7 +127,8 @@ const BookingDetailView: FunctionComponent<BookingDetailViewProps> = ({ bookingI
|
||||
const service = variantName(booking.variantSnapshotJson);
|
||||
const addressLabel = addressSnapshotLabel(booking.addressSnapshotJson, locale);
|
||||
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 nextSessionDayLabel = nextSession
|
||||
@@ -175,15 +206,6 @@ const BookingDetailView: FunctionComponent<BookingDetailViewProps> = ({ bookingI
|
||||
</Stack>
|
||||
) : 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' }}>
|
||||
<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)}
|
||||
@@ -207,6 +229,18 @@ const BookingDetailView: FunctionComponent<BookingDetailViewProps> = ({ bookingI
|
||||
</Stack>
|
||||
</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>
|
||||
<BookingStatusTimeline status={booking.status} />
|
||||
</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 }) {
|
||||
return (
|
||||
<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 });
|
||||
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 { formatShamsiDate } from '@/utils';
|
||||
import type { BookingSessionStatus, VisitVerificationStatus } from '@/services/bookings/types';
|
||||
import CheckOutConfirmButton from '../CheckOutConfirmButton';
|
||||
import EvvStatusBanner from '../EvvStatusBanner';
|
||||
import { formatClock, formatElapsed, formatTimeRange } from '../format';
|
||||
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 {
|
||||
/** Optional heading above the visit row — the patient name on the nurse's day feed (omitted in the detail). */
|
||||
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;
|
||||
/** ISO date `YYYY-MM-DD`. */
|
||||
scheduledDate: string;
|
||||
@@ -49,6 +57,7 @@ export interface SessionCardProps {
|
||||
*/
|
||||
const SessionCard: FunctionComponent<SessionCardProps> = ({
|
||||
title,
|
||||
serviceLabel,
|
||||
sessionIndex,
|
||||
scheduledDate,
|
||||
scheduledTimeStart,
|
||||
@@ -79,9 +88,16 @@ const SessionCard: FunctionComponent<SessionCardProps> = ({
|
||||
<SurfaceCard padding="sm" data-session-status={status}>
|
||||
<Stack sx={{ gap: 1.25 }}>
|
||||
{title ? (
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
<Stack sx={{ gap: 0.25 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{serviceLabel ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{serviceLabel}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Stack>
|
||||
) : null}
|
||||
<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' }}>
|
||||
@@ -116,23 +132,19 @@ const SessionCard: FunctionComponent<SessionCardProps> = ({
|
||||
startIcon={acquiringLocation ? 'gps' : 'check_in'}
|
||||
disabled={busy}
|
||||
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')}
|
||||
</AppButton>
|
||||
) : null}
|
||||
|
||||
{showEvvControls && status === 'in_progress' && evvStatus === 'checked_in' ? (
|
||||
<AppButton
|
||||
color="secondary"
|
||||
variant="outlined"
|
||||
startIcon={acquiringLocation ? 'gps' : 'check_out'}
|
||||
disabled={busy}
|
||||
onClick={onCheckOut}
|
||||
sx={{ alignSelf: 'flex-start', py: 1 }}
|
||||
>
|
||||
{acquiringLocation ? t('evv_acquiring_location') : evvPending ? t('evv_checking_out') : t('evv_check_out')}
|
||||
</AppButton>
|
||||
<CheckOutConfirmButton
|
||||
onConfirm={onCheckOut ?? noop}
|
||||
busy={evvPending}
|
||||
acquiringLocation={acquiringLocation}
|
||||
fullWidth
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
</SurfaceCard>
|
||||
|
||||
@@ -16,6 +16,8 @@ export { default as EvvStatusBanner } from './EvvStatusBanner';
|
||||
export type { EvvStatusBannerProps, EvvMatchState } from './EvvStatusBanner';
|
||||
export { default as CareInstructionsCard } 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 type { BookingMoneySummaryProps } from './BookingMoneySummary';
|
||||
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';
|
||||
@@ -19,6 +19,7 @@ import JalaliDateField from './JalaliDateField';
|
||||
import JalaliDateIntentPicker from './JalaliDateIntentPicker';
|
||||
import LocaleSwitcher from './LocaleSwitcher';
|
||||
import StickyActionBar from './StickyActionBar';
|
||||
import Pager from './Pager';
|
||||
|
||||
export {
|
||||
ErrorBoundary,
|
||||
@@ -42,6 +43,7 @@ export {
|
||||
JalaliDateIntentPicker,
|
||||
LocaleSwitcher,
|
||||
StickyActionBar,
|
||||
Pager,
|
||||
};
|
||||
export type { EmptyStateProps } from './EmptyState';
|
||||
export type { ErrorStateProps } from './ErrorState';
|
||||
@@ -56,3 +58,4 @@ export type { JalaliDatePickerProps } from './JalaliDatePicker';
|
||||
export type { JalaliDateFieldProps } from './JalaliDateField';
|
||||
export type { JalaliDateIntentPickerProps } from './JalaliDateIntentPicker';
|
||||
export type { StickyActionBarProps } from './StickyActionBar';
|
||||
export type { PagerProps } from './Pager';
|
||||
|
||||
@@ -132,6 +132,11 @@ function toListItem(dto: BookingRequestDto, role: RequestRole): BookingRequestLi
|
||||
nurseResponseDeadlineAt: dto.nurseResponseDeadlineAt,
|
||||
paymentDeadlineAt: dto.paymentDeadlineAt,
|
||||
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
|
||||
* 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 params = { role: 'nurse' as const, status, page, pageSize: BOOKING_REQUEST_PAGE_SIZE };
|
||||
return useQuery({
|
||||
queryKey: bookingRequestKeys.nurseInbox(params),
|
||||
queryFn: () => bookingRequestsApi.list(params),
|
||||
enabled: isAuthenticated,
|
||||
enabled: isAuthenticated && (options?.enabled ?? true),
|
||||
staleTime: BOOKING_REQUEST_STALE_TIME,
|
||||
gcTime: BOOKING_REQUEST_GC_TIME,
|
||||
refetchInterval: BOOKING_REQUEST_POLL_MS,
|
||||
|
||||
@@ -129,6 +129,14 @@ export interface BookingRequestListItem {
|
||||
paymentDeadlineAt: string | null;
|
||||
/** Nurse view only (stage-1 plaintext); `null` in the customer inbox. */
|
||||
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. */
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
TodaySessionsParams,
|
||||
VisitVerificationDto,
|
||||
} from '../types';
|
||||
import { isBookingConfirmedOrBeyond } from '../types';
|
||||
|
||||
const MOCK_LATENCY_MS = 350;
|
||||
|
||||
@@ -37,6 +38,16 @@ function isoDate(daysFromToday: number): string {
|
||||
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. */
|
||||
function distanceMeters(lat1: number, lng1: number, lat2: number, lng2: number): number {
|
||||
const R = 6_371_000;
|
||||
@@ -80,6 +91,10 @@ function seed(): void {
|
||||
district: 'سعادتآباد',
|
||||
line: 'خیابان نمونه، کوچه دوم، پلاک ۱۲',
|
||||
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({
|
||||
title: 'آپارتمان',
|
||||
@@ -352,10 +367,15 @@ function findSession(sessionId: number): { booking: BookingDetailDto; session: B
|
||||
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 {
|
||||
const clone = cloneBooking(b);
|
||||
if (viewerRole === 'nurse') clone.addressSnapshotJson = null;
|
||||
if (viewerRole === 'nurse' && !isBookingConfirmedOrBeyond(b.status)) clone.addressSnapshotJson = null;
|
||||
return clone;
|
||||
}
|
||||
|
||||
@@ -430,6 +450,8 @@ export const bookingsMockApi: BookingsApi = {
|
||||
scheduledTimeEnd: session.scheduledTimeEnd,
|
||||
status: session.status,
|
||||
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. */
|
||||
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. */
|
||||
export const SESSION_EVV_STALE_TIME = 15 * 1000;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { bookingsApi } from '../apis';
|
||||
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';
|
||||
|
||||
/**
|
||||
@@ -19,5 +19,6 @@ export function useTodaySessions(options?: { date?: string; page?: number; pageS
|
||||
queryKey: bookingKeys.today(params),
|
||||
queryFn: () => bookingsApi.listTodaySessions(params),
|
||||
staleTime: TODAY_SESSIONS_STALE_TIME,
|
||||
refetchInterval: TODAY_SESSIONS_REFETCH_MS,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -180,6 +180,13 @@ export interface BookingSessionListItemDto {
|
||||
scheduledTimeEnd: string;
|
||||
status: BookingSessionStatus;
|
||||
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
|
||||
* BigInt and **not clamped** — under `clawback_heavy` it goes negative ("owed back"). `paid` never enters
|
||||
* 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 {
|
||||
const pending = BigInt(4_250_000);
|
||||
@@ -309,6 +314,8 @@ function buildSummary(): NurseEarningsSummary {
|
||||
paidTotalIrr: String(paid),
|
||||
clawbackOutstandingIrr: String(clawbackOutstanding),
|
||||
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';
|
||||
}
|
||||
@@ -77,6 +77,15 @@ export interface NurseEarningsSummary {
|
||||
* negative** when outstanding clawbacks exceed accrued-unpaid earnings ("owed back"). Never clamp.
|
||||
*/
|
||||
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. */
|
||||
|
||||
Reference in New Issue
Block a user