ui phase 7
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
'use client';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Skeleton, Stack, Typography } from '@mui/material';
|
||||
import { AccentCard, AppButton, AppIcon } from '@/components';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { isApproved, type VerificationStatus } from '@/services/verification/types';
|
||||
|
||||
export interface DashboardActivationSlotProps {
|
||||
status: VerificationStatus | undefined;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The dashboard's activation/go-live composition point — **named and exported so a later phase can find
|
||||
* it**. This phase fills it only with the existing verification-status banner (rendered while the nurse
|
||||
* isn't yet approved; nothing once approved). The fuller "go live" checklist (profile/services/coverage/
|
||||
* bank all done) is **DEFERRED to ui-phase-8**, which owns this slot's content from here — extend this
|
||||
* component in place rather than adding a second slot.
|
||||
* @component DashboardActivationSlot
|
||||
*/
|
||||
export default function DashboardActivationSlot({ status, isLoading }: DashboardActivationSlotProps) {
|
||||
const t = useTranslations('dashboard');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
|
||||
if (isLoading) return <Skeleton variant="rounded" height={96} />;
|
||||
if (isApproved(status)) return null;
|
||||
|
||||
return (
|
||||
<AccentCard tone="warning">
|
||||
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'flex-start', justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
||||
<Stack direction="row" sx={{ gap: 1.25, alignItems: 'flex-start' }}>
|
||||
<AppIcon icon="verification" size={20} color="var(--bal-warning)" />
|
||||
<Stack sx={{ gap: 0.25 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{t('activation_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('activation_body')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<AppButton
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
size="small"
|
||||
endIcon="verification"
|
||||
onClick={() => router.push(`/${locale}${ROUTES.NURSE_VERIFICATION}`)}
|
||||
>
|
||||
{t('activation_cta')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</AccentCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
'use client';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Avatar, Box, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import {
|
||||
AppButton,
|
||||
AppIcon,
|
||||
AppLink,
|
||||
CountdownTimer,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
Money,
|
||||
SurfaceCard,
|
||||
TrustBadge,
|
||||
} from '@/components';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { formatRelativeTime, formatShamsiDate, localeTag, parseIrr } from '@/utils';
|
||||
import { useMe } from '@/services/auth';
|
||||
import { useNurseRequestInbox } from '@/services/bookingRequests';
|
||||
import { useTodaySessions } from '@/services/bookings';
|
||||
import { useNurseEarningsBalance } from '@/services/payouts';
|
||||
import { useUnreadCount } from '@/services/notifications';
|
||||
import { useVerificationStatus } from '@/services/verification';
|
||||
import { ownBadgeState } from '@/services/verification/types';
|
||||
import { coarseResponseLabel } from '@/services/bookingRequests/format';
|
||||
import DashboardActivationSlot from './DashboardActivationSlot';
|
||||
|
||||
const DASHBOARD_MAX_WIDTH = 960;
|
||||
/** The pill's urgency tiers (ui-phase-7 §3.4): teal >2h · amber <2h · terracotta <30min. */
|
||||
const URGENT_THRESHOLD_SECONDS = 30 * 60;
|
||||
const WARN_THRESHOLD_SECONDS = 2 * 60 * 60;
|
||||
|
||||
/**
|
||||
* The nurse "امروز" dashboard (ui-phase-7 §3.1) — the operational home replacing the `PlaceholderScreen`.
|
||||
* Pure assembly: every widget reads an already-cached query. Order matters — the pending-requests strip
|
||||
* is the most time-critical thing a nurse can miss, so it sits above the earnings snapshot.
|
||||
*/
|
||||
export default function NurseDashboardScreen() {
|
||||
const t = useTranslations('dashboard');
|
||||
const { data: me, isLoading: meLoading } = useMe();
|
||||
const verification = useVerificationStatus();
|
||||
|
||||
const displayName = me ? [me.firstName, me.lastName].filter(Boolean).join(' ').trim() || me.phone : '';
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 3, maxWidth: DASHBOARD_MAX_WIDTH, mx: 'auto', width: '100%' }}>
|
||||
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
|
||||
{meLoading ? (
|
||||
<>
|
||||
<Skeleton variant="circular" width={44} height={44} />
|
||||
<Skeleton variant="text" width={160} height={32} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Avatar sx={{ width: 44, height: 44, bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 700 }}>
|
||||
{(displayName || '؟').charAt(0)}
|
||||
</Avatar>
|
||||
<Stack sx={{ gap: 0.5 }}>
|
||||
<Typography variant="h6" component="h1" sx={{ fontWeight: 700 }}>
|
||||
{t('greeting', { name: displayName })}
|
||||
</Typography>
|
||||
{!verification.isLoading ? <TrustBadge state={ownBadgeState(verification.data)} /> : null}
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<NextVisitCard />
|
||||
<RequestsStrip />
|
||||
<EarningsSnapshotCard />
|
||||
<DashboardActivationSlot status={verification.data} isLoading={verification.isLoading} />
|
||||
<NotificationsEntryRow />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/** First actionable session from `useTodaySessions` + a display-only "time until" line. */
|
||||
function NextVisitCard() {
|
||||
const t = useTranslations('dashboard');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const { data, isLoading, isError, refetch } = useTodaySessions();
|
||||
|
||||
if (isLoading) return <Skeleton variant="rounded" height={140} />;
|
||||
if (isError) {
|
||||
return <ErrorState message={t('next_visit_error')} retryLabel={t('retry')} onRetry={() => refetch()} />;
|
||||
}
|
||||
|
||||
const items = data?.items ?? [];
|
||||
const next = items.find((item) => item.status === 'scheduled' || item.status === 'in_progress');
|
||||
|
||||
if (!next) {
|
||||
return <EmptyState icon="visits" title={t('next_visit_empty')} />;
|
||||
}
|
||||
|
||||
const timeFmt = new Intl.DateTimeFormat(localeTag(locale), { hour: '2-digit', minute: '2-digit' });
|
||||
const timeRangeLabel = `${timeFmt.format(new Date(`${next.scheduledDate}T${next.scheduledTimeStart}`))} – ${timeFmt.format(new Date(`${next.scheduledDate}T${next.scheduledTimeEnd}`))}`;
|
||||
const timeUntil = formatRelativeTime(`${next.scheduledDate}T${next.scheduledTimeStart}`, locale, formatShamsiDate);
|
||||
|
||||
return (
|
||||
<SurfaceCard data-widget="next-visit">
|
||||
<Stack sx={{ gap: 1.25 }}>
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
|
||||
<AppIcon icon="visits" size={20} color="var(--bal-primary)" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('next_visit_title')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack sx={{ gap: 0.25 }}>
|
||||
<Typography variant="body1" sx={{ fontWeight: 500 }}>
|
||||
{next.patientName}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
<Typography component="span" dir="ltr" sx={{ fontVariantNumeric: 'tabular-nums' }}>
|
||||
{timeRangeLabel}
|
||||
</Typography>
|
||||
{timeUntil ? ` · ${t('next_visit_starts_in', { relative: timeUntil })}` : ''}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<AppButton
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
startIcon="check_in"
|
||||
onClick={() => router.push(`/${locale}${ROUTES.NURSE_VISITS}`)}
|
||||
sx={{ alignSelf: 'flex-start' }}
|
||||
>
|
||||
{t('next_visit_cta')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</SurfaceCard>
|
||||
);
|
||||
}
|
||||
|
||||
/** The most time-critical widget: pending-request count + the most urgent countdown, inline into detail. */
|
||||
function RequestsStrip() {
|
||||
const t = useTranslations('dashboard');
|
||||
const tb = useTranslations('booking');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const { data, isLoading, isError, refetch } = useNurseRequestInbox();
|
||||
|
||||
if (isLoading) return <Skeleton variant="rounded" height={140} />;
|
||||
if (isError) {
|
||||
return <ErrorState message={t('requests_strip_error')} retryLabel={t('retry')} onRetry={() => refetch()} />;
|
||||
}
|
||||
|
||||
const items = data?.items ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
|
||||
if (items.length === 0) {
|
||||
return <EmptyState icon="requests" title={t('requests_strip_empty')} />;
|
||||
}
|
||||
|
||||
const mostUrgent = items[0];
|
||||
|
||||
return (
|
||||
<SurfaceCard data-widget="requests-strip">
|
||||
<Stack sx={{ gap: 1.25 }}>
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
|
||||
<AppIcon icon="requests" size={20} color="var(--bal-secondary)" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('requests_strip_title', { count: total })}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<AppButton
|
||||
variant="text"
|
||||
color="primary"
|
||||
endIcon="requests"
|
||||
onClick={() => router.push(`/${locale}${ROUTES.NURSE_REQUESTS}`)}
|
||||
>
|
||||
{t('requests_strip_cta')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{ gap: 1.5, alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap' }}
|
||||
>
|
||||
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
|
||||
<Typography variant="body1" sx={{ fontWeight: 500 }}>
|
||||
{mostUrgent.counterpartyName}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{formatShamsiDate(mostUrgent.requestedDate, locale)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<CountdownTimer
|
||||
deadlineIso={mostUrgent.nurseResponseDeadlineAt}
|
||||
elapsedText={tb('response_elapsed')}
|
||||
warnThresholdSeconds={WARN_THRESHOLD_SECONDS}
|
||||
urgentThresholdSeconds={URGENT_THRESHOLD_SECONDS}
|
||||
coarseLabel={(minutes) => coarseResponseLabel(minutes, tb)}
|
||||
size="sm"
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<AppButton
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
endIcon="requests"
|
||||
onClick={() => router.push(`/${locale}${ROUTES.NURSE_REQUESTS}/${mostUrgent.id}`)}
|
||||
sx={{ alignSelf: 'flex-start' }}
|
||||
>
|
||||
{t('requests_strip_open')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</SurfaceCard>
|
||||
);
|
||||
}
|
||||
|
||||
/** A compact two-stat row (net payable + eligible) — never clamps a negative net balance. */
|
||||
function EarningsSnapshotCard() {
|
||||
const t = useTranslations('dashboard');
|
||||
const tp = useTranslations('payouts');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const { data, isLoading, isError, refetch } = useNurseEarningsBalance();
|
||||
|
||||
if (isLoading) return <Skeleton variant="rounded" height={120} />;
|
||||
if (isError) {
|
||||
return <ErrorState message={t('earnings_snapshot_error')} retryLabel={t('retry')} onRetry={() => refetch()} />;
|
||||
}
|
||||
if (!data) return null;
|
||||
|
||||
const net = parseIrr(data.netPayableBalanceIrr);
|
||||
const isOwed = net < BigInt(0);
|
||||
const magnitude = isOwed ? -net : net;
|
||||
|
||||
return (
|
||||
<SurfaceCard data-widget="earnings-snapshot">
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
|
||||
<AppIcon icon="earnings" size={20} color="var(--bal-primary)" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('earnings_snapshot_title')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<AppButton
|
||||
variant="text"
|
||||
color="primary"
|
||||
endIcon="earnings"
|
||||
onClick={() => router.push(`/${locale}${ROUTES.NURSE_EARNINGS}`)}
|
||||
>
|
||||
{t('earnings_snapshot_cta')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
<Box sx={{ display: 'grid', gap: 1.5, gridTemplateColumns: '1fr 1fr' }}>
|
||||
<Stack sx={{ gap: 0.25 }}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{isOwed ? tp('balance_owed_label') : tp('balance_net_label')}
|
||||
</Typography>
|
||||
<Money amountIrr={String(magnitude)} size="lg" tone={isOwed ? 'error' : 'emphasis'} sx={{ fontWeight: 800 }} />
|
||||
</Stack>
|
||||
<Stack sx={{ gap: 0.25 }}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{tp('bucket_eligible')}
|
||||
</Typography>
|
||||
<Money amountIrr={data.eligibleTotalIrr} size="lg" sx={{ fontWeight: 800 }} />
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
</SurfaceCard>
|
||||
);
|
||||
}
|
||||
|
||||
/** The unread-count entry row — the bell in the shell chrome is Phase 2's; this is a dashboard shortcut. */
|
||||
function NotificationsEntryRow() {
|
||||
const t = useTranslations('dashboard');
|
||||
const locale = useLocale();
|
||||
const unread = useUnreadCount();
|
||||
|
||||
return (
|
||||
<AppLink to={`/${locale}${ROUTES.NURSE_NOTIFICATIONS}`} color="inherit" underline="none" sx={{ display: 'block' }}>
|
||||
<SurfaceCard data-widget="notifications-entry">
|
||||
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
|
||||
<AppIcon icon="notifications" size={20} color="var(--bal-primary)" />
|
||||
<Typography variant="body1" sx={{ fontWeight: 500 }}>
|
||||
{t('notifications_entry_title')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ color: unread > 0 ? 'var(--bal-secondary)' : 'text.secondary', fontWeight: unread > 0 ? 700 : 400 }}
|
||||
>
|
||||
{unread > 0 ? t('notifications_entry_unread', { count: unread }) : t('notifications_entry_empty')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</SurfaceCard>
|
||||
</AppLink>
|
||||
);
|
||||
}
|
||||
@@ -2,10 +2,11 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Box, Collapse, Paper, Skeleton, Stack, Tab, Tabs, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, EarningsBalanceHeader, EarningsRow, EmptyState, ErrorState } from '@/components';
|
||||
import { Box, ButtonBase, Collapse, Paper, Skeleton, Stack, Tab, Tabs, Typography } from '@mui/material';
|
||||
import { AppIcon, EarningsBalanceHeader, EarningsRow, EmptyState, ErrorState, Money, Pager, SurfaceCard } from '@/components';
|
||||
import { CONTENT_MAX_WIDTH } from '@/components/config';
|
||||
import { nurseBookingDetailPath, nursePayoutDetailPath } from '@/constants';
|
||||
import { formatNumber } from '@/utils';
|
||||
import { formatShamsiDate } from '@/utils';
|
||||
import { PAYOUTS_PAGE_SIZE } from '@/services/payouts/constants';
|
||||
import { EARNINGS_STATES, type EarningsState } from '@/services/payouts/types';
|
||||
import { useNurseEarnings, useNurseEarningsBalance } from '@/services/payouts';
|
||||
@@ -46,7 +47,7 @@ export default function NurseEarningsPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}>
|
||||
<Box>
|
||||
<Typography variant="h5" component="h1">
|
||||
{t('title')}
|
||||
@@ -61,7 +62,10 @@ export default function NurseEarningsPage() {
|
||||
) : balance.isError ? (
|
||||
<ErrorState message={t('balance_error')} retryLabel={t('retry')} onRetry={() => balance.refetch()} />
|
||||
) : balance.data ? (
|
||||
<EarningsBalanceHeader summary={balance.data} />
|
||||
<>
|
||||
<EarningsBalanceHeader summary={balance.data} />
|
||||
<ForecastLine nextPayoutDate={balance.data.nextPayoutDate} nextPayoutEligibleAmountIrr={balance.data.nextPayoutEligibleAmountIrr} />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<ExplainerCard open={explainerOpen} onToggle={() => setExplainerOpen((v) => !v)} />
|
||||
@@ -108,7 +112,42 @@ export default function NurseEarningsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Collapsible "how payouts work" — the cadence + dispute-window + method-invariant copy (both locales). */
|
||||
/** The «برداشت بعدی» forecast — server-served only (REQ-053); renders nothing until the earnings read
|
||||
* serves both fields (never computed client-side — holiday shifting + eligibility are backend truth). */
|
||||
function ForecastLine({
|
||||
nextPayoutDate,
|
||||
nextPayoutEligibleAmountIrr,
|
||||
}: {
|
||||
nextPayoutDate: string | null | undefined;
|
||||
nextPayoutEligibleAmountIrr: string | null | undefined;
|
||||
}) {
|
||||
const t = useTranslations('payouts');
|
||||
const locale = useLocale();
|
||||
if (!nextPayoutDate || !nextPayoutEligibleAmountIrr) return null;
|
||||
|
||||
return (
|
||||
<SurfaceCard padding="sm" data-widget="payout-forecast">
|
||||
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
|
||||
<AppIcon icon="calendar" size={18} color="var(--bal-primary)" />
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{t('forecast_label')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('forecast_on_date', { date: formatShamsiDate(nextPayoutDate, locale) })}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Money amountIrr={nextPayoutEligibleAmountIrr} size="md" tone="emphasis" sx={{ fontWeight: 700 }} />
|
||||
</Stack>
|
||||
</SurfaceCard>
|
||||
);
|
||||
}
|
||||
|
||||
const EXPLAINER_CONTENT_ID = 'nurse-earnings-explainer-content';
|
||||
|
||||
/** Collapsible "how payouts work" — the cadence + dispute-window + method-invariant copy (both locales).
|
||||
* A real `ButtonBase` toggle (`aria-expanded` + `aria-controls`) replaces the bare `onClick` Stack, and the
|
||||
* registered `expand` chevron (rotated when open) replaces the eye icons. */
|
||||
function ExplainerCard({ open, onToggle }: { open: boolean; onToggle: () => void }) {
|
||||
const t = useTranslations('payouts');
|
||||
const points = useMemo(() => ['explainer_point_1', 'explainer_point_2', 'explainer_point_3'] as const, []);
|
||||
@@ -125,10 +164,11 @@ function ExplainerCard({ open, onToggle }: { open: boolean; onToggle: () => void
|
||||
borderInlineStartColor: 'var(--bal-info)',
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between', cursor: 'pointer' }}
|
||||
<ButtonBase
|
||||
onClick={onToggle}
|
||||
aria-expanded={open}
|
||||
aria-controls={EXPLAINER_CONTENT_ID}
|
||||
sx={{ width: '100%', justifyContent: 'space-between', gap: 1, borderRadius: 1 }}
|
||||
>
|
||||
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center' }}>
|
||||
<AppIcon icon="info" size={18} color="var(--bal-info)" />
|
||||
@@ -136,9 +176,14 @@ function ExplainerCard({ open, onToggle }: { open: boolean; onToggle: () => void
|
||||
{t('explainer_title')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<AppIcon icon={open ? 'visibilityoff' : 'visibilityon'} size={18} color="var(--bal-text-secondary)" />
|
||||
</Stack>
|
||||
<Collapse in={open}>
|
||||
<AppIcon
|
||||
icon="expand"
|
||||
size={18}
|
||||
color="var(--bal-text-secondary)"
|
||||
style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform var(--bal-motion-fast) var(--bal-easing-standard)' }}
|
||||
/>
|
||||
</ButtonBase>
|
||||
<Collapse in={open} id={EXPLAINER_CONTENT_ID}>
|
||||
<Stack component="ul" sx={{ gap: 0.75, mt: 1.5, mb: 0, pl: 2.5 }}>
|
||||
{points.map((key) => (
|
||||
<Typography key={key} component="li" variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
@@ -150,35 +195,3 @@ function ExplainerCard({ open, onToggle }: { open: boolean; onToggle: () => void
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
/** Prev/next pager — rendered only when there is more than one page. */
|
||||
function Pager({
|
||||
page,
|
||||
pageCount,
|
||||
onPrev,
|
||||
onNext,
|
||||
}: {
|
||||
page: number;
|
||||
pageCount: number;
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
}) {
|
||||
const t = useTranslations('payouts');
|
||||
const locale = useLocale();
|
||||
if (pageCount <= 1) return null;
|
||||
const fmt = (n: number) => formatNumber(n, locale);
|
||||
|
||||
return (
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'center' }}>
|
||||
<AppButton variant="text" color="primary" onClick={onPrev} disabled={page <= 1}>
|
||||
{t('page_prev')}
|
||||
</AppButton>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('page_indicator', { page: fmt(page), total: fmt(pageCount) })}
|
||||
</Typography>
|
||||
<AppButton variant="text" color="primary" onClick={onNext} disabled={page >= pageCount}>
|
||||
{t('page_next')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Box, Divider, Paper, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, Money, PriceBreakdown, StatusChip } from '@/components';
|
||||
import type { StatusKind } from '@/components';
|
||||
import { CONTENT_MAX_WIDTH } from '@/components/config';
|
||||
import { nurseBookingDetailPath, ROUTES } from '@/constants';
|
||||
import { formatShamsiDate, parseIrr } from '@/utils';
|
||||
import { failureReasonLabelKey } from '@/services/payouts/failureReasons';
|
||||
import { useNursePayoutDetail } from '@/services/payouts';
|
||||
import type { PayoutBatchStatus, PayoutStatus } from '@/services/payouts/types';
|
||||
|
||||
@@ -42,7 +44,7 @@ export default function NursePayoutDetailPage() {
|
||||
const { data, isLoading, isError } = useNursePayoutDetail(Number.isFinite(payoutId) ? payoutId : undefined);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}>
|
||||
<Stack sx={{ gap: 0.5 }}>
|
||||
<AppButton
|
||||
variant="text"
|
||||
@@ -123,9 +125,12 @@ export default function NursePayoutDetailPage() {
|
||||
{t('failure_title')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t(failureReasonLabelKey(data.failureReason))}
|
||||
</Typography>
|
||||
{data.failureReason ? (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }} dir="ltr">
|
||||
{t('failure_reason_label')}: {data.failureReason}
|
||||
{data.failureReason}
|
||||
</Typography>
|
||||
) : null}
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
|
||||
|
||||
@@ -3,9 +3,9 @@ import { useState } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Box, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, EmptyState, ErrorState, PayoutHistoryRow } from '@/components';
|
||||
import { AppButton, EmptyState, ErrorState, Pager, PayoutHistoryRow } from '@/components';
|
||||
import { CONTENT_MAX_WIDTH } from '@/components/config';
|
||||
import { nursePayoutDetailPath, ROUTES } from '@/constants';
|
||||
import { formatNumber } from '@/utils';
|
||||
import { PAYOUTS_PAGE_SIZE } from '@/services/payouts/constants';
|
||||
import { useNursePayoutHistory } from '@/services/payouts';
|
||||
|
||||
@@ -26,10 +26,9 @@ export default function NursePayoutHistoryPage() {
|
||||
const items = history.data?.items ?? [];
|
||||
const total = history.data?.total ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / PAYOUTS_PAGE_SIZE));
|
||||
const fmt = (n: number) => formatNumber(n, locale);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}>
|
||||
<Stack sx={{ gap: 0.5 }}>
|
||||
<AppButton
|
||||
variant="text"
|
||||
@@ -70,19 +69,12 @@ export default function NursePayoutHistoryPage() {
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{pageCount > 1 ? (
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'center' }}>
|
||||
<AppButton variant="text" color="primary" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page <= 1}>
|
||||
{t('page_prev')}
|
||||
</AppButton>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('page_indicator', { page: fmt(page), total: fmt(pageCount) })}
|
||||
</Typography>
|
||||
<AppButton variant="text" color="primary" onClick={() => setPage((p) => Math.min(pageCount, p + 1))} disabled={page >= pageCount}>
|
||||
{t('page_next')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
) : null}
|
||||
<Pager
|
||||
page={page}
|
||||
pageCount={pageCount}
|
||||
onPrev={() => setPage((p) => Math.max(1, p - 1))}
|
||||
onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { PlaceholderScreen } from '@/components';
|
||||
import NurseDashboardScreen from './NurseDashboardScreen';
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
@@ -8,8 +8,6 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
|
||||
return { title: t('dashboard') };
|
||||
}
|
||||
|
||||
export default async function NurseDashboardPage() {
|
||||
const t = await getTranslations('nav');
|
||||
const tShell = await getTranslations('shell');
|
||||
return <PlaceholderScreen icon="dashboard" title={t('dashboard')} description={tShell('placeholder_body')} />;
|
||||
export default function NurseDashboardPage() {
|
||||
return <NurseDashboardScreen />;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,8 @@ import {
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { AppButton, AppIcon, CountdownTimer, PriceDisplay, StatusChip } from '@/components';
|
||||
import { AppButton, AppIcon, ConfirmDialog, CountdownTimer, PriceDisplay, StatusChip } from '@/components';
|
||||
import { CONTENT_MAX_WIDTH } from '@/components/config';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { formatShamsiDate, localeTag } from '@/utils';
|
||||
@@ -54,12 +55,13 @@ export default function NurseRequestDetailPage() {
|
||||
const [rejectOpen, setRejectOpen] = useState(false);
|
||||
const [reason, setReason] = useState('');
|
||||
const [reasonError, setReasonError] = useState(false);
|
||||
const [acceptConfirmOpen, setAcceptConfirmOpen] = useState(false);
|
||||
|
||||
if (isLoading) return <DetailSkeleton />;
|
||||
|
||||
if (isError || !request) {
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2, maxWidth: 640 }}>
|
||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1 }}>
|
||||
{t('not_found_title')}
|
||||
</Typography>
|
||||
@@ -121,7 +123,7 @@ export default function NurseRequestDetailPage() {
|
||||
const whenLabel = `${formatShamsiDate(startDate, locale)} · ${timeFmt.format(startDate)} – ${timeFmt.format(endDate)}`;
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 640 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}>
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}>
|
||||
<Typography variant="h5" component="h1">
|
||||
{t('detail_title')}
|
||||
@@ -201,7 +203,7 @@ export default function NurseRequestDetailPage() {
|
||||
variant="contained"
|
||||
startIcon="verified"
|
||||
disabled={acceptRequest.isPending}
|
||||
onClick={handleAccept}
|
||||
onClick={() => setAcceptConfirmOpen(true)}
|
||||
sx={{ flex: 1, py: 1.25 }}
|
||||
>
|
||||
{acceptRequest.isPending ? t('accepting') : t('accept')}
|
||||
@@ -230,12 +232,36 @@ export default function NurseRequestDetailPage() {
|
||||
borderInlineStartColor: isTerminal ? 'var(--bal-text-secondary)' : 'var(--bal-secondary)',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t(`status_${request.status}`)}
|
||||
</Typography>
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t(`status_${request.status}`)}
|
||||
</Typography>
|
||||
{request.status === 'accepted_awaiting_payment' && request.paymentDeadlineAt ? (
|
||||
<CountdownTimer
|
||||
deadlineIso={request.paymentDeadlineAt}
|
||||
label={t('payment_countdown_label')}
|
||||
elapsedText={t('payment_elapsed')}
|
||||
urgent
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={acceptConfirmOpen}
|
||||
title={t('accept_confirm_title')}
|
||||
body={t('accept_confirm_body')}
|
||||
confirmLabel={t('accept_confirm_cta')}
|
||||
cancelLabel={t('cancel_request')}
|
||||
loading={acceptRequest.isPending}
|
||||
onClose={() => setAcceptConfirmOpen(false)}
|
||||
onConfirm={() => {
|
||||
setAcceptConfirmOpen(false);
|
||||
handleAccept();
|
||||
}}
|
||||
/>
|
||||
|
||||
<Dialog open={rejectOpen} onClose={() => setRejectOpen(false)} fullWidth maxWidth="xs">
|
||||
<DialogTitle>{t('reject_dialog_title')}</DialogTitle>
|
||||
<DialogContent>
|
||||
@@ -307,7 +333,7 @@ function DetailRow({ caption, children }: { caption: string; children: React.Rea
|
||||
|
||||
function DetailSkeleton() {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 640 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}>
|
||||
<Skeleton variant="text" width="40%" height={36} />
|
||||
<Skeleton variant="rounded" height={180} />
|
||||
<Skeleton variant="rounded" height={120} />
|
||||
|
||||
@@ -1,53 +1,125 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Box, Chip, Paper, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, CountdownTimer, EmptyState, ErrorState } from '@/components';
|
||||
import { Box, Chip, Skeleton, Stack, Tab, Tabs, Typography } from '@mui/material';
|
||||
import { AppLink, CountdownTimer, EmptyState, ErrorState, Pager, PageHeader, SurfaceCard } from '@/components';
|
||||
import { CONTENT_MAX_WIDTH } from '@/components/config';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { formatShamsiDate, localeTag } from '@/utils';
|
||||
import { useNurseRequestInbox } from '@/services/bookingRequests';
|
||||
import { coarseResponseLabel } from '@/services/bookingRequests/format';
|
||||
import type { BookingRequestListItem } from '@/services/bookingRequests/types';
|
||||
|
||||
type InboxTab = 'pending' | 'answered' | 'expired';
|
||||
|
||||
/** The pill's urgency tiers (§3.4): teal >2h · amber <2h · terracotta <30min. */
|
||||
const URGENT_THRESHOLD_SECONDS = 30 * 60;
|
||||
const WARN_THRESHOLD_SECONDS = 2 * 60 * 60;
|
||||
|
||||
/**
|
||||
* Nurse incoming-requests inbox (نمای پرستار). Lists pending requests — each a card with the family's
|
||||
* patient name, the requested time (Shamsi), the **required-caregiver-gender** chip, a notes preview, and
|
||||
* a **per-request countdown** to that request's response deadline. Two-stage disclosure: the row shows
|
||||
* only `customerNotes` — never an address or any clinical field. Lightly polled so new requests appear.
|
||||
* Nurse incoming-requests inbox (نمای پرستار), ui-phase-7 redesign. Decision-first cards (service + price
|
||||
* headline when the list DTO serves them — REQ-050, mock-tolerant), an urgency-tinted countdown pill, and
|
||||
* three tabs: «در انتظار» (a single `pending_nurse_response` query) / «پاسخداده» (merged, page-1-only —
|
||||
* the API filters by a *single* status and there's no status-group filter yet, so this tab concatenates
|
||||
* three page-1 queries; a documented limitation until REQ-050's status-group filter lands) / «منقضی»
|
||||
* (`expired_no_response`). Two-stage disclosure unchanged: the row shows only `customerNotes` — never an
|
||||
* address or any clinical field.
|
||||
*/
|
||||
export default function NurseRequestsPage() {
|
||||
const t = useTranslations('booking');
|
||||
const tc = useTranslations('common');
|
||||
const { data, isLoading, isError, refetch } = useNurseRequestInbox();
|
||||
const items = data?.items ?? [];
|
||||
const [tab, setTab] = useState<InboxTab>('pending');
|
||||
const [pendingPage, setPendingPage] = useState(1);
|
||||
const [expiredPage, setExpiredPage] = useState(1);
|
||||
|
||||
const pendingQuery = useNurseRequestInbox('pending_nurse_response', pendingPage, { enabled: tab === 'pending' });
|
||||
const expiredQuery = useNurseRequestInbox('expired_no_response', expiredPage, { enabled: tab === 'expired' });
|
||||
const acceptedQuery = useNurseRequestInbox('accepted_awaiting_payment', 1, { enabled: tab === 'answered' });
|
||||
const convertedQuery = useNurseRequestInbox('converted', 1, { enabled: tab === 'answered' });
|
||||
const rejectedQuery = useNurseRequestInbox('rejected_by_nurse', 1, { enabled: tab === 'answered' });
|
||||
|
||||
const onTabChange = (_event: React.SyntheticEvent, next: InboxTab) => setTab(next);
|
||||
|
||||
const answeredLoading = acceptedQuery.isLoading || convertedQuery.isLoading || rejectedQuery.isLoading;
|
||||
const answeredError = acceptedQuery.isError || convertedQuery.isError || rejectedQuery.isError;
|
||||
const answeredItems = [
|
||||
...(acceptedQuery.data?.items ?? []),
|
||||
...(convertedQuery.data?.items ?? []),
|
||||
...(rejectedQuery.data?.items ?? []),
|
||||
];
|
||||
const retryAnswered = () => {
|
||||
acceptedQuery.refetch();
|
||||
convertedQuery.refetch();
|
||||
rejectedQuery.refetch();
|
||||
};
|
||||
|
||||
const active =
|
||||
tab === 'pending'
|
||||
? {
|
||||
items: pendingQuery.data?.items ?? [],
|
||||
isLoading: pendingQuery.isLoading,
|
||||
isError: pendingQuery.isError,
|
||||
refetch: pendingQuery.refetch,
|
||||
page: pendingPage,
|
||||
pageCount: Math.max(1, Math.ceil((pendingQuery.data?.total ?? 0) / (pendingQuery.data?.pageSize || 1))),
|
||||
onPageChange: setPendingPage,
|
||||
}
|
||||
: tab === 'expired'
|
||||
? {
|
||||
items: expiredQuery.data?.items ?? [],
|
||||
isLoading: expiredQuery.isLoading,
|
||||
isError: expiredQuery.isError,
|
||||
refetch: expiredQuery.refetch,
|
||||
page: expiredPage,
|
||||
pageCount: Math.max(1, Math.ceil((expiredQuery.data?.total ?? 0) / (expiredQuery.data?.pageSize || 1))),
|
||||
onPageChange: setExpiredPage,
|
||||
}
|
||||
: {
|
||||
items: answeredItems,
|
||||
isLoading: answeredLoading,
|
||||
isError: answeredError,
|
||||
refetch: retryAnswered,
|
||||
page: 1,
|
||||
pageCount: 1,
|
||||
onPageChange: () => {},
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 640 }}>
|
||||
<Box>
|
||||
<Typography variant="h5" component="h1">
|
||||
{t('inbox_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('inbox_subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}>
|
||||
<PageHeader title={t('inbox_title')} subtitle={t('inbox_subtitle')} />
|
||||
|
||||
{isLoading ? (
|
||||
<Tabs value={tab} onChange={onTabChange} variant="scrollable" scrollButtons="auto" allowScrollButtonsMobile>
|
||||
<Tab value="pending" label={t('nurse_inbox_tab_pending')} sx={{ textTransform: 'none' }} />
|
||||
<Tab value="answered" label={t('nurse_inbox_tab_answered')} sx={{ textTransform: 'none' }} />
|
||||
<Tab value="expired" label={t('nurse_inbox_tab_expired')} sx={{ textTransform: 'none' }} />
|
||||
</Tabs>
|
||||
|
||||
{active.isLoading ? (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
{[0, 1].map((key) => (
|
||||
<Skeleton key={key} variant="rounded" height={140} />
|
||||
))}
|
||||
</Stack>
|
||||
) : isError ? (
|
||||
<ErrorState message={t('inbox_error')} retryLabel={tc('retry')} onRetry={() => refetch()} />
|
||||
) : items.length === 0 ? (
|
||||
) : active.isError ? (
|
||||
<ErrorState message={t('inbox_error')} retryLabel={t('retry')} onRetry={() => active.refetch()} />
|
||||
) : active.items.length === 0 ? (
|
||||
<EmptyState icon="requests" title={t('inbox_empty')} />
|
||||
) : (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
{items.map((item) => (
|
||||
{active.items.map((item) => (
|
||||
<InboxCard key={item.id} item={item} />
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{tab !== 'answered' ? (
|
||||
<Pager
|
||||
page={active.page}
|
||||
pageCount={active.pageCount}
|
||||
onPrev={() => active.onPageChange(Math.max(1, active.page - 1))}
|
||||
onNext={() => active.onPageChange(Math.min(active.pageCount, active.page + 1))}
|
||||
/>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -55,59 +127,72 @@ export default function NurseRequestsPage() {
|
||||
function InboxCard({ item }: { item: BookingRequestListItem }) {
|
||||
const t = useTranslations('booking');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
|
||||
const startDate = new Date(`${item.requestedDate}T${item.requestedTimeStart}`);
|
||||
const endDate = new Date(`${item.requestedDate}T${item.requestedTimeEnd}`);
|
||||
const timeFmt = new Intl.DateTimeFormat(localeTag(locale), { hour: '2-digit', minute: '2-digit' });
|
||||
const whenLabel = `${formatShamsiDate(startDate, locale)} · ${timeFmt.format(startDate)} – ${timeFmt.format(endDate)}`;
|
||||
const hasPricedService = Boolean(item.variantLabel);
|
||||
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'flex-start', gap: 2 }}>
|
||||
<Stack sx={{ gap: 0.5, minWidth: 0 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{item.counterpartyName}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{whenLabel}
|
||||
</Typography>
|
||||
<AppLink to={`/${locale}${ROUTES.NURSE_REQUESTS}/${item.id}`} color="inherit" underline="none" sx={{ display: 'block' }}>
|
||||
<SurfaceCard data-request-status={item.status}>
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'flex-start', gap: 2 }}>
|
||||
<Stack sx={{ gap: 0.5, minWidth: 0 }}>
|
||||
{hasPricedService ? (
|
||||
<>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{item.variantLabel}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{item.counterpartyName}
|
||||
</Typography>
|
||||
</>
|
||||
) : (
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{item.counterpartyName}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{whenLabel}
|
||||
</Typography>
|
||||
</Stack>
|
||||
{item.status === 'pending_nurse_response' ? (
|
||||
<CountdownTimer
|
||||
deadlineIso={item.nurseResponseDeadlineAt}
|
||||
label={t('inbox_countdown_pill_label')}
|
||||
elapsedText={t('response_elapsed')}
|
||||
warnThresholdSeconds={WARN_THRESHOLD_SECONDS}
|
||||
urgentThresholdSeconds={URGENT_THRESHOLD_SECONDS}
|
||||
coarseLabel={(minutes) => coarseResponseLabel(minutes, t)}
|
||||
size="sm"
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
<CountdownTimer deadlineIso={item.nurseResponseDeadlineAt} elapsedText={t('response_elapsed')} />
|
||||
|
||||
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
|
||||
{item.requiredCaregiverGender ? (
|
||||
<Chip
|
||||
size="small"
|
||||
label={t('required_gender_chip', { gender: t(`gender_${item.requiredCaregiverGender}`) })}
|
||||
sx={{ bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 500 }}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
{item.customerNotes ? (
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 700 }}>
|
||||
{t('inbox_notes_label')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }} noWrap>
|
||||
{item.customerNotes}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
{item.requiredCaregiverGender ? (
|
||||
<Box>
|
||||
<Chip
|
||||
size="small"
|
||||
label={t('required_gender_chip', { gender: t(`gender_${item.requiredCaregiverGender}`) })}
|
||||
sx={{ bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 500 }}
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{item.customerNotes ? (
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 700 }}>
|
||||
{t('inbox_notes_label')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }} noWrap>
|
||||
{item.customerNotes}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
<AppButton
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
endIcon="requests"
|
||||
onClick={() => router.push(`/${locale}${ROUTES.NURSE_REQUESTS}/${item.id}`)}
|
||||
sx={{ alignSelf: 'flex-start' }}
|
||||
>
|
||||
{t('open_detail')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</SurfaceCard>
|
||||
</AppLink>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,36 +1,36 @@
|
||||
'use client';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Box, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, EmptyState } from '@/components';
|
||||
import { Box, Skeleton, Stack } from '@mui/material';
|
||||
import { AppButton, EmptyState, ErrorState, PageHeader } from '@/components';
|
||||
import { SessionCard, useEvvController } from '@/components/booking';
|
||||
import { CONTENT_MAX_WIDTH } from '@/components/config';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { formatShamsiDate } from '@/utils';
|
||||
import { useSessionEvv, useTodaySessions } from '@/services/bookings';
|
||||
import type { BookingSessionListItemDto } from '@/services/bookings/types';
|
||||
|
||||
const TODAY_HEADER_DATE_OPTIONS: Intl.DateTimeFormatOptions = { month: 'long', day: 'numeric' };
|
||||
|
||||
/**
|
||||
* Nurse ویزیت امروز (E3 top) — the day's operational surface. Lists today's sessions from
|
||||
* `useTodaySessions`; each renders the shared `SessionCard` with the per-session EVV check-in/out control
|
||||
* (driven by one `useEvvController`) and the advisory EVV banner once checked in. A GPS mismatch is
|
||||
* advisory, never a block. Each card also deep-links to the full booking detail (`/nurse/visits/{id}`),
|
||||
* where the gated care instructions live. Visit-note authoring + task checklist are deferred to f13.
|
||||
* `useTodaySessions` (polled every 60s so a same-day schedule change surfaces without re-navigation);
|
||||
* each renders the shared `SessionCard` with the per-session EVV check-in/out control (driven by one
|
||||
* `useEvvController`) and the advisory EVV banner once checked in. A GPS mismatch is advisory, never a
|
||||
* block. Each card also deep-links to the full booking detail (`/nurse/visits/{id}`), where the gated
|
||||
* care instructions live. Visit-note authoring + task checklist are deferred to f13.
|
||||
*/
|
||||
export default function NurseVisitsPage() {
|
||||
const t = useTranslations('booking');
|
||||
const { data, isLoading } = useTodaySessions();
|
||||
const locale = useLocale();
|
||||
const { data, isLoading, isError, refetch } = useTodaySessions();
|
||||
const evv = useEvvController();
|
||||
const items = data?.items ?? [];
|
||||
const dateAnchor = t('today_date_prefix', { date: formatShamsiDate(new Date(), locale, TODAY_HEADER_DATE_OPTIONS) });
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 640 }}>
|
||||
<Box>
|
||||
<Typography variant="h5" component="h1">
|
||||
{t('evv_visits_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('evv_visits_subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}>
|
||||
<PageHeader title={dateAnchor} subtitle={t('evv_visits_subtitle')} />
|
||||
|
||||
{isLoading ? (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
@@ -38,6 +38,8 @@ export default function NurseVisitsPage() {
|
||||
<Skeleton key={key} variant="rounded" height={150} />
|
||||
))}
|
||||
</Stack>
|
||||
) : isError ? (
|
||||
<ErrorState message={t('evv_visits_error')} retryLabel={t('retry')} onRetry={() => refetch()} />
|
||||
) : items.length === 0 ? (
|
||||
<EmptyState icon="visits" title={t('evv_no_visits')} />
|
||||
) : (
|
||||
@@ -64,6 +66,7 @@ function TodayVisitCard({ item, evv }: { item: BookingSessionListItemDto; evv: R
|
||||
<Stack sx={{ gap: 0.75 }}>
|
||||
<SessionCard
|
||||
title={item.patientName}
|
||||
serviceLabel={item.variantLabel}
|
||||
sessionIndex={item.sessionIndex}
|
||||
scheduledDate={item.scheduledDate}
|
||||
scheduledTimeStart={item.scheduledTimeStart}
|
||||
|
||||
Reference in New Issue
Block a user