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
@@ -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,
};