ui phase 7

This commit is contained in:
hamid
2026-07-19 11:56:59 +03:30
parent a438edeeaa
commit edc38543fd
39 changed files with 1538 additions and 208 deletions
@@ -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}