backend phase 15 & frontend phase 8

This commit is contained in:
hamid
2026-07-10 03:22:29 +03:30
parent 93cc5ecb98
commit cd6c2591a6
154 changed files with 15335 additions and 37 deletions
+6 -3
View File
@@ -126,7 +126,8 @@ client/
│ │ │ │ └── nurse/[nurseId]/page.tsx # C3 nurse profile — badges (TrustBadge + نظام پرستاری) + attribute chips + ServicePriceRow list + latest review; "درخواست رزرو" hands off to /bookings/request (f7)
│ │ │ ├── onboarding/page.tsx # /onboarding — A3→A4 wizard (relation → first patient)
│ │ │ ├── bookings/
│ │ │ │ ├── page.tsx # /bookings
│ │ │ │ ├── page.tsx # /bookings — f8 رزروها list (useBookingList('customer')); rows → booking detail
│ │ │ │ ├── [id]/page.tsx # /bookings/[id] — f8 customer booking detail (BookingDetailView viewerRole="customer")
│ │ │ │ ├── request/page.tsx # /bookings/request — f7 C4 request form (patient/variant/address/date/time + first-class caregiver-gender + stage-1 notes); C3 hands off the nurse/variant/required_gender here → creates a request → C5
│ │ │ │ ├── request/[id]/page.tsx # /bookings/request/[id] — f7 C5 awaiting screen: summary card + 3-step tracker + polled status; response countdown → (accept) 30-min payment countdown + checkout CTA / (reject/expire/cancel) terminal cards
│ │ │ │ └── checkout/page.tsx # /bookings/checkout — pay & confirm handoff target (DEFERRED→f9 stub; C5 accept CTA lands here with request_id)
@@ -149,7 +150,7 @@ 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/page.tsx # /nurse/visits (EVV)
│ │ │ └── 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)
│ │ └── admin/ # Admin/backoffice (/admin/…) — desktop sidebar shell
│ │ ├── layout.tsx # 'use client' — wraps AdminLayout
│ │ ├── page.tsx # /admin (overview)
@@ -179,6 +180,7 @@ client/
│ ├── ServicePriceRow/ # f6 C3 service line: localised name + PriceDisplay (money util + i18n unit label); reused by the booking summary later (tested)
│ ├── CountdownTimer/ # f7 pure presentational countdown to a server-frozen UTC deadline; owns its own 1s tick (only it re-renders), stops + shows elapsed text at zero, locale digits LTR (tested)
│ ├── BookingRequestSummaryCard/ # f7 engagement summary (nurse+rating, patient, priced service, address, Shamsi time) — shared by C5 + nurse detail + later f8 booking detail (tested)
│ ├── booking/ # f8 post-payment engagement composites (import from @/components/booking). BookingDetailView (both-roles smart container, role-conditioned EVV+gated care), BookingStatusTimeline (server-truth 7-status timeline over StepperHeader), SessionList→SessionCard (per-session schedule/status/EVV CTA), EvvStatusBanner (advisory in/out-of-range/no-gps), CareInstructionsCard (decrypted clinical read), BookingMoneySummary (gross/commission/payout display-only); useEvvController (GPS-capture + check-in/out orchestration), format.ts + statusKind.ts helpers. Each composite tested; the BookingDetailView test proves the customer never fires the care query (two-stage-disclosure gate)
│ ├── geography/ # F3 geo composites: CascadingRegionSelect, AddressMapPicker (map-pin stand-in), AddressForm, AddressCard (each tested)
│ └── auth/ # Auth-flow composites: LoginFlow, PhoneStep, OtpStep, RoleRouter, SelectRole, AuthCard, BrandMark, AuthSplash, useCountdown
├── i18n/
@@ -231,6 +233,7 @@ client/
│ ├── search/ # F6 family discovery (b7). The **filter object IS the query key** (searchKeys.results + canonicalizeSearchFilters): identical/reverted filters reuse cache with zero network (keepPreviousData avoids flashing). useNurseSearch/useNurseProfile/useDebouncedValue; filterParams.ts = the shared C1↔C2 URL (de)serializer; seam+mock(PRIMARY)+client. Mock supplies name/avatar/distance/profile/reviews that b7's index row + b5/b6 reads don't yet expose (gap filed in for-backend.md). Every returned row is verified-by-invariant — the UI never re-filters
│ ├── verification/ # F5 nurse trust flow (b6). ONE cached status() query drives B3+B6; every mutation invalidates it. useVerificationStatus/useStartVerification/useSubmitIdentity/useRunBankVerification/useUploadVerificationDocument/useSubmitCredentials/useNurseTrustBadge; seam+mock(primary)+client; validation.ts (national-ID checksum); types export ownBadgeState/publicBadgeState/isApproved
│ ├── bookingRequests/ # F7 pre-payment request lifecycle (b8). Money-free create→accept/reject/cancel + role-scoped inbox + single get. useCreateBookingRequest/useBookingRequest(polls until terminal)/useNurseRequestInbox/useCustomerRequests/useAccept/useReject/useCancel; seam+mock(PRIMARY, shared in-memory state machine — customer create ↔ nurse inbox ↔ accept flips C5; lazy expiry sweep)+client. Server-frozen UTC deadlines rendered by CountdownTimer (never recomputed); two-stage disclosure (nurse `get(id,'nurse')` masks address); variantPrice client-augmented (REQ-013). Contract-live but mock-primary because inputs (search/patients/addresses) are mock-primary
│ ├── bookings/ # F8 post-payment engagement (b9) — the SIBLING of bookingRequests, NOT a rename. useBookingDetail/useBookingSessions(select over detail — sessions are embedded)/useBookingList/useTodaySessions/useSessionEvv/useCareInstructions(enabled-gated)/useCheckInVisit/useCheckOutVisit; seam+mock(PRIMARY, seeded confirmed bookings + sessions + care + EVV state machine)+client(1:1 b9)+serverApi(RSC-prefetch seam, real-path). evv/locationProvider.ts = the ILocationProvider GPS seam (real navigator.geolocation vs mock coords by NEXT_PUBLIC_EVV_MOCK_GPS in_range|out_of_range|denied). Money display-only (gross=commission+payout server-side); timeline=server truth; care read gated to assigned nurse; EVV mismatch/denial advisory (never blocks); EVV mutations invalidate detail+session+today+list
│ └── {domain}/
│ ├── types.ts # Request/response types + the domain's Api interface (the seam)
│ ├── keys.ts # React Query key factory (hierarchical)
@@ -324,7 +327,7 @@ async function MyServerComponent() {
- `'catalog'`**shared** catalog vocabulary: the five `price_unit` labels + count nouns + the estimated-total label (read by `PriceDisplay`; f6 reuses it customer-side)
- `'services'` — the f4 nurse Services & prices surface (offerings list, the variant builder steps/fields/validation, the duplicate-listing warning, deactivate confirm)
- `'search'` — the f6 discovery flow (C1/C2/C3): filter section labels, the same-gender facet + hint, sort/count (ICU plural), all four result states + "relax filters" suggestions, card labels (rating/distance/from-price), profile badges (تاییدشده/نظام پرستاری)/attribute chips/specialty codes/services/latest review, and the "درخواست رزرو" CTA
- `'booking'` — the f7 booking-request flow (C4 form fields/validation, C5 tracker steps + dual-countdown + terminal-state copy, the nurse inbox + detail, gender labels, per-status labels, summary-card captions); consumed by the C4/C5 pages, the nurse requests pages, and the shared `BookingRequestSummaryCard`
- `'booking'` — the f7 booking-request flow (C4 form fields/validation, C5 tracker steps + dual-countdown + terminal-state copy, the nurse inbox + detail, gender labels, per-status labels, summary-card captions) **and the f8 post-payment engagement** (booking-status timeline labels `bstatus_*`, session-status labels `sstatus_*`, the EVV banner variants `evv_banner_{in_range,out_of_range,no_gps}` + check-in/out CTAs + GPS-acquiring copy, the care-instructions section labels `care_*` + the customer "visible to your nurse only" copy, the money summary `money_*`, the dispute-window note, the bookings list `list_*`); consumed by the C4/C5 pages, the nurse requests pages, the f8 booking-detail/EVV pages, and the shared `BookingRequestSummaryCard` + `booking/` composites
- `'verification'` — the f5 nurse trust flow: B3/B4/B5/B6 copy, per-step labels + status labels (keyed off code, never derived), the DocumentUpload state chrome, TrustBadge labels, the honesty-sensitive manual-vs-auto copy, the publish-gate + shared-SIM/mismatch messages
- `'auth'` — the phone-OTP login flow, role router, and SelectRole screen (`common.brand`/`brand_tagline` for the wordmark)
+5
View File
@@ -5,6 +5,11 @@
// Learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';
// next/jest loads .env / .env.test but not .env.development, so NEXT_PUBLIC_API_URL (required by
// `@/config`) is absent in tests. Any test that renders a component using a `services/{domain}` hook pulls
// the real `clientApi` → `@/config` at import time, which throws without this. Provide a harmless default.
process.env.NEXT_PUBLIC_API_URL = process.env.NEXT_PUBLIC_API_URL || 'https://localhost:5002';
// To get 'next/router' working with tests
jest.mock('next/router', () => require('next-router-mock'));
+83 -1
View File
@@ -474,7 +474,89 @@
"status_rejected_by_nurse": "Declined by nurse",
"status_expired_no_response": "No response in time",
"status_payment_deadline_expired": "Payment window lapsed",
"status_cancelled_by_customer": "Cancelled"
"status_cancelled_by_customer": "Cancelled",
"bd_title": "Booking",
"bd_ref": "Booking #{id}",
"bd_not_found_title": "Booking not found",
"bd_not_found_body": "This booking doesn't exist or isn't yours.",
"bd_my_bookings": "My bookings",
"timeline_title": "Progress",
"bstatus_pending_payment": "Pending payment",
"bstatus_confirmed": "Confirmed",
"bstatus_in_progress": "In progress",
"bstatus_completed": "Completed",
"bstatus_disputed": "Under review",
"bstatus_closed": "Closed",
"bstatus_cancelled": "Cancelled",
"confirmed_note": "Your booking is confirmed. The visits below are scheduled.",
"in_progress_note": "A visit is currently underway.",
"completed_note": "All visits are complete.",
"dispute_window_note": "Payment is released to the nurse after the review window closes on {date}.",
"cancelled_note": "This booking was cancelled.",
"disputed_note": "This booking is under review by our support team.",
"sessions_title": "Visit schedule",
"session_index": "Visit {n}",
"session_count": "{count, plural, =0 {no visits} one {# visit} other {# visits}}",
"sstatus_scheduled": "Scheduled",
"sstatus_in_progress": "In progress",
"sstatus_completed": "Completed",
"sstatus_missed": "Missed",
"sstatus_cancelled": "Cancelled",
"session_elapsed": "On site {duration}",
"session_payout": "Visit payout",
"money_title": "Payment summary",
"money_gross": "Service cost",
"money_commission": "Balinyaar fee",
"money_payout": "Nurse payout",
"money_nurse_earning": "Your earning",
"evv_today_title": "Today's visit",
"evv_visits_title": "Today's visits",
"evv_visits_subtitle": "Clock in and out of each visit with EVV.",
"evv_no_visits": "No visits scheduled for today.",
"evv_check_in": "Check in (EVV)",
"evv_check_out": "Check out (EVV)",
"evv_checking_in": "Checking in…",
"evv_checking_out": "Checking out…",
"evv_acquiring_location": "Getting your location…",
"evv_banner_in_range": "Checked in {time} · location confirmed (EVV)",
"evv_banner_out_of_range": "Checked in {time} · location out of range (under review)",
"evv_banner_no_gps": "Checked in {time} · location not captured",
"evv_checked_out_at": "Checked out {time}",
"evv_gps_denied_note": "Location permission was denied — you can still record the visit; it's flagged for review.",
"evv_no_open_check_in": "There's no open check-in to close.",
"evv_not_startable": "This visit can't be started yet.",
"evv_checked_in_toast": "Check-in recorded.",
"evv_checked_out_toast": "Check-out recorded.",
"evv_check_in_error": "Couldn't record the check-in. Please try again.",
"evv_check_out_error": "Couldn't record the check-out. Please try again.",
"evv_nurse_view": "Nurse view",
"view_booking": "View booking",
"care_title": "Care instructions",
"care_subtitle": "Shared with you as the assigned nurse after confirmation.",
"care_conditions": "Conditions",
"care_medications": "Medications",
"care_allergies": "Allergies",
"care_instructions_label": "Special instructions",
"care_emergency": "Emergency contact",
"care_none": "Not recorded",
"care_empty": "No care instructions were recorded for this booking.",
"care_error": "Couldn't load the care instructions.",
"care_locked_title": "Care details",
"care_locked_body": "The full care record is visible only to your assigned nurse and support.",
"list_title": "My bookings",
"list_subtitle": "Your confirmed engagements.",
"list_empty_title": "No bookings yet",
"list_empty_body": "Once a nurse accepts and you pay, your booking appears here.",
"list_error": "Couldn't load your bookings.",
"list_total": "Total"
},
"auth": {
"customer_title": "Sign in to Balinyaar",
+83 -1
View File
@@ -474,7 +474,89 @@
"status_rejected_by_nurse": "ردشده توسط پرستار",
"status_expired_no_response": "بدون پاسخ در مهلت",
"status_payment_deadline_expired": "پایان مهلت پرداخت",
"status_cancelled_by_customer": "لغوشده"
"status_cancelled_by_customer": "لغوشده",
"bd_title": "رزرو",
"bd_ref": "رزرو #{id}",
"bd_not_found_title": "رزرو یافت نشد",
"bd_not_found_body": "این رزرو وجود ندارد یا متعلق به شما نیست.",
"bd_my_bookings": "رزروهای من",
"timeline_title": "روند رزرو",
"bstatus_pending_payment": "در انتظار پرداخت",
"bstatus_confirmed": "تاییدشده",
"bstatus_in_progress": "در حال انجام",
"bstatus_completed": "تکمیل‌شده",
"bstatus_disputed": "در حال بررسی اختلاف",
"bstatus_closed": "بسته‌شده",
"bstatus_cancelled": "لغوشده",
"confirmed_note": "رزرو شما تایید شد. ویزیت‌های زیر برنامه‌ریزی شده‌اند.",
"in_progress_note": "یک ویزیت هم‌اکنون در حال انجام است.",
"completed_note": "همهٔ ویزیت‌ها به پایان رسید.",
"dispute_window_note": "مبلغ پس از پایان مهلت بررسی در {date} برای پرستار آزاد می‌شود.",
"cancelled_note": "این رزرو لغو شده است.",
"disputed_note": "این رزرو در حال بررسی توسط تیم پشتیبانی است.",
"sessions_title": "برنامهٔ ویزیت‌ها",
"session_index": "ویزیت {n}",
"session_count": "{count} ویزیت",
"sstatus_scheduled": "برنامه‌ریزی‌شده",
"sstatus_in_progress": "در حال انجام",
"sstatus_completed": "تکمیل‌شده",
"sstatus_missed": "انجام‌نشده",
"sstatus_cancelled": "لغوشده",
"session_elapsed": "مدت حضور {duration}",
"session_payout": "سهم این ویزیت",
"money_title": "خلاصهٔ پرداخت",
"money_gross": "هزینهٔ خدمت",
"money_commission": "کارمزد پلتفرم",
"money_payout": "سهم پرستار",
"money_nurse_earning": "درآمد شما",
"evv_today_title": "ویزیت امروز",
"evv_visits_title": "ویزیت‌های امروز",
"evv_visits_subtitle": "برای هر ویزیت، ورود و خروج را با EVV ثبت کنید.",
"evv_no_visits": "برای امروز ویزیتی برنامه‌ریزی نشده است.",
"evv_check_in": "ثبت ورود (EVV)",
"evv_check_out": "ثبت خروج (EVV)",
"evv_checking_in": "در حال ثبت ورود…",
"evv_checking_out": "در حال ثبت خروج…",
"evv_acquiring_location": "در حال دریافت موقعیت…",
"evv_banner_in_range": "ورود ثبت شد {time} · موقعیت تایید شد (EVV)",
"evv_banner_out_of_range": "ورود ثبت شد {time} · موقعیت خارج از محدوده (در حال بررسی)",
"evv_banner_no_gps": "ورود ثبت شد {time} · موقعیت ثبت نشد",
"evv_checked_out_at": "خروج ثبت شد {time}",
"evv_gps_denied_note": "دسترسی به موقعیت داده نشد — همچنان می‌توانید ویزیت را ثبت کنید؛ برای بررسی علامت‌گذاری می‌شود.",
"evv_no_open_check_in": "ورود بازی برای ثبت خروج وجود ندارد.",
"evv_not_startable": "این ویزیت هنوز قابل شروع نیست.",
"evv_checked_in_toast": "ورود ثبت شد.",
"evv_checked_out_toast": "خروج ثبت شد.",
"evv_check_in_error": "ثبت ورود انجام نشد. دوباره تلاش کنید.",
"evv_check_out_error": "ثبت خروج انجام نشد. دوباره تلاش کنید.",
"evv_nurse_view": "نمای پرستار",
"view_booking": "مشاهدهٔ رزرو",
"care_title": "دستورالعمل مراقبت",
"care_subtitle": "پس از تایید، به‌عنوان پرستار مسئول با شما به اشتراک گذاشته شده است.",
"care_conditions": "شرایط پزشکی",
"care_medications": "داروها",
"care_allergies": "حساسیت‌ها",
"care_instructions_label": "دستورهای ویژه",
"care_emergency": "تماس اضطراری",
"care_none": "ثبت نشده",
"care_empty": "برای این رزرو دستورالعمل مراقبتی ثبت نشده است.",
"care_error": "بارگذاری دستورالعمل مراقبت ممکن نشد.",
"care_locked_title": "شرح مراقبت",
"care_locked_body": "شرح کامل مراقبت تنها برای پرستار مسئول شما و پشتیبانی قابل مشاهده است.",
"list_title": "رزروهای من",
"list_subtitle": "مراقبت‌های تاییدشدهٔ شما.",
"list_empty_title": "هنوز رزروی ندارید",
"list_empty_body": "پس از تایید پرستار و پرداخت، رزرو شما اینجا نمایش داده می‌شود.",
"list_error": "بارگذاری رزروها ممکن نشد.",
"list_total": "مبلغ کل"
},
"auth": {
"customer_title": "ورود به بلینیار",
@@ -0,0 +1,14 @@
'use client';
import { useParams } from 'next/navigation';
import { BookingDetailView } from '@/components/booking';
/**
* Customer booking detail (`/bookings/{id}`) — the read-only both-roles view in the **customer** shell:
* server-truth status timeline, session schedule, and money summary. Care instructions are gated to the
* assigned nurse, so the customer sees the "visible to your nurse only" affordance (the query never fires).
*/
export default function CustomerBookingDetailPage() {
const params = useParams<{ id: string }>();
const id = Number(params.id);
return <BookingDetailView bookingId={Number.isInteger(id) && id > 0 ? id : -1} viewerRole="customer" />;
}
@@ -1,8 +1,104 @@
import { getTranslations } from 'next-intl/server';
import { PlaceholderScreen } from '@/components';
'use client';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, StatusChip } from '@/components';
import { BOOKING_STATUS_KIND } from '@/components/booking/statusKind';
import { ROUTES } from '@/constants';
import { formatIrrToToman, formatShamsiDate } from '@/utils';
import { useBookingList } from '@/services/bookings';
import type { BookingListItemDto } from '@/services/bookings/types';
export default async function BookingsPage() {
const t = await getTranslations('nav');
const tShell = await getTranslations('shell');
return <PlaceholderScreen icon="bookings" title={t('bookings')} description={tShell('placeholder_body')} />;
/**
* Customer رزروها — the "My bookings" list. Reads `useBookingList('customer')`; each row opens the
* booking detail (`/bookings/{id}`). This is the customer entry to the f8 booking-detail surface (the C5
* `converted` state also lands here). Amounts render in Toman via the money util.
*/
export default function CustomerBookingsPage() {
const t = useTranslations('booking');
const { data, isLoading, isError } = useBookingList('customer');
const items = data?.items ?? [];
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Box>
<Typography variant="h5" component="h1">
{t('list_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('list_subtitle')}
</Typography>
</Box>
{isLoading ? (
<Stack sx={{ gap: 2 }}>
{[0, 1].map((key) => (
<Skeleton key={key} variant="rounded" height={120} />
))}
</Stack>
) : isError ? (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('list_error')}
</Typography>
</Paper>
) : items.length === 0 ? (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
<AppIcon icon="bookings" size={40} color="var(--bal-text-secondary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1 }}>
{t('list_empty_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
{t('list_empty_body')}
</Typography>
</Paper>
) : (
<Stack sx={{ gap: 2 }}>
{items.map((item) => (
<BookingRow key={item.id} item={item} />
))}
</Stack>
)}
</Box>
);
}
function BookingRow({ item }: { item: BookingListItemDto }) {
const t = useTranslations('booking');
const tc = useTranslations('common');
const locale = useLocale();
const router = useRouter();
return (
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1.5 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'flex-start', gap: 1.5, flexWrap: 'wrap' }}>
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{item.counterpartyName}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{formatShamsiDate(item.scheduledDate, locale)} · {t('session_count', { count: item.sessionCount })}
</Typography>
</Stack>
<StatusChip status={BOOKING_STATUS_KIND[item.status]} label={t(`bstatus_${item.status}`)} />
</Stack>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{t('list_total')}: {formatIrrToToman(item.amountIrr, locale)} {tc('currency_toman')}
</Typography>
<AppButton
variant="outlined"
color="primary"
endIcon="bookings"
onClick={() => router.push(`/${locale}${ROUTES.BOOKINGS}/${item.id}`)}
sx={{ m: 0 }}
>
{t('view_booking')}
</AppButton>
</Stack>
</Stack>
</Paper>
);
}
@@ -0,0 +1,14 @@
'use client';
import { useParams } from 'next/navigation';
import { BookingDetailView } from '@/components/booking';
/**
* Nurse booking detail (`/nurse/visits/{id}`) — the both-roles view in the **nurse** shell, where the
* assigned nurse gets the per-session EVV check-in/out controls and the gated care-instructions card
* (two-stage disclosure). Reached from the ویزیت امروز day surface.
*/
export default function NurseBookingDetailPage() {
const params = useParams<{ id: string }>();
const id = Number(params.id);
return <BookingDetailView bookingId={Number.isInteger(id) && id > 0 ? id : -1} viewerRole="nurse" />;
}
@@ -1,8 +1,98 @@
import { getTranslations } from 'next-intl/server';
import { PlaceholderScreen } from '@/components';
'use client';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon } from '@/components';
import { SessionCard, useEvvController } from '@/components/booking';
import { ROUTES } from '@/constants';
import { useSessionEvv, useTodaySessions } from '@/services/bookings';
import type { BookingSessionListItemDto } from '@/services/bookings/types';
export default async function NurseVisitsPage() {
const t = await getTranslations('nav');
const tShell = await getTranslations('shell');
return <PlaceholderScreen icon="visits" title={t('visits')} description={tShell('placeholder_body')} />;
/**
* Nurse ویزیت امروز (E3 top) — the day's operational surface. Lists today's sessions from
* `useTodaySessions`; each renders the shared `SessionCard` with the per-session EVV check-in/out control
* (driven by one `useEvvController`) and the advisory EVV banner once checked in. A GPS mismatch is
* advisory, never a block. Each card also deep-links to the full booking detail (`/nurse/visits/{id}`),
* where the gated care instructions live. Visit-note authoring + task checklist are deferred to f13.
*/
export default function NurseVisitsPage() {
const t = useTranslations('booking');
const { data, isLoading } = useTodaySessions();
const evv = useEvvController();
const items = data?.items ?? [];
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 640 }}>
<Box>
<Typography variant="h5" component="h1">
{t('evv_visits_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('evv_visits_subtitle')}
</Typography>
</Box>
{isLoading ? (
<Stack sx={{ gap: 2 }}>
{[0, 1].map((key) => (
<Skeleton key={key} variant="rounded" height={150} />
))}
</Stack>
) : items.length === 0 ? (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
<AppIcon icon="visits" size={40} color="var(--bal-text-secondary)" />
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 1 }}>
{t('evv_no_visits')}
</Typography>
</Paper>
) : (
<Stack sx={{ gap: 2 }}>
{items.map((item) => (
<TodayVisitCard key={item.sessionId} item={item} evv={evv} />
))}
</Stack>
)}
</Box>
);
}
function TodayVisitCard({ item, evv }: { item: BookingSessionListItemDto; evv: ReturnType<typeof useEvvController> }) {
const t = useTranslations('booking');
const locale = useLocale();
const router = useRouter();
// Fetch the EVV detail only once the session has activity, so the banner has the server check-in time.
const hasEvvActivity = item.evvStatus !== 'pending';
const { data: evvDetail } = useSessionEvv(item.sessionId, { enabled: hasEvvActivity });
return (
<Stack sx={{ gap: 0.75 }}>
<SessionCard
title={item.patientName}
sessionIndex={item.sessionIndex}
scheduledDate={item.scheduledDate}
scheduledTimeStart={item.scheduledTimeStart}
scheduledTimeEnd={item.scheduledTimeEnd}
status={item.status}
evvStatus={item.evvStatus}
checkInAt={evvDetail?.checkInAt ?? null}
checkOutAt={evvDetail?.checkOutAt ?? null}
checkInAddressMatch={evvDetail?.checkInAddressMatch ?? null}
showEvvControls
evvPending={evv.busySessionId === item.sessionId}
acquiringLocation={evv.acquiringSessionId === item.sessionId}
onCheckIn={() => evv.checkIn({ sessionId: item.sessionId, bookingId: item.bookingId })}
onCheckOut={() => evv.checkOut({ sessionId: item.sessionId, bookingId: item.bookingId })}
/>
<AppButton
variant="text"
color="primary"
endIcon="bookings"
onClick={() => router.push(`/${locale}${ROUTES.NURSE_VISITS}/${item.bookingId}`)}
sx={{ m: 0, alignSelf: 'flex-start' }}
>
{t('view_booking')}
</AppButton>
</Stack>
);
}
@@ -0,0 +1,49 @@
import { render, screen, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider } from '../../../theme';
jest.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
useLocale: () => 'en',
}));
jest.mock('notistack', () => ({ useSnackbar: () => ({ enqueueSnackbar: jest.fn() }) }));
import BookingDetailView from './BookingDetailView';
import { bookingsApi } from '@/services/bookings/apis';
function renderView(viewerRole: 'customer' | 'nurse') {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<BookingDetailView bookingId={5001} viewerRole={viewerRole} />
</ThemeProvider>
</QueryClientProvider>,
);
}
describe('<BookingDetailView/> — two-stage disclosure gate', () => {
afterEach(() => jest.restoreAllMocks());
it('customer: never fetches care instructions and shows the "visible to your nurse only" affordance', async () => {
const careSpy = jest.spyOn(bookingsApi, 'getCareInstructions');
renderView('customer');
// Wait for the booking to load and the locked affordance to render.
await waitFor(() => expect(screen.getByText('care_locked_title')).toBeInTheDocument());
// The hard gate: the client must not even request the clinical record for the customer.
expect(careSpy).not.toHaveBeenCalled();
expect(screen.queryByText('care_title')).not.toBeInTheDocument();
});
it('assigned nurse: fetches and renders the gated care instructions on a confirmed booking', async () => {
const careSpy = jest.spyOn(bookingsApi, 'getCareInstructions');
renderView('nurse');
await waitFor(() => expect(careSpy).toHaveBeenCalledWith(5001, 'nurse'));
// The nurse never sees the customer's locked placeholder.
expect(screen.queryByText('care_locked_title')).not.toBeInTheDocument();
await waitFor(() => expect(screen.getByText('care_title')).toBeInTheDocument());
});
});
@@ -0,0 +1,279 @@
'use client';
import { FunctionComponent } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Chip, Paper, Skeleton, Stack, Typography } from '@mui/material';
import AppIcon from '@/components/common/AppIcon';
import { formatShamsiDate } from '@/utils';
import { useBookingDetail, useCareInstructions } from '@/services/bookings';
import {
isBookingConfirmedOrBeyond,
type BookingDetailDto,
type BookingViewerRole,
} from '@/services/bookings/types';
import BookingStatusTimeline from '../BookingStatusTimeline';
import SessionList from '../SessionList';
import BookingMoneySummary from '../BookingMoneySummary';
import CareInstructionsCard from '../CareInstructionsCard';
import { useEvvController } from '../useEvvController';
export interface BookingDetailViewProps {
bookingId: number;
/** `customer` sees the read-only timeline + sessions + money; `nurse` adds EVV controls + gated care. */
viewerRole: BookingViewerRole;
}
/** Best-effort read of the frozen variant display name from the booking's variant snapshot. */
function variantName(snapshotJson: string): string | null {
try {
const parsed = JSON.parse(snapshotJson) as { displayName?: string };
return parsed?.displayName ?? null;
} catch {
return null;
}
}
/**
* The both-roles booking detail — the hinge screen. Fetches `useBookingDetail`, renders the server-truth
* `BookingStatusTimeline`, the `SessionList`, and the `BookingMoneySummary`. Role-conditioned: the
* **assigned nurse** additionally gets the per-session EVV controls and the gated `CareInstructionsCard`;
* the **customer** sees the read-only view and a "visible to your nurse only" affordance in place of the
* clinical record.
*
* **Two-stage disclosure is a hard UI gate:** the care-instructions query is `enabled` **only** for the
* nurse view on a `confirmed`+ booking — for the customer it never fires (a 404 would be a defect path,
* not the design). Money is display-only; the timeline is never advanced client-side.
* @component BookingDetailView
*/
const BookingDetailView: FunctionComponent<BookingDetailViewProps> = ({ bookingId, viewerRole }) => {
const t = useTranslations('booking');
const isNurse = viewerRole === 'nurse';
const { data: booking, isLoading, isError } = useBookingDetail(bookingId, viewerRole);
const careEnabled = isNurse && !!booking && isBookingConfirmedOrBeyond(booking.status);
const care = useCareInstructions(bookingId, { enabled: careEnabled });
const evv = useEvvController();
if (isLoading) return <DetailSkeleton />;
if (isError || !booking) return <NotFoundCard title={t('bd_not_found_title')} body={t('bd_not_found_body')} />;
const service = variantName(booking.variantSnapshotJson);
return (
<Stack sx={{ gap: 3, maxWidth: 640, mx: 'auto', width: '100%' }} data-viewer-role={viewerRole}>
{/* Header — carries the terracotta accent + "نمای پرستار" chip on the nurse view. */}
<Paper
elevation={0}
sx={{
p: 2.5,
border: '1px solid',
borderColor: 'divider',
borderRadius: 2,
...(isNurse ? { borderTopWidth: 3, borderTopColor: 'var(--bal-secondary)' } : {}),
}}
>
<Stack sx={{ gap: 1 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'flex-start', gap: 1.5, flexWrap: 'wrap' }}>
<Typography variant="h6" component="h1">
{service ?? t('bd_title')}
</Typography>
{isNurse ? (
<Chip
size="small"
label={t('evv_nurse_view')}
sx={{ bgcolor: 'var(--bal-secondary-soft)', color: 'var(--bal-secondary-dark)', fontWeight: 700 }}
/>
) : null}
</Stack>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('bd_ref', { id: booking.id })}
</Typography>
<Stack direction="row" sx={{ gap: 3, flexWrap: 'wrap', mt: 0.5 }}>
<HeaderFact label={t('summary_patient')} value={booking.patientName} />
<HeaderFact label={t('unnamed_nurse')} value={booking.nurseName} />
</Stack>
</Stack>
</Paper>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<BookingStatusTimeline status={booking.status} />
</Paper>
<StatusNote booking={booking} />
<Stack sx={{ gap: 1.5 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('sessions_title')}
</Typography>
<SessionList
sessions={booking.sessions}
showEvvControls={isNurse}
showPayout={isNurse}
busySessionId={evv.busySessionId}
acquiringSessionId={evv.acquiringSessionId}
onCheckIn={isNurse ? (s) => evv.checkIn({ sessionId: s.id, bookingId: booking.id }) : undefined}
onCheckOut={isNurse ? (s) => evv.checkOut({ sessionId: s.id, bookingId: booking.id }) : undefined}
/>
</Stack>
<BookingMoneySummary
grossPriceIrr={booking.grossPriceIrr}
balinyaarCommissionIrr={booking.balinyaarCommissionIrr}
nursePayoutAmount={booking.nursePayoutAmount}
viewerRole={viewerRole}
/>
<CareSection viewerRole={viewerRole} careEnabled={careEnabled} care={care} />
</Stack>
);
};
function HeaderFact({ label, value }: { label: string; value: string }) {
return (
<Stack sx={{ gap: 0.25 }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{label}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{value}
</Typography>
</Stack>
);
}
/** The per-status content note — confirmed/in-progress/completed(+dispute window)/disputed/cancelled. */
function StatusNote({ booking }: { booking: BookingDetailDto }) {
const t = useTranslations('booking');
const locale = useLocale();
const lines: string[] = [];
let tone: 'info' | 'warning' | 'neutral' = 'info';
let icon = 'info';
switch (booking.status) {
case 'confirmed':
lines.push(t('confirmed_note'));
break;
case 'in_progress':
lines.push(t('in_progress_note'));
break;
case 'completed':
case 'closed':
lines.push(t('completed_note'));
icon = 'verified';
break;
case 'disputed':
lines.push(t('disputed_note'));
tone = 'warning';
icon = 'warning';
break;
case 'cancelled':
lines.push(t('cancelled_note'));
tone = 'neutral';
icon = 'rejected';
break;
default:
break;
}
if (booking.disputeWindowEndsAt && (booking.status === 'completed' || booking.status === 'closed')) {
lines.push(t('dispute_window_note', { date: formatShamsiDate(booking.disputeWindowEndsAt, locale) }));
}
if (lines.length === 0) return null;
const bg =
tone === 'warning' ? 'var(--bal-secondary-soft)' : tone === 'neutral' ? 'var(--bal-divider)' : 'var(--bal-primary-soft)';
const color = tone === 'warning' ? 'var(--bal-secondary-dark)' : 'var(--bal-text-secondary)';
return (
<Stack direction="row" sx={{ gap: 1, alignItems: 'flex-start', p: 1.5, borderRadius: 2, bgcolor: bg }}>
<AppIcon icon={icon} size={18} color={color} />
<Stack sx={{ gap: 0.25 }}>
{lines.map((line) => (
<Typography key={line} variant="body2" sx={{ color: 'text.secondary' }}>
{line}
</Typography>
))}
</Stack>
</Stack>
);
}
/** The care-instructions region — the gated nurse card, or the customer's "visible to your nurse" note. */
function CareSection({
viewerRole,
careEnabled,
care,
}: {
viewerRole: BookingViewerRole;
careEnabled: boolean;
care: ReturnType<typeof useCareInstructions>;
}) {
const t = useTranslations('booking');
if (viewerRole !== 'nurse') {
// Customer: never renders the clinical record and never fires the query.
return (
<Paper
elevation={0}
sx={{ p: 2.5, border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}
>
<Stack direction="row" sx={{ gap: 1.25, alignItems: 'flex-start' }}>
<AppIcon icon="lock" size={20} color="var(--bal-text-secondary)" />
<Stack sx={{ gap: 0.25 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('care_locked_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('care_locked_body')}
</Typography>
</Stack>
</Stack>
</Paper>
);
}
if (!careEnabled) return null;
if (care.isLoading) return <Skeleton variant="rounded" height={180} />;
if (care.isError || !care.data) {
return (
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('care_error')}
</Typography>
</Paper>
);
}
return <CareInstructionsCard data={care.data} />;
}
function NotFoundCard({ title, body }: { title: string; body: string }) {
return (
<Paper
elevation={0}
sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2, maxWidth: 640, mx: 'auto' }}
>
<AppIcon icon="error" size={44} color="var(--bal-text-secondary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1, mb: 0.5 }}>
{title}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{body}
</Typography>
</Paper>
);
}
function DetailSkeleton() {
return (
<Stack sx={{ gap: 3, maxWidth: 640, mx: 'auto', width: '100%' }}>
<Skeleton variant="rounded" height={110} />
<Skeleton variant="rounded" height={96} />
<Skeleton variant="rounded" height={72} />
<Skeleton variant="rounded" height={140} />
<Skeleton variant="rounded" height={120} />
</Stack>
);
}
export default BookingDetailView;
@@ -0,0 +1,2 @@
export { default } from './BookingDetailView';
export type { BookingDetailViewProps } from './BookingDetailView';
@@ -0,0 +1,42 @@
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../../theme';
jest.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
useLocale: () => 'en',
}));
import BookingMoneySummary from './BookingMoneySummary';
function renderSummary(viewerRole: 'customer' | 'nurse') {
return render(
<ThemeProvider>
<BookingMoneySummary
grossPriceIrr="45000000"
balinyaarCommissionIrr="5400000"
nursePayoutAmount="39600000"
viewerRole={viewerRole}
/>
</ThemeProvider>,
);
}
describe('<BookingMoneySummary/> component', () => {
it('renders the three amounts as grouped Toman (IRR ÷ 10), never re-summed', () => {
renderSummary('customer');
expect(screen.getByText(/4,500,000/)).toBeInTheDocument(); // gross
expect(screen.getByText(/540,000/)).toBeInTheDocument(); // commission
expect(screen.getByText(/3,960,000/)).toBeInTheDocument(); // payout
});
it('labels the payout row for the customer', () => {
renderSummary('customer');
expect(screen.getByText('money_payout')).toBeInTheDocument();
expect(screen.queryByText('money_nurse_earning')).not.toBeInTheDocument();
});
it('labels the payout row as the nurse earning in the nurse view', () => {
renderSummary('nurse');
expect(screen.getByText('money_nurse_earning')).toBeInTheDocument();
});
});
@@ -0,0 +1,80 @@
'use client';
import { FunctionComponent, ReactNode } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Paper, Stack, Typography } from '@mui/material';
import { formatIrrToToman } from '@/utils';
import type { BookingViewerRole } from '@/services/bookings/types';
export interface BookingMoneySummaryProps {
/** IRR digit-strings; `gross = commission + payout`, guaranteed server-side. **Never** summed/re-split here. */
grossPriceIrr: string;
balinyaarCommissionIrr: string;
nursePayoutAmount: string;
/** Drives the payout row label (nurse sees «درآمد شما»; customer sees «سهم پرستار»). */
viewerRole: BookingViewerRole;
}
/**
* The confirmed booking money summary: service cost / Balinyaar fee (کارمزد) / nurse payout — each
* rendered exactly as the server sent it (IRR digit-strings) through the money util as grouped Toman.
* **Display-only:** no client-side sum, derive, or re-split; the tax line + escrow notice are the
* checkout surface (deferred to f9). The payout row label adapts to the viewer.
* @component BookingMoneySummary
*/
const BookingMoneySummary: FunctionComponent<BookingMoneySummaryProps> = ({
grossPriceIrr,
balinyaarCommissionIrr,
nursePayoutAmount,
viewerRole,
}) => {
const t = useTranslations('booking');
const tc = useTranslations('common');
const locale = useLocale();
const toman = (irr: string) => `${formatIrrToToman(irr, locale)} ${tc('currency_toman')}`;
return (
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1.5 }}>
{t('money_title')}
</Typography>
<Stack sx={{ gap: 1 }}>
<MoneyRow label={t('money_gross')} value={toman(grossPriceIrr)} emphasize />
<MoneyRow label={t('money_commission')} value={toman(balinyaarCommissionIrr)} />
<MoneyRow
label={viewerRole === 'nurse' ? t('money_nurse_earning') : t('money_payout')}
value={toman(nursePayoutAmount)}
accent
/>
</Stack>
</Paper>
);
};
function MoneyRow({
label,
value,
emphasize = false,
accent = false,
}: {
label: string;
value: string;
emphasize?: boolean;
accent?: boolean;
}): ReactNode {
return (
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 2 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{label}
</Typography>
<Typography
variant="body2"
sx={{ fontWeight: emphasize ? 800 : 700, color: accent ? 'var(--bal-secondary-dark)' : 'text.primary' }}
>
{value}
</Typography>
</Stack>
);
}
export default BookingMoneySummary;
@@ -0,0 +1,2 @@
export { default } from './BookingMoneySummary';
export type { BookingMoneySummaryProps } from './BookingMoneySummary';
@@ -0,0 +1,40 @@
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../../theme';
jest.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
useLocale: () => 'en',
}));
import BookingStatusTimeline from './BookingStatusTimeline';
function renderTimeline(status: Parameters<typeof BookingStatusTimeline>[0]['status']) {
return render(
<ThemeProvider>
<BookingStatusTimeline status={status} />
</ThemeProvider>,
);
}
describe('<BookingStatusTimeline/> component', () => {
it('renders the 4-step happy-path stepper with the current-status chip', () => {
renderTimeline('confirmed');
// The stepper renders every step label; pending_payment appears only in the stepper.
expect(screen.getByText('bstatus_pending_payment')).toBeInTheDocument();
expect(screen.getByText('bstatus_in_progress')).toBeInTheDocument();
// confirmed appears in both the chip and the stepper.
expect(screen.getAllByText('bstatus_confirmed').length).toBeGreaterThanOrEqual(1);
});
it('reflects the server status without advancing the client (in_progress)', () => {
const { container } = renderTimeline('in_progress');
expect(container.querySelector('[data-booking-status="in_progress"]')).toBeInTheDocument();
});
it('renders cancelled distinctly — no misleading progress stepper', () => {
renderTimeline('cancelled');
expect(screen.getAllByText('bstatus_cancelled').length).toBeGreaterThanOrEqual(1);
// The stepper is replaced by a terminal row, so its step labels are absent.
expect(screen.queryByText('bstatus_pending_payment')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,59 @@
'use client';
import { FunctionComponent } from 'react';
import { useTranslations } from 'next-intl';
import { Stack, Typography } from '@mui/material';
import AppIcon from '@/components/common/AppIcon';
import StatusChip from '@/components/StatusChip';
import StepperHeader from '@/components/StepperHeader';
import {
BOOKING_TIMELINE_ORDER,
bookingTimelineActiveIndex,
type BookingStatus,
} from '@/services/bookings/types';
import { BOOKING_STATUS_KIND } from '../statusKind';
export interface BookingStatusTimelineProps {
/** Server truth — the single source. The timeline never advances/infers a step client-side. */
status: BookingStatus;
}
/**
* The both-roles booking status timeline — the grown-up version of the f7 C5 3-step tracker. It renders
* the canonical happy path `pending_payment → confirmed → in_progress → completed` over the shared
* StepperHeader, with the current-status chip above it. Terminal branches are shown **distinctly**:
* `cancelled` replaces the stepper with a neutral terminal row (a progress bar would mislead); `disputed`
* / `closed` keep the stepper (they follow completion) and surface through the chip. **Reflects
* `BookingDetailDto.status` exactly** — it is never advanced on the client.
* @component BookingStatusTimeline
*/
const BookingStatusTimeline: FunctionComponent<BookingStatusTimelineProps> = ({ status }) => {
const t = useTranslations('booking');
const steps = BOOKING_TIMELINE_ORDER.map((s) => t(`bstatus_${s}`));
return (
<Stack sx={{ gap: 1.5 }} data-booking-status={status}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: 'text.secondary' }}>
{t('timeline_title')}
</Typography>
<StatusChip status={BOOKING_STATUS_KIND[status]} label={t(`bstatus_${status}`)} />
</Stack>
{status === 'cancelled' ? (
<Stack
direction="row"
sx={{ gap: 1, alignItems: 'center', px: 1.5, py: 1.25, borderRadius: 2, bgcolor: 'var(--bal-primary-soft)' }}
>
<AppIcon icon="rejected" size={20} color="var(--bal-text-secondary)" />
<Typography variant="body2" sx={{ fontWeight: 600, color: 'text.secondary' }}>
{t('bstatus_cancelled')}
</Typography>
</Stack>
) : (
<StepperHeader steps={steps} activeStep={bookingTimelineActiveIndex(status)} />
)}
</Stack>
);
};
export default BookingStatusTimeline;
@@ -0,0 +1,2 @@
export { default } from './BookingStatusTimeline';
export type { BookingStatusTimelineProps } from './BookingStatusTimeline';
@@ -0,0 +1,57 @@
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../../theme';
jest.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
useLocale: () => 'en',
}));
import CareInstructionsCard from './CareInstructionsCard';
import type { CareInstructionsDto } from '@/services/bookings/types';
const FULL: CareInstructionsDto = {
bookingId: 5001,
currentConditions: 'Type 2 diabetes, hypertension',
medications: 'Metformin 500 · Losartan 25',
allergies: 'Penicillin',
specialInstructions: 'Check blood sugar before meals.',
emergencyContactName: 'Zahra Mousavi',
emergencyContactPhone: '09121234567',
};
function renderCard(data: CareInstructionsDto) {
return render(
<ThemeProvider>
<CareInstructionsCard data={data} />
</ThemeProvider>,
);
}
describe('<CareInstructionsCard/> component', () => {
it('renders every decrypted clinical field', () => {
renderCard(FULL);
expect(screen.getByText('Type 2 diabetes, hypertension')).toBeInTheDocument();
expect(screen.getByText('Metformin 500 · Losartan 25')).toBeInTheDocument();
expect(screen.getByText('Penicillin')).toBeInTheDocument();
expect(screen.getByText('Check blood sugar before meals.')).toBeInTheDocument();
expect(screen.getByText('Zahra Mousavi · 09121234567')).toBeInTheDocument();
});
it('shows the "not recorded" placeholder for a null field', () => {
renderCard({ ...FULL, allergies: null });
expect(screen.getByText('care_none')).toBeInTheDocument();
});
it('shows the empty state when nothing was recorded', () => {
renderCard({
bookingId: 5001,
currentConditions: null,
medications: null,
allergies: null,
specialInstructions: null,
emergencyContactName: null,
emergencyContactPhone: null,
});
expect(screen.getByText('care_empty')).toBeInTheDocument();
});
});
@@ -0,0 +1,109 @@
'use client';
import { FunctionComponent, ReactNode } from 'react';
import { useTranslations } from 'next-intl';
import { Divider, Paper, Stack, Typography } from '@mui/material';
import AppIcon from '@/components/common/AppIcon';
import type { CareInstructionsDto } from '@/services/bookings/types';
export interface CareInstructionsCardProps {
/** The decrypted stage-2 record — supplied only by the gated read (assigned nurse / admin, confirmed+). */
data: CareInstructionsDto;
}
/**
* The decrypted care-instructions card — conditions / medications / allergies / special instructions /
* emergency contact. This is the **read side** of the two-stage clinical disclosure: it is only ever
* mounted by the booking-detail view once the viewer is confirmed to be the assigned nurse (or admin) on
* a `confirmed`+ booking. The card itself is presentational; the gate (and the "never even fetch" rule)
* lives in the view + `useCareInstructions`.
* @component CareInstructionsCard
*/
const CareInstructionsCard: FunctionComponent<CareInstructionsCardProps> = ({ data }) => {
const t = useTranslations('booking');
const emergency =
data.emergencyContactName || data.emergencyContactPhone
? [data.emergencyContactName, data.emergencyContactPhone].filter(Boolean).join(' · ')
: null;
const isEmpty =
!data.currentConditions &&
!data.medications &&
!data.allergies &&
!data.specialInstructions &&
!emergency;
return (
<Paper
elevation={0}
sx={{
p: 2.5,
border: '1px solid',
borderColor: 'divider',
borderRadius: 2,
borderInlineStartWidth: 4,
borderInlineStartColor: 'var(--bal-primary)',
}}
>
<Stack sx={{ gap: 0.5, mb: 1.5 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon="clinical" size={20} color="var(--bal-primary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('care_title')}
</Typography>
</Stack>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('care_subtitle')}
</Typography>
</Stack>
{isEmpty ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('care_empty')}
</Typography>
) : (
<Stack divider={<Divider />} sx={{ gap: 1.5 }}>
<CareRow icon="clinical" label={t('care_conditions')} value={data.currentConditions} noneLabel={t('care_none')} />
<CareRow icon="medication" label={t('care_medications')} value={data.medications} noneLabel={t('care_none')} />
<CareRow icon="warning" label={t('care_allergies')} value={data.allergies} noneLabel={t('care_none')} />
<CareRow icon="info" label={t('care_instructions_label')} value={data.specialInstructions} noneLabel={t('care_none')} />
<CareRow icon="emergency" label={t('care_emergency')} value={emergency} noneLabel={t('care_none')} ltrValue />
</Stack>
)}
</Paper>
);
};
function CareRow({
icon,
label,
value,
noneLabel,
ltrValue = false,
}: {
icon: string;
label: string;
value: string | null;
noneLabel: string;
ltrValue?: boolean;
}): ReactNode {
return (
<Stack direction="row" sx={{ gap: 1.25, alignItems: 'flex-start' }}>
<AppIcon icon={icon} size={18} color="var(--bal-text-secondary)" />
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 700 }}>
{label}
</Typography>
<Typography
variant="body2"
{...(ltrValue && value ? { dir: 'ltr' as const } : {})}
sx={{ color: value ? 'text.primary' : 'text.secondary', whiteSpace: 'pre-line' }}
>
{value || noneLabel}
</Typography>
</Stack>
</Stack>
);
}
export default CareInstructionsCard;
@@ -0,0 +1,2 @@
export { default } from './CareInstructionsCard';
export type { CareInstructionsCardProps } from './CareInstructionsCard';
@@ -0,0 +1,38 @@
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../../theme';
// next-intl mocked to echo keys; locale = en so the clock formats with ASCII digits.
jest.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
useLocale: () => 'en',
}));
import EvvStatusBanner from './EvvStatusBanner';
function renderBanner(match: boolean | null) {
return render(
<ThemeProvider>
<EvvStatusBanner checkInAtIso="2026-07-10T09:02:00.000Z" addressMatch={match} />
</ThemeProvider>,
);
}
describe('<EvvStatusBanner/> component', () => {
it('shows the in-range confirmation when the address matched', () => {
renderBanner(true);
expect(screen.getByText('evv_banner_in_range')).toBeInTheDocument();
});
it('shows the advisory out-of-range variant (warning tone, not error) when the address mismatched', () => {
const { container } = renderBanner(false);
expect(screen.getByText('evv_banner_out_of_range')).toBeInTheDocument();
// Advisory: the warning tone, never the error tone.
const banner = container.querySelector('[data-evv-match="false"]') as HTMLElement;
expect(banner).toHaveAttribute('data-evv-tone', 'warning');
});
it('shows the neutral no-GPS variant when the position was unavailable', () => {
renderBanner(null);
expect(screen.getByText('evv_banner_no_gps')).toBeInTheDocument();
});
});
@@ -0,0 +1,82 @@
'use client';
import { FunctionComponent } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Stack, Typography } from '@mui/material';
import AppIcon from '@/components/common/AppIcon';
import { formatClock } from '../format';
/**
* The advisory address-match result driving the banner variant:
* - `true` → in range · «موقعیت تایید شد (EVV)» (success-tokened)
* - `false` → out of range, under review · «موقعیت خارج از محدوده (در حال بررسی)» (**warning**-tokened, NOT error)
* - `null` → GPS unavailable/denied · «موقعیت ثبت نشد» (neutral/info-tokened)
*/
export type EvvMatchState = boolean | null;
export interface EvvStatusBannerProps {
/** The server `checkInAt` — the banner clock renders from this, never a client clock. */
checkInAtIso: string;
/** Advisory match; a mismatch is a warning banner, **never a block**. */
addressMatch: EvvMatchState;
}
type BannerTone = 'success' | 'warning' | 'info';
interface BannerStyle {
tone: BannerTone;
bg: string;
fg: string;
icon: string;
messageKey: 'evv_banner_in_range' | 'evv_banner_out_of_range' | 'evv_banner_no_gps';
}
// Colors resolve from the semantic --bal-* tokens (both schemes), so the banner switches with the color
// scheme. Out-of-range uses the **warning** token (amber), never the error token — a mismatch is advisory.
function styleFor(match: EvvMatchState): BannerStyle {
if (match === true) {
return { tone: 'success', bg: 'var(--bal-success)', fg: 'var(--bal-success-contrast)', icon: 'verified', messageKey: 'evv_banner_in_range' };
}
if (match === false) {
return { tone: 'warning', bg: 'var(--bal-warning)', fg: 'var(--bal-warning-contrast)', icon: 'warning', messageKey: 'evv_banner_out_of_range' };
}
return { tone: 'info', bg: 'var(--bal-info)', fg: 'var(--bal-info-contrast)', icon: 'gps', messageKey: 'evv_banner_no_gps' };
}
/**
* The EVV check-in banner — «ورود ثبت شد {time} · موقعیت تایید شد (EVV)» in range, the advisory
* out-of-range variant when the GPS mismatched, and a neutral variant when GPS was unavailable. A
* tokenised status banner (never an error), shown wherever an open/closed check-in exists (the
* booking-detail session card and the nurse day surface both use it). Presentational — time and match are
* supplied by the caller from server truth.
* @component EvvStatusBanner
*/
const EvvStatusBanner: FunctionComponent<EvvStatusBannerProps> = ({ checkInAtIso, addressMatch }) => {
const t = useTranslations('booking');
const locale = useLocale();
const style = styleFor(addressMatch);
const time = formatClock(checkInAtIso, locale);
return (
<Stack
direction="row"
data-evv-match={String(addressMatch)}
data-evv-tone={style.tone}
sx={{
gap: 1,
alignItems: 'center',
px: 1.5,
py: 1,
borderRadius: 2,
backgroundColor: style.bg,
color: style.fg,
}}
>
<AppIcon icon={style.icon} size={18} color={style.fg} />
<Typography variant="body2" sx={{ fontWeight: 700, color: style.fg }}>
{t(style.messageKey, { time })}
</Typography>
</Stack>
);
};
export default EvvStatusBanner;
@@ -0,0 +1,2 @@
export { default } from './EvvStatusBanner';
export type { EvvStatusBannerProps, EvvMatchState } from './EvvStatusBanner';
@@ -0,0 +1,64 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { ThemeProvider } from '../../../theme';
jest.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
useLocale: () => 'en',
}));
import SessionCard, { SessionCardProps } from './SessionCard';
const BASE: SessionCardProps = {
sessionIndex: 1,
scheduledDate: '2026-08-01',
scheduledTimeStart: '09:00:00',
scheduledTimeEnd: '13:00:00',
status: 'scheduled',
};
function renderCard(props: Partial<SessionCardProps> = {}) {
return render(
<ThemeProvider>
<SessionCard {...BASE} {...props} />
</ThemeProvider>,
);
}
describe('<SessionCard/> component', () => {
it('renders the visit index and a per-session status chip', () => {
const { container } = renderCard();
expect(screen.getByText('session_index')).toBeInTheDocument();
expect(screen.getByText('sstatus_scheduled')).toBeInTheDocument();
expect(container.querySelector('[data-session-status="scheduled"]')).toBeInTheDocument();
});
it('shows no EVV controls for the customer view', () => {
renderCard({ showEvvControls: false });
expect(screen.queryByText('evv_check_in')).not.toBeInTheDocument();
});
it('offers check-in for a scheduled session in the nurse view and fires onCheckIn', () => {
const onCheckIn = jest.fn();
renderCard({ showEvvControls: true, onCheckIn });
const button = screen.getByText('evv_check_in');
fireEvent.click(button);
expect(onCheckIn).toHaveBeenCalledTimes(1);
});
it('offers check-out once checked in', () => {
renderCard({ showEvvControls: true, status: 'in_progress', evvStatus: 'checked_in', checkInAt: '2026-08-01T09:02:00.000Z' });
expect(screen.getByText('evv_check_out')).toBeInTheDocument();
// The in-range banner renders from the server check-in time (default match null → no-gps variant here).
expect(screen.getByText('evv_banner_no_gps')).toBeInTheDocument();
});
it('shows the in-range EVV banner when the address matched', () => {
renderCard({ status: 'in_progress', evvStatus: 'checked_in', checkInAt: '2026-08-01T09:02:00.000Z', checkInAddressMatch: true });
expect(screen.getByText('evv_banner_in_range')).toBeInTheDocument();
});
it('shows the acquiring-location label while capturing GPS', () => {
renderCard({ showEvvControls: true, acquiringLocation: true });
expect(screen.getByText('evv_acquiring_location')).toBeInTheDocument();
});
});
@@ -0,0 +1,147 @@
'use client';
import { FunctionComponent } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Paper, Stack, Typography } from '@mui/material';
import { AppButton } from '@/components/common';
import StatusChip from '@/components/StatusChip';
import { formatIrrToToman, formatShamsiDate } from '@/utils';
import type { BookingSessionStatus, VisitVerificationStatus } from '@/services/bookings/types';
import EvvStatusBanner from '../EvvStatusBanner';
import { formatClock, formatElapsed, formatTimeRange } from '../format';
import { SESSION_STATUS_KIND } from '../statusKind';
export interface SessionCardProps {
/** Optional heading above the visit row — the patient name on the nurse's day feed (omitted in the detail). */
title?: string;
sessionIndex: number;
/** ISO date `YYYY-MM-DD`. */
scheduledDate: string;
/** `HH:mm:ss`. */
scheduledTimeStart: string;
scheduledTimeEnd: string;
status: BookingSessionStatus;
evvStatus?: VisitVerificationStatus;
/** Server `checkInAt`/`checkOutAt` — the banner + elapsed render from these, never a client clock. */
checkInAt?: string | null;
checkOutAt?: string | null;
/** Advisory match: `true` in range · `false` out-of-range (under review) · `null` GPS unavailable. */
checkInAddressMatch?: boolean | null;
/** IRR digit-string — this session's payout share; shown in the nurse view when `showPayout`. */
visitPayoutAmount?: string | null;
showPayout?: boolean;
/** Render the nurse EVV check-in/out CTA (state-machine driven). */
showEvvControls?: boolean;
/** This session's EVV mutation is in flight. */
evvPending?: boolean;
/** Acquiring GPS for this session (pre-mutation). */
acquiringLocation?: boolean;
onCheckIn?: () => void;
onCheckOut?: () => void;
}
/**
* One visit row: the Shamsi schedule, a per-session status chip, the EVV banner once checked in, and —
* for the assigned nurse — the check-in/out CTA driven by the session + EVV state machine (`scheduled` →
* «ثبت ورود» · `in_progress`/checked-in → «ثبت خروج» · `completed` → elapsed duration · `missed` → no
* action). A single-visit booking renders exactly this same card. Presentational: the container supplies
* the EVV handlers + busy flags; a GPS mismatch never disables the flow (advisory, not a block).
* @component SessionCard
*/
const SessionCard: FunctionComponent<SessionCardProps> = ({
title,
sessionIndex,
scheduledDate,
scheduledTimeStart,
scheduledTimeEnd,
status,
evvStatus,
checkInAt,
checkOutAt,
checkInAddressMatch = null,
visitPayoutAmount,
showPayout = false,
showEvvControls = false,
evvPending = false,
acquiringLocation = false,
onCheckIn,
onCheckOut,
}) => {
const t = useTranslations('booking');
const tc = useTranslations('common');
const locale = useLocale();
const dateLabel = formatShamsiDate(scheduledDate, locale);
const timeLabel = formatTimeRange(scheduledTimeStart, scheduledTimeEnd, locale);
const showBanner = (evvStatus === 'checked_in' || evvStatus === 'completed') && Boolean(checkInAt);
const elapsed = formatElapsed(checkInAt ?? null, checkOutAt ?? null, locale);
const busy = acquiringLocation || evvPending;
return (
<Paper
elevation={0}
data-session-status={status}
sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
>
<Stack sx={{ gap: 1.25 }}>
{title ? (
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{title}
</Typography>
) : null}
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: title ? 600 : 700, color: title ? 'text.secondary' : 'text.primary' }}>
{t('session_index', { n: sessionIndex })}
</Typography>
<StatusChip status={SESSION_STATUS_KIND[status]} label={t(`sstatus_${status}`)} />
</Stack>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{dateLabel} · <Typography component="span" dir="ltr" sx={{ fontVariantNumeric: 'tabular-nums' }}>{timeLabel}</Typography>
</Typography>
{showBanner ? <EvvStatusBanner checkInAtIso={checkInAt as string} addressMatch={checkInAddressMatch} /> : null}
{checkOutAt ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('evv_checked_out_at', { time: formatClock(checkOutAt, locale) })}
{elapsed ? ` · ${t('session_elapsed', { duration: elapsed })}` : ''}
</Typography>
) : null}
{showPayout && visitPayoutAmount ? (
<Typography variant="caption" sx={{ color: 'var(--bal-secondary-dark)', fontWeight: 600 }}>
{t('session_payout')}: {formatIrrToToman(visitPayoutAmount, locale)} {tc('currency_toman')}
</Typography>
) : null}
{showEvvControls && status === 'scheduled' ? (
<AppButton
color="secondary"
variant="contained"
startIcon={acquiringLocation ? 'gps' : 'check_in'}
disabled={busy}
onClick={onCheckIn}
sx={{ m: 0, alignSelf: 'flex-start', py: 1 }}
>
{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={{ m: 0, alignSelf: 'flex-start', py: 1 }}
>
{acquiringLocation ? t('evv_acquiring_location') : evvPending ? t('evv_checking_out') : t('evv_check_out')}
</AppButton>
) : null}
</Stack>
</Paper>
);
};
export default SessionCard;
@@ -0,0 +1,2 @@
export { default } from './SessionCard';
export type { SessionCardProps } from './SessionCard';
@@ -0,0 +1,47 @@
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../../theme';
jest.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
useLocale: () => 'en',
}));
import SessionList from './SessionList';
import type { BookingSessionDto } from '@/services/bookings/types';
function makeSession(id: number, sessionIndex: number): BookingSessionDto {
return {
id,
sessionIndex,
scheduledDate: '2026-08-01',
scheduledTimeStart: '09:00:00',
scheduledTimeEnd: '13:00:00',
status: 'scheduled',
visitPayoutAmount: '13200000',
payoutEligibleAt: null,
evvStatus: 'pending',
checkInAt: null,
checkOutAt: null,
checkInAddressMatch: null,
};
}
function renderList(sessions: BookingSessionDto[]) {
return render(
<ThemeProvider>
<SessionList sessions={sessions} />
</ThemeProvider>,
);
}
describe('<SessionList/> component', () => {
it('renders one card per session', () => {
renderList([makeSession(1, 1), makeSession(2, 2), makeSession(3, 3)]);
expect(screen.getAllByText('session_index')).toHaveLength(3);
});
it('renders exactly one card for a single-visit booking (no special case)', () => {
renderList([makeSession(1, 1)]);
expect(screen.getAllByText('session_index')).toHaveLength(1);
});
});
@@ -0,0 +1,60 @@
'use client';
import { FunctionComponent } from 'react';
import { Stack } from '@mui/material';
import type { BookingSessionDto } from '@/services/bookings/types';
import SessionCard from '../SessionCard';
export interface SessionListProps {
sessions: BookingSessionDto[];
/** Render the nurse EVV check-in/out CTA on each card. */
showEvvControls?: boolean;
showPayout?: boolean;
/** The session whose EVV mutation is in flight (per-card busy state). */
busySessionId?: number | null;
/** The session currently acquiring GPS (pre-mutation). */
acquiringSessionId?: number | null;
onCheckIn?: (session: BookingSessionDto) => void;
onCheckOut?: (session: BookingSessionDto) => void;
}
/**
* The session schedule list — one `SessionCard` per `BookingSessionDto`. A **single-visit booking renders
* exactly one card through this same path** (no special case). Per-session busy state is derived from the
* container's controller so only the acting card shows the spinner.
* @component SessionList
*/
const SessionList: FunctionComponent<SessionListProps> = ({
sessions,
showEvvControls = false,
showPayout = false,
busySessionId = null,
acquiringSessionId = null,
onCheckIn,
onCheckOut,
}) => (
<Stack sx={{ gap: 1.5 }}>
{sessions.map((session) => (
<SessionCard
key={session.id}
sessionIndex={session.sessionIndex}
scheduledDate={session.scheduledDate}
scheduledTimeStart={session.scheduledTimeStart}
scheduledTimeEnd={session.scheduledTimeEnd}
status={session.status}
evvStatus={session.evvStatus}
checkInAt={session.checkInAt}
checkOutAt={session.checkOutAt}
checkInAddressMatch={session.checkInAddressMatch}
visitPayoutAmount={session.visitPayoutAmount}
showPayout={showPayout}
showEvvControls={showEvvControls}
evvPending={busySessionId === session.id}
acquiringLocation={acquiringSessionId === session.id}
onCheckIn={onCheckIn ? () => onCheckIn(session) : undefined}
onCheckOut={onCheckOut ? () => onCheckOut(session) : undefined}
/>
))}
</Stack>
);
export default SessionList;
@@ -0,0 +1,2 @@
export { default } from './SessionList';
export type { SessionListProps } from './SessionList';
+51
View File
@@ -0,0 +1,51 @@
/**
* Shared time formatters for the booking composites. Timestamps cross the wire as UTC ISO; these render
* a locale clock (Persian digits for `fa`) for the EVV banner + session schedule. Kept internal to the
* booking components — money/date-of formatting lives in `@/utils`; this is only the clock/duration view.
*/
/** `HH:MM` for a UTC ISO instant in the active locale (Persian digits for `fa`). */
export function formatClock(iso: string, locale: string): string {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return '';
return new Intl.DateTimeFormat(locale === 'fa' ? 'fa-IR' : 'en-US', {
hour: '2-digit',
minute: '2-digit',
}).format(date);
}
/** `HH:MM` for a `HH:mm:ss` wire time, in the active locale. */
export function formatTimeOfDay(time: string, locale: string): string {
const [h, m] = time.split(':');
if (h == null || m == null) return time;
const nf = new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US', {
minimumIntegerDigits: 2,
useGrouping: false,
});
return `${nf.format(Number(h))}:${nf.format(Number(m))}`;
}
/** The `HH:MM`-range label for a session's scheduled window, in the active locale. */
export function formatTimeRange(start: string, end: string, locale: string): string {
return `${formatTimeOfDay(start, locale)} ${formatTimeOfDay(end, locale)}`;
}
/**
* The elapsed on-site duration between two UTC ISO instants, as `H:MM` in the active locale. Returns
* `null` when either endpoint is missing (e.g. an open check-in with no check-out yet).
*/
export function formatElapsed(startIso: string | null, endIso: string | null, locale: string): string | null {
if (!startIso || !endIso) return null;
const start = new Date(startIso).getTime();
const end = new Date(endIso).getTime();
if (Number.isNaN(start) || Number.isNaN(end) || end < start) return null;
const totalMinutes = Math.round((end - start) / 60_000);
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
const nf = new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US', { useGrouping: false });
const nfPad = new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US', {
minimumIntegerDigits: 2,
useGrouping: false,
});
return `${nf.format(hours)}:${nfPad.format(minutes)}`;
}
+22
View File
@@ -0,0 +1,22 @@
/**
* Booking composites (f8) — the shared visual layer for the post-payment engagement: the both-roles
* booking detail, the server-truth status timeline, the session schedule + per-session EVV, the advisory
* EVV banner, and the gated care-instructions card. Kept at the shared level (co-located tests) so f9
* (checkout) and f13 (records) extend them rather than re-derive the layout. Import from `@/components/booking`.
*/
export { default as BookingDetailView } from './BookingDetailView';
export type { BookingDetailViewProps } from './BookingDetailView';
export { default as BookingStatusTimeline } from './BookingStatusTimeline';
export type { BookingStatusTimelineProps } from './BookingStatusTimeline';
export { default as SessionList } from './SessionList';
export type { SessionListProps } from './SessionList';
export { default as SessionCard } from './SessionCard';
export type { SessionCardProps } from './SessionCard';
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 BookingMoneySummary } from './BookingMoneySummary';
export type { BookingMoneySummaryProps } from './BookingMoneySummary';
export { useEvvController } from './useEvvController';
export type { EvvTarget } from './useEvvController';
@@ -0,0 +1,22 @@
import type { StatusKind } from '@/components/StatusChip';
import type { BookingSessionStatus, BookingStatus } from '@/services/bookings/types';
/** Booking status → the shared semantic StatusChip kind (used by the timeline + the bookings list). */
export const BOOKING_STATUS_KIND: Record<BookingStatus, StatusKind> = {
pending_payment: 'pending',
confirmed: 'info',
in_progress: 'active',
completed: 'verified',
disputed: 'rejected',
closed: 'neutral',
cancelled: 'neutral',
};
/** Session status → the shared semantic StatusChip kind (used by the session card + the today feed). */
export const SESSION_STATUS_KIND: Record<BookingSessionStatus, StatusKind> = {
scheduled: 'info',
in_progress: 'active',
completed: 'verified',
missed: 'rejected',
cancelled: 'neutral',
};
@@ -0,0 +1,96 @@
'use client';
import { useCallback, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import { ApiError } from '@/lib/api/errors';
import { useCheckInVisit, useCheckOutVisit } from '@/services/bookings';
import { locationProvider } from '@/services/bookings/evv/locationProvider';
export interface EvvTarget {
sessionId: number;
bookingId: number;
}
/**
* Orchestrates an EVV check-in/out for a session: capture GPS through the `ILocationProvider` seam, then
* post via the `useCheckInVisit`/`useCheckOutVisit` mutations (which invalidate the detail/today/EVV
* queries so the timeline, session row, and banner re-render from server truth). Tracks the acting
* session so only its card shows the "acquiring…" / pending state.
*
* **GPS is never a hard stop** — a denied/unavailable fix (`null`) still checks in (flagged), with an
* advisory toast. A mismatch is advisory server-side (the banner warns). Only domain 4xx are toasted here
* (409 not-startable, 400 no-open-check-in); `clientFetch` already toasts 401/403/5xx/network.
*/
export function useEvvController() {
const t = useTranslations('booking');
const { enqueueSnackbar } = useSnackbar();
const checkInMut = useCheckInVisit();
const checkOutMut = useCheckOutVisit();
const [acquiringSessionId, setAcquiringSessionId] = useState<number | null>(null);
const [busySessionId, setBusySessionId] = useState<number | null>(null);
const capture = useCallback(async (sessionId: number) => {
setAcquiringSessionId(sessionId);
const pos = await locationProvider.getCurrentPosition();
setAcquiringSessionId(null);
return pos;
}, []);
const checkIn = useCallback(
async ({ sessionId, bookingId }: EvvTarget) => {
const pos = await capture(sessionId);
setBusySessionId(sessionId);
try {
await checkInMut.mutateAsync({
input: {
bookingSessionId: sessionId,
latitude: pos?.latitude ?? null,
longitude: pos?.longitude ?? null,
capturedAt: new Date().toISOString(),
},
bookingId,
});
enqueueSnackbar(t('evv_checked_in_toast'), { variant: 'success' });
// GPS denied/unavailable is advisory, not a block — surface it without failing the check-in.
if (!pos) enqueueSnackbar(t('evv_gps_denied_note'), { variant: 'warning' });
} catch (error) {
if (error instanceof ApiError && error.status === 409) {
enqueueSnackbar(t('evv_not_startable'), { variant: 'warning' });
}
} finally {
setBusySessionId(null);
}
},
[capture, checkInMut, enqueueSnackbar, t],
);
const checkOut = useCallback(
async ({ sessionId, bookingId }: EvvTarget) => {
const pos = await capture(sessionId);
setBusySessionId(sessionId);
try {
await checkOutMut.mutateAsync({
input: {
bookingSessionId: sessionId,
latitude: pos?.latitude ?? null,
longitude: pos?.longitude ?? null,
capturedAt: new Date().toISOString(),
},
bookingId,
});
enqueueSnackbar(t('evv_checked_out_toast'), { variant: 'success' });
} catch (error) {
if (error instanceof ApiError && (error.status === 400 || error.code === 'no_open_check_in')) {
enqueueSnackbar(t('evv_no_open_check_in'), { variant: 'warning' });
} else if (error instanceof ApiError && error.status === 409) {
enqueueSnackbar(t('evv_check_out_error'), { variant: 'warning' });
}
} finally {
setBusySessionId(null);
}
},
[capture, checkOutMut, enqueueSnackbar, t],
);
return { acquiringSessionId, busySessionId, checkIn, checkOut };
}
@@ -61,6 +61,15 @@ import TuneIcon from '@mui/icons-material/TuneOutlined';
// Booking requests — the pre-payment intent flow (f7/b8): nurse inbox + the pay-&-continue handoff
import RequestsIcon from '@mui/icons-material/AssignmentOutlined';
import PaymentIcon from '@mui/icons-material/CreditCardOutlined';
// Bookings, sessions & EVV — the post-payment engagement (f8/b9): check-in/out, GPS, care instructions
import CheckInIcon from '@mui/icons-material/LoginOutlined';
import CheckOutIcon from '@mui/icons-material/LogoutOutlined';
import GpsIcon from '@mui/icons-material/MyLocationOutlined';
import ScheduleIcon from '@mui/icons-material/ScheduleOutlined';
import ClinicalIcon from '@mui/icons-material/HealthAndSafetyOutlined';
import MedicationIcon from '@mui/icons-material/MedicationOutlined';
import EmergencyIcon from '@mui/icons-material/LocalPhoneOutlined';
import LockIcon from '@mui/icons-material/LockOutlined';
/**
* List of all available Icon names
@@ -133,4 +142,12 @@ export const ICONS /* Note: Setting type disables property autocomplete :( was -
tune: TuneIcon,
requests: RequestsIcon,
payment: PaymentIcon,
check_in: CheckInIcon,
check_out: CheckOutIcon,
gps: GpsIcon,
schedule: ScheduleIcon,
clinical: ClinicalIcon,
medication: MedicationIcon,
emergency: EmergencyIcon,
lock: LockIcon,
};
@@ -0,0 +1,76 @@
import { clientFetch } from '@/lib/api/client';
import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types';
import { BOOKINGS_PAGE_SIZE } from '../constants';
import type {
BookingDetailDto,
BookingListItemDto,
BookingListParams,
BookingSessionListItemDto,
BookingsApi,
CareInstructionsDto,
CheckInVisitInput,
CheckOutVisitInput,
TodaySessionsParams,
VisitVerificationDto,
} from '../types';
const BOOKINGS = '/api/v1/bookings';
const SESSIONS = '/api/v1/booking_sessions';
/**
* Real HTTP implementation of the `BookingsApi` seam (b9 contract `dev/contracts/domains/bookings-evv.md`,
* swagger `dev/contracts/openapi/swagger.v1.json`). Routes are action-style + snake_case; ids come from
* the **route**; bodies/fields are camelCase and `clientFetch` returns the raw envelope, so we `unwrap()`.
*
* NOT the primary implementation this phase (`USE_BOOKINGS_MOCK = true`): a booking only exists after the
* (mock-primary) request flow converts + is paid (b10), so `bookings/list` has nothing to return yet.
* The server infers the viewer from auth + tenancy (nurse view masks `addressSnapshotJson`; the
* care-instructions read 404s for anyone but the assigned nurse/admin), so the `viewerRole` args a mock
* needs are ignored here. The EVV commands send only the coordinates the server timestamps the
* authoritative `checkInAt`, so the client's `capturedAt` is not sent. One config flip selects this.
*/
export const bookingsClientApi: BookingsApi = {
getBookingDetail: async (id: number) =>
unwrap(await clientFetch<ApiEnvelope<BookingDetailDto>>(`${BOOKINGS}/get/${id}`)),
listBookings: async (params: BookingListParams): Promise<Paginated<BookingListItemDto>> => {
const query = new URLSearchParams();
query.set('role', params.role);
if (params.status) query.set('status', params.status);
query.set('page', String(params.page ?? 1));
query.set('pageSize', String(params.pageSize ?? BOOKINGS_PAGE_SIZE));
return unwrap(await clientFetch<ApiEnvelope<Paginated<BookingListItemDto>>>(`${BOOKINGS}/list?${query.toString()}`));
},
listTodaySessions: async (params: TodaySessionsParams): Promise<Paginated<BookingSessionListItemDto>> => {
const query = new URLSearchParams();
if (params.date) query.set('date', params.date);
query.set('page', String(params.page ?? 1));
query.set('pageSize', String(params.pageSize ?? BOOKINGS_PAGE_SIZE));
return unwrap(
await clientFetch<ApiEnvelope<Paginated<BookingSessionListItemDto>>>(`${SESSIONS}/today?${query.toString()}`),
);
},
getSessionEvv: async (sessionId: number) =>
unwrap(await clientFetch<ApiEnvelope<VisitVerificationDto>>(`${SESSIONS}/evv/${sessionId}`)),
getCareInstructions: async (bookingId: number) =>
unwrap(await clientFetch<ApiEnvelope<CareInstructionsDto>>(`${BOOKINGS}/care_instructions/${bookingId}`)),
checkInVisit: async (input: CheckInVisitInput) =>
unwrap(
await clientFetch<ApiEnvelope<VisitVerificationDto>>(`${SESSIONS}/check_in/${input.bookingSessionId}`, {
method: 'POST',
body: JSON.stringify({ latitude: input.latitude, longitude: input.longitude }),
}),
),
checkOutVisit: async (input: CheckOutVisitInput) =>
unwrap(
await clientFetch<ApiEnvelope<VisitVerificationDto>>(`${SESSIONS}/check_out/${input.bookingSessionId}`, {
method: 'POST',
body: JSON.stringify({ latitude: input.latitude, longitude: input.longitude }),
}),
),
};
@@ -0,0 +1,10 @@
import { USE_BOOKINGS_MOCK } from '../constants';
import type { BookingsApi } from '../types';
import { bookingsClientApi } from './clientApi';
import { bookingsMockApi } from './mockApi';
/**
* The selected `BookingsApi` implementation the single seam the hooks import. Selection is by config
* (`USE_BOOKINGS_MOCK`), never by scattered `if (mock)` checks.
*/
export const bookingsApi: BookingsApi = USE_BOOKINGS_MOCK ? bookingsMockApi : bookingsClientApi;
@@ -0,0 +1,382 @@
import { sleep } from '@/utils';
import { ApiError } from '@/lib/api/errors';
import type { Paginated } from '@/lib/api/types';
import {
BOOKINGS_PAGE_SIZE,
MOCK_EVV_REFERENCE_LAT,
MOCK_EVV_REFERENCE_LNG,
MOCK_EVV_TOLERANCE_METERS,
} from '../constants';
import type {
BookingDetailDto,
BookingListItemDto,
BookingListParams,
BookingSessionDto,
BookingSessionListItemDto,
BookingsApi,
BookingViewerRole,
CareInstructionsDto,
CheckInVisitInput,
CheckOutVisitInput,
TodaySessionsParams,
VisitVerificationDto,
} from '../types';
const MOCK_LATENCY_MS = 350;
/** Dispute window the mock stamps at completion — the payout-eligibility gate is server truth, mocked here. */
const DISPUTE_WINDOW_HOURS = 72;
const NURSE_ID = 1;
const NURSE_NAME = 'مریم رضایی';
/** `YYYY-MM-DD` for a day offset from today (mock seed dates — runs client-side, so `new Date()` is fine). */
function isoDate(daysFromToday: number): string {
const d = new Date();
d.setDate(d.getDate() + daysFromToday);
return d.toISOString().slice(0, 10);
}
/** 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;
const toRad = (deg: number) => (deg * Math.PI) / 180;
const dLat = toRad(lat2 - lat1);
const dLng = toRad(lng2 - lng1);
const a =
Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(a));
}
function makeSession(id: number, sessionIndex: number, daysFromToday: number, payoutIrr: string): BookingSessionDto {
return {
id,
sessionIndex,
scheduledDate: isoDate(daysFromToday),
scheduledTimeStart: '09:00:00',
scheduledTimeEnd: '13:00:00',
status: 'scheduled',
visitPayoutAmount: payoutIrr,
payoutEligibleAt: null,
evvStatus: 'pending',
checkInAt: null,
checkOutAt: null,
checkInAddressMatch: null,
};
}
// Shared, module-level store so both the customer view and the nurse view read the same booking, and an
// EVV check-in/out flips the timeline/session/banner for both. Seeded with one multi-session booking and
// one single-visit booking (proving the single-visit path renders one session row through the same card),
// both assigned to the seeded nurse and scheduled with a today session so check-in is demoable out of box.
let bookings: BookingDetailDto[] = [];
const verifications: Record<number, VisitVerificationDto> = {};
const care: Record<number, CareInstructionsDto> = {};
function seed(): void {
const addr5001 = JSON.stringify({
title: 'منزل',
city: 'تهران',
district: 'سعادت‌آباد',
line: 'خیابان نمونه، کوچه دوم، پلاک ۱۲',
postalCode: '1998887766',
});
const addr5002 = JSON.stringify({
title: 'آپارتمان',
city: 'تهران',
district: 'ونک',
line: 'خیابان ملاصدرا، پلاک ۴۵، واحد ۷',
postalCode: '1991112233',
});
bookings = [
{
id: 5001,
bookingRequestId: 9001,
status: 'confirmed',
nurseId: NURSE_ID,
nurseName: NURSE_NAME,
patientId: 901,
patientName: 'حاج‌آقا موسوی',
variantId: 11,
variantSnapshotJson: JSON.stringify({ displayName: 'مراقبت سالمند — شیفت روز', priceUnit: 'per_day' }),
customerAddressId: 801,
addressSnapshotJson: addr5001,
grossPriceIrr: '45000000',
balinyaarCommissionIrr: '5400000',
nursePayoutAmount: '39600000',
pspFeeAmount: '900000',
platformFeeRate: 0.12,
sessionCount: 3,
scheduledDate: isoDate(0),
scheduledTimeStart: '09:00:00',
scheduledTimeEnd: '13:00:00',
confirmedAt: new Date().toISOString(),
completedAt: null,
cancelledAt: null,
cancelledBy: null,
cancellationReason: null,
cancellationPolicyCode: null,
cancellationRefundPercentage: null,
refundableAmountIrr: null,
disputeWindowEndsAt: null,
createdAt: new Date().toISOString(),
sessions: [
makeSession(70011, 1, 0, '13200000'),
makeSession(70012, 2, 1, '13200000'),
makeSession(70013, 3, 2, '13200000'),
],
},
{
id: 5002,
bookingRequestId: 9002,
status: 'confirmed',
nurseId: NURSE_ID,
nurseName: NURSE_NAME,
patientId: 902,
patientName: 'خانم احمدی',
variantId: 12,
variantSnapshotJson: JSON.stringify({ displayName: 'مراقبت پس از جراحی', priceUnit: 'per_session' }),
customerAddressId: 802,
addressSnapshotJson: addr5002,
grossPriceIrr: '18000000',
balinyaarCommissionIrr: '2160000',
nursePayoutAmount: '15840000',
pspFeeAmount: '360000',
platformFeeRate: 0.12,
sessionCount: 1,
scheduledDate: isoDate(0),
scheduledTimeStart: '15:00:00',
scheduledTimeEnd: '19:00:00',
confirmedAt: new Date().toISOString(),
completedAt: null,
cancelledAt: null,
cancelledBy: null,
cancellationReason: null,
cancellationPolicyCode: null,
cancellationRefundPercentage: null,
refundableAmountIrr: null,
disputeWindowEndsAt: null,
createdAt: new Date().toISOString(),
sessions: [{ ...makeSession(70021, 1, 0, '15840000'), scheduledTimeStart: '15:00:00', scheduledTimeEnd: '19:00:00' }],
},
];
care[5001] = {
bookingId: 5001,
currentConditions: 'دیابت نوع ۲، فشار خون بالا',
medications: 'متفورمین ۵۰۰ (صبح و شب) · لوزارتان ۲۵ (صبح)',
allergies: 'حساسیت به پنی‌سیلین',
specialInstructions: 'قند خون پیش از هر وعده اندازه‌گیری شود؛ یک پیاده‌روی کوتاه بعدازظهر توصیه شده است.',
emergencyContactName: 'زهرا موسوی',
emergencyContactPhone: '09121234567',
};
care[5002] = {
bookingId: 5002,
currentConditions: 'دورهٔ نقاهت پس از عمل زانو',
medications: 'مسکن طبق دستور پزشک',
allergies: null,
specialInstructions: 'در جابه‌جایی و تعویض پانسمان کمک شود؛ از فشار روی زانوی عمل‌شده پرهیز شود.',
emergencyContactName: 'علی احمدی',
emergencyContactPhone: '09120009988',
};
}
seed();
function cloneBooking(b: BookingDetailDto): BookingDetailDto {
return { ...b, sessions: b.sessions.map((s) => ({ ...s })) };
}
function findBooking(id: number): BookingDetailDto {
const b = bookings.find((row) => row.id === id);
// Tenancy is not modelled in the single-session mock; a missing booking 404s (no leak either way).
if (!b) throw new ApiError(404, 'Booking not found', 'not_found');
return b;
}
function findSession(sessionId: number): { booking: BookingDetailDto; session: BookingSessionDto } {
for (const booking of bookings) {
const session = booking.sessions.find((s) => s.id === sessionId);
if (session) return { booking, session };
}
throw new ApiError(404, 'Session not found', 'not_found');
}
/** The nurse view omits the full address snapshot (two-stage disclosure — coarse context only). */
function forViewer(b: BookingDetailDto, viewerRole: BookingViewerRole | undefined): BookingDetailDto {
const clone = cloneBooking(b);
if (viewerRole === 'nurse') clone.addressSnapshotJson = null;
return clone;
}
function toListItem(b: BookingDetailDto, role: BookingListParams['role']): BookingListItemDto {
return {
id: b.id,
status: b.status,
counterpartyName: role === 'nurse' ? b.patientName : b.nurseName,
scheduledDate: b.scheduledDate,
sessionCount: b.sessionCount,
// Contract: gross for the customer, the nurse's payout for the nurse.
amountIrr: role === 'nurse' ? b.nursePayoutAmount : b.grossPriceIrr,
disputeWindowEndsAt: b.disputeWindowEndsAt,
createdAt: b.createdAt,
};
}
function pendingVerification(session: BookingSessionDto): VisitVerificationDto {
return {
id: session.id,
bookingSessionId: session.id,
status: session.evvStatus,
checkInAt: session.checkInAt,
checkInLat: null,
checkInLng: null,
checkOutAt: session.checkOutAt,
checkOutLat: null,
checkOutLng: null,
checkInAddressMatch: session.checkInAddressMatch,
checkInDistanceMeters: null,
};
}
/**
* In-memory mock behind the `BookingsApi` seam. Seeds confirmed bookings + sessions + care + EVV and
* drives the check-in/out state machine so the timeline, session chips, and EVV banner all transition
* without a live b9 backend. Mirrors the real shapes + status/EVV/masking/gating semantics for a one-line
* swap once conversion (b10) is live client-side (`USE_BOOKINGS_MOCK = false`).
*/
export const bookingsMockApi: BookingsApi = {
getBookingDetail: async (id, viewerRole) => {
await sleep(MOCK_LATENCY_MS);
return forViewer(findBooking(id), viewerRole);
},
listBookings: async (params: BookingListParams): Promise<Paginated<BookingListItemDto>> => {
await sleep(MOCK_LATENCY_MS);
const matched = bookings
.filter((b) => (params.status ? b.status === params.status : true))
.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt))
.map((b) => toListItem(b, params.role));
const page = params.page ?? 1;
const pageSize = params.pageSize ?? BOOKINGS_PAGE_SIZE;
const start = (page - 1) * pageSize;
return { items: matched.slice(start, start + pageSize), total: matched.length, page, pageSize };
},
listTodaySessions: async (params: TodaySessionsParams): Promise<Paginated<BookingSessionListItemDto>> => {
await sleep(MOCK_LATENCY_MS);
const day = params.date ?? isoDate(0);
const items: BookingSessionListItemDto[] = [];
for (const booking of bookings) {
for (const session of booking.sessions) {
if (session.scheduledDate !== day) continue;
items.push({
sessionId: session.id,
bookingId: booking.id,
sessionIndex: session.sessionIndex,
patientName: booking.patientName,
scheduledDate: session.scheduledDate,
scheduledTimeStart: session.scheduledTimeStart,
scheduledTimeEnd: session.scheduledTimeEnd,
status: session.status,
evvStatus: session.evvStatus,
});
}
}
items.sort((a, b) => a.scheduledTimeStart.localeCompare(b.scheduledTimeStart));
const page = params.page ?? 1;
const pageSize = params.pageSize ?? BOOKINGS_PAGE_SIZE;
const start = (page - 1) * pageSize;
return { items: items.slice(start, start + pageSize), total: items.length, page, pageSize };
},
getSessionEvv: async (sessionId: number) => {
await sleep(MOCK_LATENCY_MS);
const existing = verifications[sessionId];
if (existing) return { ...existing };
const { session } = findSession(sessionId);
return pendingVerification(session);
},
getCareInstructions: async (bookingId: number, viewerRole) => {
await sleep(MOCK_LATENCY_MS);
const booking = findBooking(bookingId);
// The gated boundary — anyone but the assigned nurse/admin 404s (never leaks). The client UI gate
// means this is a defence-in-depth path the customer should never even reach.
if (viewerRole !== 'nurse') throw new ApiError(404, 'Not found', 'not_found');
const record = care[booking.id];
if (!record) throw new ApiError(404, 'Not found', 'not_found');
return { ...record };
},
checkInVisit: async (input: CheckInVisitInput) => {
await sleep(MOCK_LATENCY_MS);
const { booking, session } = findSession(input.bookingSessionId);
if (session.status !== 'scheduled') throw new ApiError(409, 'Session is not startable', 'not_startable');
const now = new Date().toISOString();
const hasCoords = input.latitude != null && input.longitude != null;
const meters = hasCoords
? distanceMeters(input.latitude as number, input.longitude as number, MOCK_EVV_REFERENCE_LAT, MOCK_EVV_REFERENCE_LNG)
: null;
// Advisory: true in range · false out-of-range (under review) · null when GPS was unavailable.
const match = meters == null ? null : meters <= MOCK_EVV_TOLERANCE_METERS;
session.status = 'in_progress';
session.evvStatus = 'checked_in';
session.checkInAt = now;
session.checkInAddressMatch = match;
if (booking.status === 'confirmed') booking.status = 'in_progress';
const verification: VisitVerificationDto = {
id: session.id,
bookingSessionId: session.id,
status: 'checked_in',
checkInAt: now,
checkInLat: input.latitude,
checkInLng: input.longitude,
checkOutAt: null,
checkOutLat: null,
checkOutLng: null,
checkInAddressMatch: match,
checkInDistanceMeters: meters == null ? null : Math.round(meters),
};
verifications[session.id] = verification;
return { ...verification };
},
checkOutVisit: async (input: CheckOutVisitInput) => {
await sleep(MOCK_LATENCY_MS);
const { booking, session } = findSession(input.bookingSessionId);
if (session.evvStatus !== 'checked_in') throw new ApiError(400, 'No open check-in to close', 'no_open_check_in');
const now = new Date().toISOString();
session.status = 'completed';
session.evvStatus = 'completed';
session.checkOutAt = now;
const disputeEnd = new Date(Date.now() + DISPUTE_WINDOW_HOURS * 3_600_000).toISOString();
// Payout eligibility is server truth (gated by the dispute window). The mock stamps it; the client
// renders it and never recomputes it.
session.payoutEligibleAt = disputeEnd;
const allSettled = booking.sessions.every((s) => s.status === 'completed' || s.status === 'cancelled');
if (allSettled) {
booking.status = 'completed';
booking.completedAt = now;
booking.disputeWindowEndsAt = disputeEnd;
}
const verification = verifications[session.id] ?? pendingVerification(session);
const updated: VisitVerificationDto = {
...verification,
status: 'completed',
checkOutAt: now,
checkOutLat: input.latitude,
checkOutLng: input.longitude,
};
verifications[session.id] = updated;
return { ...updated };
},
};
@@ -0,0 +1,19 @@
import { serverFetch } from '@/lib/api/server';
import { unwrap, type ApiEnvelope } from '@/lib/api/types';
import type { BookingDetailDto } from '../types';
const BOOKINGS = '/api/v1/bookings';
/**
* Server-side reads for the bookings domain used to **prefetch the booking detail in an RSC** and hand
* it to the client tree via `initialData`, removing a client round-trip on first paint (f0 pattern). Only
* the detail is prefetched; the EVV mutations and the gated care-instructions read stay on the client.
*
* Not wired into the pages while the domain is mock-primary (an RSC cannot read the in-memory mock store);
* it becomes the first-paint source the moment `USE_BOOKINGS_MOCK` flips to `false`. Kept separate from
* `clientApi.ts` Next.js enforces the `serverFetch`/`clientFetch` environment boundary at build time.
*/
export const bookingsServerApi = {
getBookingDetail: async (id: number): Promise<BookingDetailDto> =>
unwrap(await serverFetch<ApiEnvelope<BookingDetailDto>>(`${BOOKINGS}/get/${id}`)),
};
+64
View File
@@ -0,0 +1,64 @@
/**
* When true, the bookings domain is served by the in-memory mock (`apis/mockApi.ts`) behind the
* `BookingsApi` seam.
*
* **Mock is primary this phase.** The b9 endpoints are fully specified in swagger, but a booking only
* exists after `bookings/convert` runs against an `accepted_awaiting_payment` **request** that was paid
* and both upstreams are not real on the client yet: `services/bookingRequests` is mock-primary
* (f7, `USE_BOOKING_REQUESTS_MOCK`) and card capture (b10) isn't wired. So a real `bookings/list` would
* return nothing to render. The mock seeds confirmed bookings + sessions + care + EVV and drives the
* check-in/out state machine so the timeline/session/banner transitions demo end-to-end. The real
* `clientApi` maps the routes 1:1; flip to `false` once conversion (b10) is live client-side a single
* config change, no hook/component edits (see `dev/shared-working-context/reports/frontend-phase-8-report.md`).
*/
export const USE_BOOKINGS_MOCK = true;
/**
* The booking detail changes on status transitions (payment confirmed in_progress completed) and
* on EVV mutations. Keep the stale window modest and **invalidate on every EVV mutation** so the timeline
* reflects server truth immediately rather than waiting it out.
*/
export const BOOKING_DETAIL_STALE_TIME = 30 * 1000;
export const BOOKING_DETAIL_GC_TIME = 5 * 60 * 1000;
/** The bookings list changes only on new conversions/transitions — a short stale window is plenty. */
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;
/** Per-session EVV detail is immutable once completed; a short window covers the checked-in interval. */
export const SESSION_EVV_STALE_TIME = 15 * 1000;
/** Care instructions are effectively static per booking (edited rarely by the customer) — session-cached. */
export const CARE_INSTRUCTIONS_STALE_TIME = 5 * 60 * 1000;
/** api-conventions default page size; a bookings/today page. */
export const BOOKINGS_PAGE_SIZE = 20;
/**
* EVV GPS capture mode for the `ILocationProvider` seam (`evv/locationProvider.ts`).
*
* - `off` the **real** browser Geolocation provider.
* - `in_range` mock returns coordinates that fall inside the seeded booking's tolerance (match `true`).
* - `out_of_range` mock returns far coordinates (advisory match `false`).
* - `denied` mock returns `null` (permission denied / unavailable) the nurse still checks in.
*
* Default: while the bookings domain is mock-primary, real browser GPS would never fall near the seeded
* Tehran address, so the happy path defaults to `in_range` so «موقعیت تایید شد» is demoable out of the box.
* Override with `NEXT_PUBLIC_EVV_MOCK_GPS`; set to `off` (or flip `USE_BOOKINGS_MOCK`) for real capture.
*/
export type EvvGpsMode = 'off' | 'in_range' | 'out_of_range' | 'denied';
export const EVV_GPS_MODE: EvvGpsMode =
(process.env.NEXT_PUBLIC_EVV_MOCK_GPS as EvvGpsMode | undefined) ?? (USE_BOOKINGS_MOCK ? 'in_range' : 'off');
/**
* The seeded booking's reference location + advisory tolerance, shared by the mock `ILocationProvider`
* (its `in_range` coords sit on this point) and the mock `BookingsApi` (it computes the advisory
* `checkInAddressMatch` against this point). Real address-match math lives server-side behind the
* backend geocoding seam this is mock-only. A Tehran (Saadat-Abad) point, matching the seeded address.
*/
export const MOCK_EVV_REFERENCE_LAT = 35.7869;
export const MOCK_EVV_REFERENCE_LNG = 51.3699;
export const MOCK_EVV_TOLERANCE_METERS = 150;
@@ -0,0 +1,77 @@
import {
EVV_GPS_MODE,
MOCK_EVV_REFERENCE_LAT,
MOCK_EVV_REFERENCE_LNG,
type EvvGpsMode,
} from '../constants';
/**
* `ILocationProvider` the one client seam this phase introduces. It wraps the browser Geolocation API
* for EVV GPS capture so the check-in/out flow is testable without a device and so the
* denied/unavailable path can be exercised deterministically.
*
* `getCurrentPosition` **never rejects** a denied/unavailable/timed-out fix resolves to `null`. The
* product rule is that a GPS problem is **advisory, never a hard stop**: the caller submits the check-in
* with `null` coordinates (flagged server-side) rather than blocking the visit. Selection between the
* real and mock implementations is by `EVV_GPS_MODE` (`NEXT_PUBLIC_EVV_MOCK_GPS`), never scattered checks.
*
* Registered in `dev/shared-working-context/reports/mocks-registry.md`. Server-side GPS/address-match
* math lives behind the backend's geocoding seam this seam only *captures* the position.
*/
export interface GeoPosition {
latitude: number;
longitude: number;
}
export interface ILocationProvider {
/** Resolves the current position, or `null` when it can't be obtained (denied/unavailable/timeout). */
getCurrentPosition(): Promise<GeoPosition | null>;
}
const GEOLOCATION_TIMEOUT_MS = 10_000;
/** Real provider — `navigator.geolocation.getCurrentPosition`, resolving `null` on any failure. */
const realLocationProvider: ILocationProvider = {
getCurrentPosition: () =>
new Promise<GeoPosition | null>((resolve) => {
if (typeof navigator === 'undefined' || !navigator.geolocation) {
resolve(null);
return;
}
navigator.geolocation.getCurrentPosition(
(pos) => resolve({ latitude: pos.coords.latitude, longitude: pos.coords.longitude }),
() => resolve(null), // denied / unavailable / timeout — advisory, never a throw
{ enableHighAccuracy: true, timeout: GEOLOCATION_TIMEOUT_MS, maximumAge: 0 },
);
}),
};
const MOCK_LATENCY_MS = 500;
// ~5 km offset from the reference — comfortably outside any sane tolerance (advisory mismatch).
const OUT_OF_RANGE_DEGREE_OFFSET = 0.05;
/** Mock provider — canned coordinates per mode, with a small latency to exercise the "acquiring…" state. */
function makeMockLocationProvider(mode: Exclude<EvvGpsMode, 'off'>): ILocationProvider {
return {
getCurrentPosition: () =>
new Promise<GeoPosition | null>((resolve) => {
setTimeout(() => {
if (mode === 'denied') {
resolve(null);
} else if (mode === 'out_of_range') {
resolve({
latitude: MOCK_EVV_REFERENCE_LAT + OUT_OF_RANGE_DEGREE_OFFSET,
longitude: MOCK_EVV_REFERENCE_LNG + OUT_OF_RANGE_DEGREE_OFFSET,
});
} else {
resolve({ latitude: MOCK_EVV_REFERENCE_LAT, longitude: MOCK_EVV_REFERENCE_LNG });
}
}, MOCK_LATENCY_MS);
}),
};
}
/** The selected provider — the single seam the EVV controller imports. */
export const locationProvider: ILocationProvider =
EVV_GPS_MODE === 'off' ? realLocationProvider : makeMockLocationProvider(EVV_GPS_MODE);
@@ -0,0 +1,21 @@
import { useQuery } from '@tanstack/react-query';
import { bookingsApi } from '../apis';
import { bookingKeys } from '../keys';
import { BOOKING_DETAIL_GC_TIME, BOOKING_DETAIL_STALE_TIME } from '../constants';
import type { BookingViewerRole } from '../types';
/**
* The booking header + money summary + embedded sessions + timeline status. `viewerRole` drives the
* mock's address masking (the real server infers it from auth). A modest `staleTime` keeps the timeline
* fresh across status transitions; EVV mutations **invalidate** this key so the timeline and session rows
* reflect server truth immediately. Enabled only when an id is present.
*/
export function useBookingDetail(id: number | undefined, viewerRole: BookingViewerRole) {
return useQuery({
queryKey: bookingKeys.bookingDetail(id ?? -1),
queryFn: () => bookingsApi.getBookingDetail(id as number, viewerRole),
enabled: id != null && id > 0,
staleTime: BOOKING_DETAIL_STALE_TIME,
gcTime: BOOKING_DETAIL_GC_TIME,
});
}
@@ -0,0 +1,27 @@
import { useQuery } from '@tanstack/react-query';
import { bookingsApi } from '../apis';
import { bookingKeys } from '../keys';
import { BOOKING_LIST_STALE_TIME, BOOKINGS_PAGE_SIZE } from '../constants';
import type { BookingListParams, BookingListRole, BookingStatus } from '../types';
/**
* The role-scoped "My bookings" list (`bookings/list`). The customer رزروها tab reads `role='customer'`;
* the nurse reads `role='nurse'`. The role + status filter are part of the query key so each scope is a
* distinct cache entry. A conversion/transition invalidates `bookingKeys.lists()`.
*/
export function useBookingList(
role: BookingListRole,
options?: { status?: BookingStatus; page?: number; pageSize?: number },
) {
const params: BookingListParams = {
role,
status: options?.status,
page: options?.page ?? 1,
pageSize: options?.pageSize ?? BOOKINGS_PAGE_SIZE,
};
return useQuery({
queryKey: bookingKeys.list(params),
queryFn: () => bookingsApi.listBookings(params),
staleTime: BOOKING_LIST_STALE_TIME,
});
}
@@ -0,0 +1,21 @@
import { useQuery } from '@tanstack/react-query';
import { bookingsApi } from '../apis';
import { bookingKeys } from '../keys';
import { BOOKING_DETAIL_STALE_TIME } from '../constants';
import type { BookingSessionDto, BookingViewerRole } from '../types';
/**
* The session schedule for a booking. Sessions are **embedded** in the booking detail (the contract
* exposes no standalone per-booking session-list endpoint), so this shares the `bookingDetail(id)` query
* key + queryFn and `select`s `sessions` it dedupes with `useBookingDetail` and never fires a second
* request. Invalidating `bookingDetail(id)` (which the EVV mutations do) refreshes it automatically.
*/
export function useBookingSessions(id: number | undefined, viewerRole: BookingViewerRole) {
return useQuery({
queryKey: bookingKeys.bookingDetail(id ?? -1),
queryFn: () => bookingsApi.getBookingDetail(id as number, viewerRole),
enabled: id != null && id > 0,
staleTime: BOOKING_DETAIL_STALE_TIME,
select: (detail): BookingSessionDto[] => detail.sessions,
});
}
@@ -0,0 +1,20 @@
import { useQuery } from '@tanstack/react-query';
import { bookingsApi } from '../apis';
import { bookingKeys } from '../keys';
import { CARE_INSTRUCTIONS_STALE_TIME } from '../constants';
/**
* The gated stage-2 care-instructions read (`bookings/care_instructions/{id}`) the two-stage clinical
* disclosure boundary. **`enabled` is the hard UI gate:** the caller passes `enabled` only when the
* booking is `confirmed`+ **and** the viewer is the assigned nurse (or admin). When disabled the query
* **never fires** the client must not even request instructions it has no right to (a 403/404 from the
* server is a defect path, not the design). Always reads with the `nurse` viewer role.
*/
export function useCareInstructions(bookingId: number | undefined, options: { enabled: boolean }) {
return useQuery({
queryKey: bookingKeys.careInstructions(bookingId ?? -1),
queryFn: () => bookingsApi.getCareInstructions(bookingId as number, 'nurse'),
enabled: options.enabled && bookingId != null && bookingId > 0,
staleTime: CARE_INSTRUCTIONS_STALE_TIME,
});
}
@@ -0,0 +1,29 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { bookingsApi } from '../apis';
import { bookingKeys } from '../keys';
import type { CheckInVisitInput } from '../types';
/**
* Nurse EVV check-in. On success it **invalidates** the booking detail (timeline + the embedded session
* flips to `in_progress`), the today feed (the row's CTA state), and the bookings lists, and primes the
* per-session EVV cache with the returned verification so the banner is instant. No client-side status or
* money math the server response is the single source. `bookingId` is passed alongside the input so the
* invalidation is surgical (the verification payload carries only the session id).
*
* A GPS mismatch is advisory (`checkInAddressMatch = false`) and still succeeds the banner warns, it
* never blocks. Fetch-layer errors (401/403/5xx) are toasted by `clientFetch`; the caller surfaces only
* domain-specific 4xx (e.g. a `409` not-startable).
*/
export function useCheckInVisit() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ input }: { input: CheckInVisitInput; bookingId: number }) => bookingsApi.checkInVisit(input),
onSuccess: (verification, { input, bookingId }) => {
queryClient.setQueryData(bookingKeys.sessionEvv(input.bookingSessionId), verification);
queryClient.invalidateQueries({ queryKey: bookingKeys.bookingDetail(bookingId) });
queryClient.invalidateQueries({ queryKey: bookingKeys.sessionEvv(input.bookingSessionId) });
queryClient.invalidateQueries({ queryKey: bookingKeys.todayLists() });
queryClient.invalidateQueries({ queryKey: bookingKeys.lists() });
},
});
}
@@ -0,0 +1,25 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { bookingsApi } from '../apis';
import { bookingKeys } from '../keys';
import type { CheckOutVisitInput } from '../types';
/**
* Nurse EVV check-out must follow an open check-in (a `400 no_open_check_in` otherwise, surfaced by the
* caller, not the fetch layer). On success the session flips to `completed` and, when it's the last
* session, the booking completes + the dispute window opens all server-driven. It invalidates the same
* keys as check-in so the timeline, session row, and today feed reflect the new server state; no
* client-side payout-eligibility math (`payoutEligibleAt` is server truth).
*/
export function useCheckOutVisit() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ input }: { input: CheckOutVisitInput; bookingId: number }) => bookingsApi.checkOutVisit(input),
onSuccess: (verification, { input, bookingId }) => {
queryClient.setQueryData(bookingKeys.sessionEvv(input.bookingSessionId), verification);
queryClient.invalidateQueries({ queryKey: bookingKeys.bookingDetail(bookingId) });
queryClient.invalidateQueries({ queryKey: bookingKeys.sessionEvv(input.bookingSessionId) });
queryClient.invalidateQueries({ queryKey: bookingKeys.todayLists() });
queryClient.invalidateQueries({ queryKey: bookingKeys.lists() });
},
});
}
@@ -0,0 +1,19 @@
import { useQuery } from '@tanstack/react-query';
import { bookingsApi } from '../apis';
import { bookingKeys } from '../keys';
import { SESSION_EVV_STALE_TIME } from '../constants';
/**
* Per-session EVV detail (`booking_sessions/evv/{id}`) the `checkInAt` + advisory `checkInAddressMatch`
* the EVV banner renders on the nurse's day surface (the booking-detail card reads these from the
* embedded session instead, so it doesn't need this). `enabled` lets the day surface fetch it only for
* sessions that already have EVV activity. EVV mutations `setQueryData` this key so the banner is instant.
*/
export function useSessionEvv(sessionId: number | undefined, options?: { enabled?: boolean }) {
return useQuery({
queryKey: bookingKeys.sessionEvv(sessionId ?? -1),
queryFn: () => bookingsApi.getSessionEvv(sessionId as number),
enabled: (options?.enabled ?? true) && sessionId != null && sessionId > 0,
staleTime: SESSION_EVV_STALE_TIME,
});
}
@@ -0,0 +1,23 @@
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 type { TodaySessionsParams } from '../types';
/**
* The nurse's "today" session feed (`booking_sessions/today`) the ویزیت امروز surface. Kept fresh (short
* `staleTime`) and **invalidated on every EVV mutation** so a check-in/out flips the row's CTA state
* without a manual refresh. `date` omitted = the server's today.
*/
export function useTodaySessions(options?: { date?: string; page?: number; pageSize?: number }) {
const params: TodaySessionsParams = {
date: options?.date,
page: options?.page ?? 1,
pageSize: options?.pageSize ?? BOOKINGS_PAGE_SIZE,
};
return useQuery({
queryKey: bookingKeys.today(params),
queryFn: () => bookingsApi.listTodaySessions(params),
staleTime: TODAY_SESSIONS_STALE_TIME,
});
}
+14
View File
@@ -0,0 +1,14 @@
/**
* Bookings domain barrel re-exports **hooks only** (per the `services/{domain}` convention). Import
* types/keys/apis/evv directly from their files when needed. This is the **post-payment** engagement half
* (booking detail, sessions, EVV, gated care); the **pre-payment** request half lives in
* `services/bookingRequests`.
*/
export { useBookingDetail } from './hooks/useBookingDetail';
export { useBookingSessions } from './hooks/useBookingSessions';
export { useBookingList } from './hooks/useBookingList';
export { useTodaySessions } from './hooks/useTodaySessions';
export { useSessionEvv } from './hooks/useSessionEvv';
export { useCareInstructions } from './hooks/useCareInstructions';
export { useCheckInVisit } from './hooks/useCheckInVisit';
export { useCheckOutVisit } from './hooks/useCheckOutVisit';
+33
View File
@@ -0,0 +1,33 @@
import type { BookingListParams, TodaySessionsParams } from './types';
/**
* React Query key factory for the bookings domain (hierarchical, per the `services/{domain}` pattern).
*
* Sessions are **embedded** in `BookingDetailDto.sessions` (the contract exposes no standalone
* per-booking session-list endpoint), so `bookingSessions(id)` is an intentional **alias** of
* `bookingDetail(id)` the session list is a `select` over the one detail query, never a second fetch.
* Invalidating `bookingDetail(id)` therefore refreshes the timeline **and** the sessions in one shot.
* `sessionEvv`/`today` are their own endpoints and their own keys.
*/
export const bookingKeys = {
all: ['bookings'] as const,
lists: () => [...bookingKeys.all, 'list'] as const,
list: (params: BookingListParams) =>
[...bookingKeys.lists(), params.role, params.status ?? 'all', params.page ?? 1, params.pageSize ?? 0] as const,
details: () => [...bookingKeys.all, 'detail'] as const,
bookingDetail: (id: number) => [...bookingKeys.details(), id] as const,
/** Alias of `bookingDetail` — sessions live inside the detail payload (no separate endpoint). */
bookingSessions: (id: number) => bookingKeys.bookingDetail(id),
todayLists: () => [...bookingKeys.all, 'today'] as const,
today: (params: TodaySessionsParams) =>
[...bookingKeys.todayLists(), params.date ?? 'today', params.page ?? 1, params.pageSize ?? 0] as const,
evv: () => [...bookingKeys.all, 'evv'] as const,
sessionEvv: (sessionId: number) => [...bookingKeys.evv(), sessionId] as const,
care: () => [...bookingKeys.all, 'care'] as const,
careInstructions: (bookingId: number) => [...bookingKeys.care(), bookingId] as const,
};
+263
View File
@@ -0,0 +1,263 @@
import type { PageParams, Paginated } from '@/lib/api/types';
/**
* Bookings domain the **post-payment engagement** layer of the lifecycle (b9). An
* `accepted_awaiting_payment` request that is paid converts (`bookings/convert`) into a `bookings` row
* with N `booking_sessions`, an encrypted `booking_care_instructions`, and per-session EVV
* (`visit_verifications`). This is the sibling of the **pre-payment** `services/bookingRequests` domain
* (b8) a distinct contract (`dev/contracts/domains/bookings-evv.md`), distinct routes
* (`/api/v1/bookings/*` + `/api/v1/booking_sessions/*`), distinct shapes **not** a rename of it.
*
* Shapes mirror the b9 swagger 1:1 (camelCase; `clientFetch` unwraps the `ApiEnvelope<T>`, so these are
* the post-`unwrap()` payloads). Load-bearing semantics (contract + phase §5):
* - **Money is display-only and never computed.** `grossPriceIrr = balinyaarCommissionIrr +
* nursePayoutAmount` is guaranteed server-side; render the three IRR **digit-strings** as-is through
* the money util never sum, re-split, or derive them client-side.
* - **The status timeline is server truth.** `BookingDetailDto.status` is the single source; never
* advance/infer a step client-side. After an EVV mutation, invalidate and re-render from the server.
* - **Two-stage clinical disclosure.** `CareInstructionsDto` is decrypted and returned **only** to the
* assigned nurse (or admin) on a `confirmed`+ booking; the client must not even request it otherwise.
* - **EVV mismatch is advisory, never a block.** `checkInAddressMatch` (`true` in range · `false`
* out-of-range/under-review · `null` GPS unavailable) drives a banner, never a gate.
* - **Payout-eligibility is server truth.** `payoutEligibleAt` is gated by the dispute window
* server-side; render it, never recompute it.
*/
/** `BookingStatus` — the seven-state booking lifecycle (contract enum, stable string codes). */
export type BookingStatus =
| 'pending_payment'
| 'confirmed'
| 'in_progress'
| 'completed'
| 'disputed'
| 'closed'
| 'cancelled';
/** `BookingSessionStatus` — per-visit lifecycle (contract enum). */
export type BookingSessionStatus = 'scheduled' | 'in_progress' | 'completed' | 'missed' | 'cancelled';
/** `VisitVerificationStatus` — the EVV state of a session (contract enum). */
export type VisitVerificationStatus = 'pending' | 'checked_in' | 'completed';
/** Which "my bookings" scope to read — `all` is admin-only server-side (contract `list?role=`). */
export type BookingListRole = 'customer' | 'nurse' | 'all';
/**
* The viewer's actor role for a booking-detail read. Drives (a) the mock's address-snapshot masking
* (the nurse view omits it) and (b) the client-side care-instructions **UI gate**. The real server
* infers the view from auth + tenancy; this is passed for the mock and the gate. Admin is out of scope
* this phase (its console is f15).
*/
export type BookingViewerRole = 'customer' | 'nurse';
/**
* The happy-path timeline order rendered by `BookingStatusTimeline`. Terminal branches
* (`disputed`/`closed`/`cancelled`) are shown distinctly, off this line see `isBookingTerminalBranch`.
*/
export const BOOKING_TIMELINE_ORDER: readonly BookingStatus[] = [
'pending_payment',
'confirmed',
'in_progress',
'completed',
] as const;
/** `disputed`/`closed`/`cancelled` leave the happy path — rendered as a distinct terminal state. */
export function isBookingTerminalBranch(status: BookingStatus): boolean {
return status === 'disputed' || status === 'closed' || status === 'cancelled';
}
/**
* `confirmed` or beyond the booking has been paid/converted. Gates the care-instructions read
* (two-stage disclosure) and the "upcoming sessions" content. `pending_payment` and `cancelled` are
* **not** confirmed+; `disputed`/`closed`/`completed` are (they follow confirmation).
*/
export function isBookingConfirmedOrBeyond(status: BookingStatus): boolean {
return (
status === 'confirmed' ||
status === 'in_progress' ||
status === 'completed' ||
status === 'disputed' ||
status === 'closed'
);
}
/**
* The active step index for the 4-step timeline. A terminal branch reports the step it left from
* (`cancelled` from wherever, rendered distinctly), so callers should check `isBookingTerminalBranch`
* first and only use this for the happy path.
*/
export function bookingTimelineActiveIndex(status: BookingStatus): number {
const idx = BOOKING_TIMELINE_ORDER.indexOf(status);
if (idx >= 0) return idx;
// disputed/closed follow completion → sit the line at "completed"; cancelled is rendered distinctly
// (off the line) by the timeline, so its index is only a harmless fallback.
if (status === 'disputed' || status === 'closed') return BOOKING_TIMELINE_ORDER.indexOf('completed');
return BOOKING_TIMELINE_ORDER.indexOf('confirmed');
}
/** A per-visit session (embedded in `BookingDetailDto.sessions`; `BookingSessionSummaryDto` on the wire). */
export interface BookingSessionDto {
id: number;
sessionIndex: number;
/** ISO date `YYYY-MM-DD`. */
scheduledDate: string;
/** `HH:mm:ss`. */
scheduledTimeStart: string;
scheduledTimeEnd: string;
status: BookingSessionStatus;
/** IRR digit-string — this session's share of the nurse payout (Σ over sessions = nursePayoutAmount). */
visitPayoutAmount: string;
/** Server truth: set at check-out, gated by the dispute window. `null` until eligible. Never computed. */
payoutEligibleAt: string | null;
evvStatus: VisitVerificationStatus;
/** Server `checked_in_at` — the banner renders its Shamsi/clock from this, never a client clock. */
checkInAt: string | null;
checkOutAt: string | null;
/** Advisory: `true` in range · `false` out-of-range (under review) · `null` GPS unavailable. */
checkInAddressMatch: boolean | null;
}
/** The booking header + money summary + embedded sessions (`bookings/get`, `convert`, `transition`). */
export interface BookingDetailDto {
id: number;
bookingRequestId: number;
status: BookingStatus;
nurseId: number;
nurseName: string;
patientId: number;
patientName: string;
variantId: number;
variantSnapshotJson: string;
customerAddressId: number;
/** Full snapshot for the customer/admin; **`null` in the nurse view** (masked server-side). */
addressSnapshotJson: string | null;
/** The three money amounts — IRR digit-strings; `gross = commission + payout`, guaranteed server-side. */
grossPriceIrr: string;
balinyaarCommissionIrr: string;
nursePayoutAmount: string;
/** PSP fee (nullable) — a checkout concern, surfaced here for completeness. */
pspFeeAmount: string | null;
/** Snapshotted commission rate (decimal). */
platformFeeRate: number;
sessionCount: number;
scheduledDate: string;
scheduledTimeStart: string;
scheduledTimeEnd: string;
confirmedAt: string | null;
completedAt: string | null;
cancelledAt: string | null;
cancelledBy: string | null;
cancellationReason: string | null;
cancellationPolicyCode: string | null;
cancellationRefundPercentage: number | null;
refundableAmountIrr: string | null;
/** Set when the booking completes; the payout gate reads from this. `null` before completion. */
disputeWindowEndsAt: string | null;
createdAt: string;
sessions: BookingSessionDto[];
}
/** A row in the role-scoped "My bookings" list (`bookings/list`). `amountIrr` = gross (customer) / payout (nurse). */
export interface BookingListItemDto {
id: number;
status: BookingStatus;
counterpartyName: string;
scheduledDate: string;
sessionCount: number;
amountIrr: string;
disputeWindowEndsAt: string | null;
createdAt: string;
}
/** A row in the nurse's "today" session feed (`booking_sessions/today`) with EVV CTA state. */
export interface BookingSessionListItemDto {
sessionId: number;
bookingId: number;
sessionIndex: number;
patientName: string;
scheduledDate: string;
scheduledTimeStart: string;
scheduledTimeEnd: string;
status: BookingSessionStatus;
evvStatus: VisitVerificationStatus;
}
/**
* Per-session EVV detail (`booking_sessions/evv/{id}`). Raw GPS (`*Lat`/`*Lng`/`checkInDistanceMeters`)
* is gated to the owning nurse + admin server-side. The banner needs only `checkInAt` +
* `checkInAddressMatch`; the coordinates are informational.
*/
export interface VisitVerificationDto {
id: number;
bookingSessionId: number;
status: VisitVerificationStatus;
checkInAt: string | null;
checkInLat: number | null;
checkInLng: number | null;
checkOutAt: string | null;
checkOutLat: number | null;
checkOutLng: number | null;
checkInAddressMatch: boolean | null;
checkInDistanceMeters: number | null;
}
/**
* The decrypted stage-2 clinical/logistical context (`bookings/care_instructions/{id}`). Encrypted at
* rest; **present only in the gated read** to the assigned nurse (or admin) post-confirmation. All fields
* are free-text and nullable (the write path is `bookings/submit_care_instructions/{id}`, customer/admin).
*/
export interface CareInstructionsDto {
bookingId: number;
currentConditions: string | null;
medications: string | null;
allergies: string | null;
specialInstructions: string | null;
emergencyContactName: string | null;
emergencyContactPhone: string | null;
}
/**
* EVV check-in command. `latitude`/`longitude` are **nullable** a GPS-denied nurse still checks in
* (flagged, never blocked). `capturedAt` is the client capture instant; the server timestamps the
* authoritative `checkInAt`, so the real client sends only the coordinates (the contract command carries
* `latitude`/`longitude`/`sessionId`). Kept on the input for the mock's banner + audit fidelity.
*/
export interface CheckInVisitInput {
bookingSessionId: number;
latitude: number | null;
longitude: number | null;
/** ISO instant the client captured position; server time is authoritative. */
capturedAt: string;
}
/** EVV check-out command — same shape; must follow an open check-in (a `400` otherwise). */
export type CheckOutVisitInput = CheckInVisitInput;
/** `bookings/list` query params (role-scoped, paginated, optional status filter). */
export interface BookingListParams extends PageParams {
role: BookingListRole;
status?: BookingStatus;
}
/** `booking_sessions/today` query params (a nurse's day; default = all today). */
export interface TodaySessionsParams extends PageParams {
/** ISO date `YYYY-MM-DD`; omitted = the server's "today". */
date?: string;
}
/**
* The bookings API seam the real HTTP client and the in-memory mock both implement this interface;
* selection is by config (`USE_BOOKINGS_MOCK`), never scattered `if (mock)` checks.
*
* `getBookingDetail`/`getCareInstructions` take an optional `viewerRole` that only the mock uses (address
* masking + the care-instructions 404 boundary); the real client infers the view from auth and ignores it.
*/
export interface BookingsApi {
getBookingDetail(id: number, viewerRole?: BookingViewerRole): Promise<BookingDetailDto>;
listBookings(params: BookingListParams): Promise<Paginated<BookingListItemDto>>;
listTodaySessions(params: TodaySessionsParams): Promise<Paginated<BookingSessionListItemDto>>;
getSessionEvv(sessionId: number): Promise<VisitVerificationDto>;
getCareInstructions(bookingId: number, viewerRole?: BookingViewerRole): Promise<CareInstructionsDto>;
checkInVisit(input: CheckInVisitInput): Promise<VisitVerificationDto>;
checkOutVisit(input: CheckOutVisitInput): Promise<VisitVerificationDto>;
}
+4
View File
@@ -33,6 +33,8 @@
--bal-secondary-light: #e6a98a;
--bal-secondary-dark: #bf6f4d;
--bal-secondary-contrast: #2a1a12;
/* Soft terracotta tint — the "نمای پرستار" nurse-view chip + EVV/financial affordances */
--bal-secondary-soft: rgba(217, 140, 106, 0.14);
/* Surfaces */
--bal-bg-default: #faf9f5;
@@ -71,6 +73,8 @@
--bal-secondary-light: #f0bfa3;
--bal-secondary-dark: #d98c6a;
--bal-secondary-contrast: #2a1a12;
/* Soft terracotta tint — the "نمای پرستار" nurse-view chip + EVV/financial affordances */
--bal-secondary-soft: rgba(230, 169, 138, 0.18);
/* Surfaces — deep teal */
--bal-bg-default: #0f1c19;