backend phase 15 & frontend phase 8
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
'use client';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { BookingDetailView } from '@/components/booking';
|
||||
|
||||
/**
|
||||
* Customer booking detail (`/bookings/{id}`) — the read-only both-roles view in the **customer** shell:
|
||||
* server-truth status timeline, session schedule, and money summary. Care instructions are gated to the
|
||||
* assigned nurse, so the customer sees the "visible to your nurse only" affordance (the query never fires).
|
||||
*/
|
||||
export default function CustomerBookingDetailPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const id = Number(params.id);
|
||||
return <BookingDetailView bookingId={Number.isInteger(id) && id > 0 ? id : -1} viewerRole="customer" />;
|
||||
}
|
||||
@@ -1,8 +1,104 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { PlaceholderScreen } from '@/components';
|
||||
'use client';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, StatusChip } from '@/components';
|
||||
import { BOOKING_STATUS_KIND } from '@/components/booking/statusKind';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { formatIrrToToman, formatShamsiDate } from '@/utils';
|
||||
import { useBookingList } from '@/services/bookings';
|
||||
import type { BookingListItemDto } from '@/services/bookings/types';
|
||||
|
||||
export default async function BookingsPage() {
|
||||
const t = await getTranslations('nav');
|
||||
const tShell = await getTranslations('shell');
|
||||
return <PlaceholderScreen icon="bookings" title={t('bookings')} description={tShell('placeholder_body')} />;
|
||||
/**
|
||||
* Customer رزروها — the "My bookings" list. Reads `useBookingList('customer')`; each row opens the
|
||||
* booking detail (`/bookings/{id}`). This is the customer entry to the f8 booking-detail surface (the C5
|
||||
* `converted` state also lands here). Amounts render in Toman via the money util.
|
||||
*/
|
||||
export default function CustomerBookingsPage() {
|
||||
const t = useTranslations('booking');
|
||||
const { data, isLoading, isError } = useBookingList('customer');
|
||||
const items = data?.items ?? [];
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<Box>
|
||||
<Typography variant="h5" component="h1">
|
||||
{t('list_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('list_subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{isLoading ? (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
{[0, 1].map((key) => (
|
||||
<Skeleton key={key} variant="rounded" height={120} />
|
||||
))}
|
||||
</Stack>
|
||||
) : isError ? (
|
||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('list_error')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
) : items.length === 0 ? (
|
||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<AppIcon icon="bookings" size={40} color="var(--bal-text-secondary)" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1 }}>
|
||||
{t('list_empty_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
|
||||
{t('list_empty_body')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
) : (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
{items.map((item) => (
|
||||
<BookingRow key={item.id} item={item} />
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function BookingRow({ item }: { item: BookingListItemDto }) {
|
||||
const t = useTranslations('booking');
|
||||
const tc = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
|
||||
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: 1.5, flexWrap: 'wrap' }}>
|
||||
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{item.counterpartyName}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{formatShamsiDate(item.scheduledDate, locale)} · {t('session_count', { count: item.sessionCount })}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<StatusChip status={BOOKING_STATUS_KIND[item.status]} label={t(`bstatus_${item.status}`)} />
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{t('list_total')}: {formatIrrToToman(item.amountIrr, locale)} {tc('currency_toman')}
|
||||
</Typography>
|
||||
<AppButton
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
endIcon="bookings"
|
||||
onClick={() => router.push(`/${locale}${ROUTES.BOOKINGS}/${item.id}`)}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{t('view_booking')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
'use client';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { BookingDetailView } from '@/components/booking';
|
||||
|
||||
/**
|
||||
* Nurse booking detail (`/nurse/visits/{id}`) — the both-roles view in the **nurse** shell, where the
|
||||
* assigned nurse gets the per-session EVV check-in/out controls and the gated care-instructions card
|
||||
* (two-stage disclosure). Reached from the ویزیت امروز day surface.
|
||||
*/
|
||||
export default function NurseBookingDetailPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const id = Number(params.id);
|
||||
return <BookingDetailView bookingId={Number.isInteger(id) && id > 0 ? id : -1} viewerRole="nurse" />;
|
||||
}
|
||||
@@ -1,8 +1,98 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { PlaceholderScreen } from '@/components';
|
||||
'use client';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon } from '@/components';
|
||||
import { SessionCard, useEvvController } from '@/components/booking';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { useSessionEvv, useTodaySessions } from '@/services/bookings';
|
||||
import type { BookingSessionListItemDto } from '@/services/bookings/types';
|
||||
|
||||
export default async function NurseVisitsPage() {
|
||||
const t = await getTranslations('nav');
|
||||
const tShell = await getTranslations('shell');
|
||||
return <PlaceholderScreen icon="visits" title={t('visits')} description={tShell('placeholder_body')} />;
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export default function NurseVisitsPage() {
|
||||
const t = useTranslations('booking');
|
||||
const { data, isLoading } = useTodaySessions();
|
||||
const evv = useEvvController();
|
||||
const items = data?.items ?? [];
|
||||
|
||||
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>
|
||||
|
||||
{isLoading ? (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
{[0, 1].map((key) => (
|
||||
<Skeleton key={key} variant="rounded" height={150} />
|
||||
))}
|
||||
</Stack>
|
||||
) : items.length === 0 ? (
|
||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<AppIcon icon="visits" size={40} color="var(--bal-text-secondary)" />
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 1 }}>
|
||||
{t('evv_no_visits')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
) : (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
{items.map((item) => (
|
||||
<TodayVisitCard key={item.sessionId} item={item} evv={evv} />
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function TodayVisitCard({ item, evv }: { item: BookingSessionListItemDto; evv: ReturnType<typeof useEvvController> }) {
|
||||
const t = useTranslations('booking');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
|
||||
// Fetch the EVV detail only once the session has activity, so the banner has the server check-in time.
|
||||
const hasEvvActivity = item.evvStatus !== 'pending';
|
||||
const { data: evvDetail } = useSessionEvv(item.sessionId, { enabled: hasEvvActivity });
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 0.75 }}>
|
||||
<SessionCard
|
||||
title={item.patientName}
|
||||
sessionIndex={item.sessionIndex}
|
||||
scheduledDate={item.scheduledDate}
|
||||
scheduledTimeStart={item.scheduledTimeStart}
|
||||
scheduledTimeEnd={item.scheduledTimeEnd}
|
||||
status={item.status}
|
||||
evvStatus={item.evvStatus}
|
||||
checkInAt={evvDetail?.checkInAt ?? null}
|
||||
checkOutAt={evvDetail?.checkOutAt ?? null}
|
||||
checkInAddressMatch={evvDetail?.checkInAddressMatch ?? null}
|
||||
showEvvControls
|
||||
evvPending={evv.busySessionId === item.sessionId}
|
||||
acquiringLocation={evv.acquiringSessionId === item.sessionId}
|
||||
onCheckIn={() => evv.checkIn({ sessionId: item.sessionId, bookingId: item.bookingId })}
|
||||
onCheckOut={() => evv.checkOut({ sessionId: item.sessionId, bookingId: item.bookingId })}
|
||||
/>
|
||||
<AppButton
|
||||
variant="text"
|
||||
color="primary"
|
||||
endIcon="bookings"
|
||||
onClick={() => router.push(`/${locale}${ROUTES.NURSE_VISITS}/${item.bookingId}`)}
|
||||
sx={{ m: 0, alignSelf: 'flex-start' }}
|
||||
>
|
||||
{t('view_booking')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user