270 lines
10 KiB
TypeScript
270 lines
10 KiB
TypeScript
'use client';
|
||
import { ReactNode } from 'react';
|
||
import { useLocale, useTranslations } from 'next-intl';
|
||
import { Box, Skeleton, Stack, Typography } from '@mui/material';
|
||
import {
|
||
AccentCard,
|
||
AppButton,
|
||
AppLink,
|
||
CountdownTimer,
|
||
EmptyState,
|
||
ErrorState,
|
||
Money,
|
||
PageHeader,
|
||
SurfaceCard,
|
||
} from '@/components';
|
||
import { ROUTES } from '@/constants';
|
||
import { formatRelativeTime, formatShamsiDate, localeTag, parseIrr } from '@/utils';
|
||
import { useNurseRequestInbox } from '@/services/bookingRequests';
|
||
import { useTodaySessions } from '@/services/bookings';
|
||
import { useNurseEarningsBalance } from '@/services/payouts';
|
||
import { coarseResponseLabel } from '@/services/bookingRequests/format';
|
||
import DashboardActivationSlot from './DashboardActivationSlot';
|
||
|
||
/** 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 «امروز» home — the first bottom-nav destination.
|
||
*
|
||
* Rebuilt for the phone-width frame. The previous version stacked five same-weight cards, each
|
||
* repeating its own icon + bold heading + inline "see all" button; at 480px the buttons wrapped
|
||
* mid-word, the countdown collided with the request title, and nothing on the screen looked more
|
||
* important than anything else. This version gives the page one visual hierarchy: exactly one hero
|
||
* action (the next visit), then sections introduced by a plain label with a text link instead of a
|
||
* competing button.
|
||
*
|
||
* The greeting/identity strip that used to sit above all of it is gone: it spent the most valuable
|
||
* row on the screen restating the signed-in name to the person who typed the phone number, and the
|
||
* badge beside it duplicated the activation tracker further down. Identity now lives in the shell's
|
||
* top bar (`NurseAccountButton`), where it costs no content height and opens the account hub.
|
||
*
|
||
* Composition only — every widget reads a query that is already cached elsewhere in the shell, and
|
||
* order encodes urgency: a missed request expires, an unread earnings figure does not.
|
||
*/
|
||
export default function NurseDashboardScreen() {
|
||
const t = useTranslations('dashboard');
|
||
return (
|
||
<Stack sx={{ gap: 2.5 }}>
|
||
{/* Load-bearing now that the bottom nav is icon-only: this is the only place the current
|
||
section is named, and the page's only h1. */}
|
||
<PageHeader title={t('title')} />
|
||
<NextVisitCard />
|
||
<RequestsSection />
|
||
<EarningsSection />
|
||
<DashboardActivationSlot />
|
||
</Stack>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* A section label + an optional text link. Deliberately not a button: on a 480px row a
|
||
* `<Button>` labelled «مشاهده همه» wrapped to two lines and outweighed the section it introduced.
|
||
*/
|
||
function SectionHeader({ title, actionLabel, actionTo }: { title: string; actionLabel?: string; actionTo?: string }) {
|
||
const locale = useLocale();
|
||
return (
|
||
<Stack direction="row" sx={{ alignItems: 'baseline', justifyContent: 'space-between', gap: 1 }}>
|
||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||
{title}
|
||
</Typography>
|
||
{actionLabel && actionTo ? (
|
||
<AppLink to={`/${locale}${actionTo}`} variant="caption" color="primary" sx={{ flexShrink: 0 }}>
|
||
{actionLabel}
|
||
</AppLink>
|
||
) : null}
|
||
</Stack>
|
||
);
|
||
}
|
||
|
||
/** The page's one hero action: the next actionable session, with a full-width primary CTA. */
|
||
function NextVisitCard() {
|
||
const t = useTranslations('dashboard');
|
||
const locale = useLocale();
|
||
const { data, isLoading, isError, refetch } = useTodaySessions();
|
||
|
||
if (isLoading) return <Skeleton variant="rounded" height={150} sx={{ borderRadius: 'var(--bal-radius-md)' }} />;
|
||
if (isError) {
|
||
return <ErrorState message={t('next_visit_error')} retryLabel={t('retry')} onRetry={() => refetch()} />;
|
||
}
|
||
|
||
const next = (data?.items ?? []).find((item) => item.status === 'scheduled' || item.status === 'in_progress');
|
||
|
||
if (!next) {
|
||
return (
|
||
<Stack sx={{ gap: 1 }}>
|
||
<SectionHeader title={t('next_visit_title')} />
|
||
<EmptyState icon="visits" title={t('next_visit_empty')} />
|
||
</Stack>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<AccentCard tone="secondary" data-widget="next-visit">
|
||
<Stack sx={{ gap: 1.5 }}>
|
||
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
|
||
<Typography variant="overline" sx={{ color: 'text.secondary' }}>
|
||
{t('next_visit_title')}
|
||
</Typography>
|
||
<Typography variant="h6" sx={{ fontWeight: 700 }}>
|
||
{next.patientName}
|
||
</Typography>
|
||
<MetaLine
|
||
items={[
|
||
<Box key="range" component="span" dir="ltr" sx={{ fontVariantNumeric: 'tabular-nums' }}>
|
||
{timeRangeLabel}
|
||
</Box>,
|
||
timeUntil ? t('next_visit_starts_in', { relative: timeUntil }) : null,
|
||
]}
|
||
/>
|
||
</Stack>
|
||
|
||
<AppButton variant="contained" color="secondary" startIcon="check_in" fullWidth to={`/${locale}${ROUTES.NURSE_VISITS}`}>
|
||
{t('next_visit_cta')}
|
||
</AppButton>
|
||
</Stack>
|
||
</AccentCard>
|
||
);
|
||
}
|
||
|
||
/** The most time-critical section: a pending request expires on its own if it isn't answered. */
|
||
function RequestsSection() {
|
||
const t = useTranslations('dashboard');
|
||
const tb = useTranslations('booking');
|
||
const locale = useLocale();
|
||
const { data, isLoading, isError, refetch } = useNurseRequestInbox();
|
||
|
||
if (isLoading) return <Skeleton variant="rounded" height={130} sx={{ borderRadius: 'var(--bal-radius-md)' }} />;
|
||
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 (
|
||
<Stack sx={{ gap: 1 }}>
|
||
<SectionHeader title={t('requests_strip_title', { count: 0 })} />
|
||
<EmptyState icon="requests" title={t('requests_strip_empty')} />
|
||
</Stack>
|
||
);
|
||
}
|
||
|
||
const mostUrgent = items[0];
|
||
|
||
return (
|
||
<Stack sx={{ gap: 1 }} data-widget="requests-strip">
|
||
<SectionHeader
|
||
title={t('requests_strip_title', { count: total })}
|
||
actionLabel={t('requests_strip_cta')}
|
||
actionTo={ROUTES.NURSE_REQUESTS}
|
||
/>
|
||
|
||
<SurfaceCard>
|
||
<Stack sx={{ gap: 1.5 }}>
|
||
{/* Name and countdown are siblings on one row, with the pill `flexShrink: 0` — the old
|
||
layout let the countdown wrap under a long Persian name and collide with the date. */}
|
||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between' }}>
|
||
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
|
||
<Typography variant="body1" noWrap sx={{ fontWeight: 500 }}>
|
||
{mostUrgent.counterpartyName}
|
||
</Typography>
|
||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||
{formatShamsiDate(mostUrgent.requestedDate, locale)}
|
||
</Typography>
|
||
</Stack>
|
||
<Box sx={{ flexShrink: 0 }}>
|
||
<CountdownTimer
|
||
deadlineIso={mostUrgent.nurseResponseDeadlineAt}
|
||
elapsedText={tb('response_elapsed')}
|
||
warnThresholdSeconds={WARN_THRESHOLD_SECONDS}
|
||
urgentThresholdSeconds={URGENT_THRESHOLD_SECONDS}
|
||
coarseLabel={(minutes) => coarseResponseLabel(minutes, tb)}
|
||
size="sm"
|
||
/>
|
||
</Box>
|
||
</Stack>
|
||
|
||
<AppButton
|
||
variant="outlined"
|
||
color="primary"
|
||
fullWidth
|
||
to={`/${locale}${ROUTES.NURSE_REQUESTS}/${mostUrgent.id}`}
|
||
>
|
||
{t('requests_strip_open')}
|
||
</AppButton>
|
||
</Stack>
|
||
</SurfaceCard>
|
||
</Stack>
|
||
);
|
||
}
|
||
|
||
/** Two stat tiles — the signed net balance is never clamped, an "owed back" reads as an error tone. */
|
||
function EarningsSection() {
|
||
const t = useTranslations('dashboard');
|
||
const tp = useTranslations('payouts');
|
||
const { data, isLoading, isError, refetch } = useNurseEarningsBalance();
|
||
|
||
if (isLoading) return <Skeleton variant="rounded" height={110} sx={{ borderRadius: 'var(--bal-radius-md)' }} />;
|
||
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 (
|
||
<Stack sx={{ gap: 1 }} data-widget="earnings-snapshot">
|
||
<SectionHeader
|
||
title={t('earnings_snapshot_title')}
|
||
actionLabel={t('earnings_snapshot_cta')}
|
||
actionTo={ROUTES.NURSE_EARNINGS}
|
||
/>
|
||
|
||
<Box sx={{ display: 'grid', gap: 1.5, gridTemplateColumns: '1fr 1fr' }}>
|
||
<StatTile
|
||
label={isOwed ? tp('balance_owed_label') : tp('balance_net_label')}
|
||
value={<Money amountIrr={String(magnitude)} size="lg" tone={isOwed ? 'error' : 'emphasis'} />}
|
||
/>
|
||
<StatTile label={tp('bucket_eligible')} value={<Money amountIrr={data.eligibleTotalIrr} size="lg" />} />
|
||
</Box>
|
||
</Stack>
|
||
);
|
||
}
|
||
|
||
function StatTile({ label, value }: { label: string; value: ReactNode }) {
|
||
return (
|
||
<SurfaceCard padding="sm">
|
||
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
|
||
<Typography variant="caption" noWrap sx={{ color: 'text.secondary' }}>
|
||
{label}
|
||
</Typography>
|
||
{value}
|
||
</Stack>
|
||
</SurfaceCard>
|
||
);
|
||
}
|
||
|
||
/** Dot-separated secondary facts on one line, skipping the ones that aren't available. */
|
||
function MetaLine({ items }: { items: Array<ReactNode> }) {
|
||
const present = items.filter(Boolean);
|
||
return (
|
||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||
{present.map((item, index) => (
|
||
<Box key={index} component="span">
|
||
{index > 0 ? ' · ' : null}
|
||
{item}
|
||
</Box>
|
||
))}
|
||
</Typography>
|
||
);
|
||
}
|