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