backend phase 14 & frontend phase 7

This commit is contained in:
hamid
2026-07-09 15:30:03 +03:30
parent de53f9d8a6
commit 93cc5ecb98
101 changed files with 12930 additions and 39 deletions
@@ -0,0 +1,25 @@
'use client';
import { Suspense } from 'react';
import { useTranslations } from 'next-intl';
import { useSearchParams } from 'next/navigation';
import { AppLoading, PlaceholderScreen } from '@/components';
/**
* Checkout (pay & confirm) — **DEFERRED → frontend-phase-9-b10**. C5's "ادامه پرداخت" hands off here with
* the accepted `request_id`; f9 builds the C6 summary + escrow notice + card/BNPL. This placeholder
* confirms the hand-off arrived so the CTA doesn't dead-end. `useSearchParams` needs a Suspense boundary.
*/
export default function CheckoutPage() {
return (
<Suspense fallback={<AppLoading />}>
<CheckoutDeferred />
</Suspense>
);
}
function CheckoutDeferred() {
const t = useTranslations('booking');
const params = useSearchParams();
const requestId = params.get('request_id') ?? '—';
return <PlaceholderScreen icon="payment" title={t('step_payment')} description={`#${requestId}`} />;
}
@@ -0,0 +1,290 @@
'use client';
import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useParams, useRouter } from 'next/navigation';
import {
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Paper,
Skeleton,
Stack,
Typography,
} from '@mui/material';
import { AppButton, AppIcon, BookingRequestSummaryCard, CountdownTimer, StatusChip, StepperHeader } from '@/components';
import { ROUTES } from '@/constants';
import { useBookingRequest, useCancelBookingRequest } from '@/services/bookingRequests';
import type { BookingRequestDto } from '@/services/bookingRequests/types';
/**
* C5 — Awaiting nurse acceptance (در انتظار تایید پرستار). Keyed by the request id, it **polls** the
* request (`useBookingRequest`, stopping at a terminal status) so the accept / reject / expire transition
* surfaces without a manual refresh. It renders the shared summary card, the 3-step tracker, and a
* countdown driven by the **server-frozen** deadline: the response window while pending, then the 30-min
* payment window once accepted (with the hand-off to checkout). Terminal states show their own card.
*/
export default function BookingRequestStatusPage() {
const t = useTranslations('booking');
const locale = useLocale();
const router = useRouter();
const params = useParams<{ id: string }>();
const id = Number(params.id);
const { data: request, isLoading, isError, refetch } = useBookingRequest(
Number.isInteger(id) && id > 0 ? id : undefined,
'customer',
);
const cancelRequest = useCancelBookingRequest();
const [confirmCancel, setConfirmCancel] = useState(false);
if (isLoading) return <StatusSkeleton />;
if (isError || !request) {
return (
<TerminalCard
icon="error"
tone="var(--bal-error)"
title={t('error_title')}
body={t('error_body')}
ctaLabel={t('retry')}
onCta={() => refetch()}
/>
);
}
const goToSearch = () => router.push(`/${locale}${ROUTES.SEARCH}`);
const addressLabel = customerAddressLabel(request, locale, t('address_whole_city'));
const summary = (
<BookingRequestSummaryCard
nurseName={request.nurseName}
nurseRating={request.nurseRating}
patientName={request.patientName}
variantLabel={request.variantLabel}
variantPrice={request.variantPrice}
variantPriceUnit={request.variantPriceUnit}
addressLabel={addressLabel}
requestedDate={request.requestedDate}
requestedTimeStart={request.requestedTimeStart}
requestedTimeEnd={request.requestedTimeEnd}
/>
);
// Terminal states — each is its own card with a re-request path back into discovery (or booking).
if (request.status === 'rejected_by_nurse') {
return (
<Stack sx={{ gap: 3 }}>
{summary}
<TerminalCard
icon="rejected"
tone="var(--bal-error)"
title={t('rejected_title')}
body={request.nurseRejectionReason ? `${t('rejected_reason_label')}: ${request.nurseRejectionReason}` : undefined}
ctaLabel={t('terminal_rerequest')}
onCta={goToSearch}
/>
</Stack>
);
}
if (request.status === 'expired_no_response') {
return (
<Stack sx={{ gap: 3 }}>
{summary}
<TerminalCard icon="pending" tone="var(--bal-warning)" title={t('expired_title')} ctaLabel={t('terminal_rerequest')} onCta={goToSearch} />
</Stack>
);
}
if (request.status === 'payment_deadline_expired') {
return (
<Stack sx={{ gap: 3 }}>
{summary}
<TerminalCard icon="pending" tone="var(--bal-warning)" title={t('payment_expired_title')} ctaLabel={t('terminal_rerequest')} onCta={goToSearch} />
</Stack>
);
}
if (request.status === 'cancelled_by_customer') {
return (
<Stack sx={{ gap: 3 }}>
{summary}
<TerminalCard icon="rejected" tone="var(--bal-text-secondary)" title={t('cancelled_title')} ctaLabel={t('terminal_rerequest')} onCta={goToSearch} />
</Stack>
);
}
if (request.status === 'converted') {
return (
<Stack sx={{ gap: 3 }}>
{summary}
<TerminalCard
icon="verified"
tone="var(--bal-success)"
title={t('converted_title')}
ctaLabel={t('converted_cta')}
onCta={() => router.push(`/${locale}${ROUTES.BOOKINGS}`)}
/>
</Stack>
);
}
const accepted = request.status === 'accepted_awaiting_payment';
const activeStep = accepted ? 2 : 1;
return (
<Stack sx={{ gap: 3 }}>
<Stack sx={{ gap: 0.5, alignItems: 'center', textAlign: 'center' }}>
<AppIcon icon="pending" size={40} color="var(--bal-primary)" />
<Typography variant="h6" component="h1">
{t('awaiting_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('awaiting_subtitle')}
</Typography>
</Stack>
<StepperHeader
steps={[t('step_submitted'), t('step_awaiting'), t('step_payment')]}
activeStep={activeStep}
/>
{summary}
{accepted ? (
<Paper
elevation={0}
sx={{
p: 2.5,
borderRadius: 2,
border: '1px solid',
borderColor: 'divider',
borderInlineStartWidth: 4,
borderInlineStartColor: 'var(--bal-secondary)',
}}
>
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
<StatusChip status="verified" label={t('accepted_badge')} />
<Typography variant="body2" sx={{ color: 'text.secondary', textAlign: 'center' }}>
{t('accepted_body')}
</Typography>
{request.paymentDeadlineAt ? (
<CountdownTimer
deadlineIso={request.paymentDeadlineAt}
label={t('payment_countdown_label')}
elapsedText={t('payment_elapsed')}
urgent
onElapsed={() => refetch()}
/>
) : null}
<AppButton
color="secondary"
variant="contained"
size="large"
endIcon="payment"
onClick={() => router.push(`/${locale}${ROUTES.CHECKOUT}?request_id=${request.id}`)}
sx={{ m: 0, py: 1.25 }}
>
{t('continue_payment')}
</AppButton>
</Stack>
</Paper>
) : (
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
<CountdownTimer
deadlineIso={request.nurseResponseDeadlineAt}
label={t('response_countdown_label')}
elapsedText={t('response_elapsed')}
onElapsed={() => refetch()}
/>
</Paper>
)}
<AppButton
variant="text"
color="error"
disabled={cancelRequest.isPending}
onClick={() => setConfirmCancel(true)}
sx={{ m: 0, alignSelf: 'center' }}
>
{cancelRequest.isPending ? t('cancelling') : t('cancel_request')}
</AppButton>
<Dialog open={confirmCancel} onClose={() => setConfirmCancel(false)}>
<DialogTitle>{t('cancel_confirm_title')}</DialogTitle>
<DialogContent>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('cancel_confirm_body')}
</Typography>
</DialogContent>
<DialogActions>
<AppButton variant="text" onClick={() => setConfirmCancel(false)}>
{t('cancel_request')}
</AppButton>
<AppButton
color="error"
variant="contained"
onClick={() => {
setConfirmCancel(false);
cancelRequest.mutate(request.id);
}}
>
{t('cancel_confirm_yes')}
</AppButton>
</DialogActions>
</Dialog>
</Stack>
);
}
/** "title · city · district" (or "· whole city"), locale-aware — the customer view carries the full address. */
function customerAddressLabel(request: BookingRequestDto, locale: string, wholeCityLabel: string): string {
const city = locale === 'en' ? request.cityNameEn : request.cityNameFa;
const district =
request.districtId == null ? wholeCityLabel : locale === 'en' ? request.districtNameEn : request.districtNameFa;
return `${request.addressTitle} · ${city} · ${district}`;
}
function TerminalCard({
icon,
tone,
title,
body,
ctaLabel,
onCta,
}: {
icon: string;
tone: string;
title: string;
body?: string;
ctaLabel: string;
onCta: () => void;
}) {
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<AppIcon icon={icon} size={44} color={tone} />
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1, mb: body ? 0.5 : 2 }}>
{title}
</Typography>
{body ? (
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
{body}
</Typography>
) : null}
<AppButton variant="contained" color="primary" onClick={onCta} sx={{ m: 0 }}>
{ctaLabel}
</AppButton>
</Paper>
);
}
function StatusSkeleton() {
return (
<Stack sx={{ gap: 3 }}>
<Stack sx={{ gap: 1, alignItems: 'center' }}>
<Skeleton variant="circular" width={44} height={44} />
<Skeleton variant="text" width="60%" height={28} />
</Stack>
<Skeleton variant="rounded" height={72} />
<Skeleton variant="rounded" height={160} />
<Skeleton variant="rounded" height={96} />
</Stack>
);
}
@@ -1,32 +1,545 @@
'use client';
import { Suspense } from 'react';
import { useSearchParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { AppLoading, PlaceholderScreen } from '@/components';
import { Suspense, useMemo, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation';
import {
Box,
MenuItem,
Paper,
Skeleton,
Stack,
TextField,
ToggleButton,
ToggleButtonGroup,
Typography,
} from '@mui/material';
import { AppButton, AppIcon, AppLoading, PriceDisplay } from '@/components';
import { AddressMapPicker } from '@/components/geography';
import { ROUTES } from '@/constants';
import { ApiError } from '@/lib/api/errors';
import { cityCentroid } from '@/services/geography/constants';
import { usePatients } from '@/services/patients';
import { useAddresses } from '@/services/addresses';
import { useNurseProfile } from '@/services/search';
import { useCreateBookingRequest } from '@/services/bookingRequests';
import { CUSTOMER_NOTES_MAX_LENGTH } from '@/services/bookingRequests/constants';
import type {
BookingRequestDisplayContext,
RequiredCaregiverGender,
} from '@/services/bookingRequests/types';
const GENDER_OPTIONS: RequiredCaregiverGender[] = ['female', 'male', 'any'];
/**
* Booking-request handoff target — **DEFERRED → frontend-phase-7-b8**. C3's "درخواست رزرو" lands here
* carrying the selected nurse + variant + the same-gender intent (`required_gender`, which becomes
* `required_caregiver_gender` in b8) + city/category. f7 builds the actual request form; this placeholder
* confirms the intent arrived so the CTA doesn't dead-end. `useSearchParams` needs a Suspense boundary.
* C4 — Booking-request form (فرم درخواست). The destination of the C3 "درخواست رزرو" CTA (it carries the
* `nurse_id`, an optional `variant_id`, and the same-gender `required_gender` intent from search). The
* family picks a patient (f2), one of the nurse's service variants (f4/search profile), a saved address
* (f3), a future date + time window, the **first-class caregiver-gender** preference, and stage-1 notes,
* then sends the request → lands on C5. Money-free: no price breakdown, no booking row (that's f9/b9).
* `useSearchParams` requires a Suspense boundary.
*/
export default function BookingRequestPage() {
export default function BookingRequestFormPage() {
return (
<Suspense fallback={<AppLoading />}>
<BookingRequestDeferred />
<BookingRequestForm />
</Suspense>
);
}
function BookingRequestDeferred() {
function BookingRequestForm() {
const t = useTranslations('booking');
const params = useSearchParams();
const gender = params.get('required_gender');
const echo = t('handoff_echo', {
nurse: params.get('nurse_id') ?? '—',
variant: params.get('variant_id') ?? '—',
gender: gender ? t(`gender_${gender}`) : t('gender_any'),
});
const tAddress = useTranslations('address');
const locale = useLocale();
const router = useRouter();
const query = useSearchParams();
return <PlaceholderScreen icon="bookings" title={t('request_title')} description={[t('deferred'), echo].join(' ')} />;
const nurseId = Number(query.get('nurse_id'));
const hasNurse = Number.isInteger(nurseId) && nurseId > 0;
const variantIdParam = Number(query.get('variant_id')) || null;
const genderParam = query.get('required_gender');
const profileQuery = useNurseProfile(hasNurse ? nurseId : undefined);
const patientsQuery = usePatients();
const addressesQuery = useAddresses();
const createRequest = useCreateBookingRequest();
const profile = profileQuery.data;
const patients = useMemo(() => patientsQuery.data?.items ?? [], [patientsQuery.data]);
const addresses = useMemo(() => addressesQuery.data?.items ?? [], [addressesQuery.data]);
const services = useMemo(() => profile?.services ?? [], [profile]);
const [patientId, setPatientId] = useState<number | ''>('');
const [variantSel, setVariantSel] = useState<number | ''>(variantIdParam ?? '');
const [addressSel, setAddressSel] = useState<number | ''>('');
const [gender, setGender] = useState<RequiredCaregiverGender | ''>(
genderParam === 'male' || genderParam === 'female' ? genderParam : '',
);
const [date, setDate] = useState('');
const [timeStart, setTimeStart] = useState('09:00');
const [timeEnd, setTimeEnd] = useState('13:00');
const [notes, setNotes] = useState('');
const [attempted, setAttempted] = useState(false);
const [pastDateError, setPastDateError] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
// Effective selection = the user's explicit choice, else a sensible default derived from the loaded
// data. Computed during render (no setState-in-effect): the variant defaults to the carried one / the
// first offered, the address to the primary / first.
const firstVariantId: number | '' = services.length > 0 ? services[0].variantId : '';
const variantId = variantSel !== '' ? variantSel : firstVariantId;
const primaryAddressId: number | '' =
addresses.length > 0 ? (addresses.find((address) => address.isPrimary)?.id ?? addresses[0].id) : '';
const addressId = addressSel !== '' ? addressSel : primaryAddressId;
const selectedVariant = useMemo(
() => services.find((service) => service.variantId === variantId),
[services, variantId],
);
const selectedAddress = useMemo(
() => addresses.find((address) => address.id === addressId),
[addresses, addressId],
);
const selectedPatient = useMemo(
() => patients.find((patient) => patient.id === patientId),
[patients, patientId],
);
// A concrete gender that contradicts the (single) nurse's gender is a same-gender mismatch (400) —
// block it inline before the round-trip; the server re-validates and is authoritative.
const genderMismatch =
gender !== '' && gender !== 'any' && profile != null && gender !== profile.nurseGender;
const requiredChosen =
patientId !== '' && variantId !== '' && addressId !== '' && gender !== '' && date !== '' && timeStart !== '' && timeEnd !== '';
const regionLabel = (): string => {
if (!selectedAddress) return '';
const city = locale === 'en' ? selectedAddress.cityNameEn : selectedAddress.cityNameFa;
const district =
selectedAddress.districtId == null
? t('address_whole_city')
: locale === 'en'
? selectedAddress.districtNameEn
: selectedAddress.districtNameFa;
return `${selectedAddress.title} · ${city} · ${district}`;
};
const handleSubmit = () => {
setAttempted(true);
setFormError(null);
if (!requiredChosen) return;
if (timeEnd <= timeStart) return;
// Future date+time guard (local wall-clock, matching the wire's date + time fields). Evaluated in the
// handler (not render) so the render path stays pure; the result drives the inline date error.
if (Date.parse(`${date}T${timeStart}`) < Date.now()) {
setPastDateError(true);
return;
}
setPastDateError(false);
if (genderMismatch) return;
const context: BookingRequestDisplayContext | undefined =
profile && selectedVariant && selectedAddress && selectedPatient
? {
nurseName: profile.nurseName,
nurseRating: profile.averageRating,
nurseTotalReviews: profile.totalReviews,
patientName: selectedPatient.displayName,
variantLabel: selectedVariant.displayName,
variantPriceUnit: selectedVariant.priceUnit,
variantPrice: selectedVariant.priceIrr,
addressTitle: selectedAddress.title,
cityId: selectedAddress.cityId,
cityNameFa: selectedAddress.cityNameFa,
cityNameEn: selectedAddress.cityNameEn,
districtId: selectedAddress.districtId,
districtNameFa: selectedAddress.districtNameFa,
districtNameEn: selectedAddress.districtNameEn,
addressLine: selectedAddress.addressLine,
postalCode: selectedAddress.postalCode,
recipientName: selectedAddress.recipientName,
recipientPhone: selectedAddress.recipientPhone,
}
: undefined;
createRequest.mutate(
{
payload: {
nurseId,
variantId: variantId as number,
patientId: patientId as number,
customerAddressId: addressId as number,
requestedDate: date,
requestedTimeStart: `${timeStart}:00`,
requestedTimeEnd: `${timeEnd}:00`,
requiredCaregiverGender: gender as RequiredCaregiverGender,
customerNotes: notes.trim() || null,
},
context,
},
{
onSuccess: (dto) => {
router.push(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${dto.id}`);
},
onError: (error) => setFormError(mapCreateError(error, t)),
},
);
};
if (!hasNurse) {
return (
<EmptyState
icon="search"
title={t('missing_nurse_title')}
body={t('missing_nurse_body')}
ctaLabel={t('missing_nurse_cta')}
onCta={() => router.push(`/${locale}${ROUTES.SEARCH}`)}
/>
);
}
if (profileQuery.isLoading) return <FormSkeleton />;
const timeError = attempted && timeStart !== '' && timeEnd !== '' && timeEnd <= timeStart;
const pastError = pastDateError;
return (
<Stack sx={{ gap: 3 }}>
<Box>
<Typography variant="h5" component="h1">
{t('request_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('form_subtitle')}
</Typography>
</Box>
{/* Patient */}
{patients.length === 0 ? (
<FieldEmpty
label={t('patient_label')}
message={t('patient_empty')}
ctaLabel={t('patient_add_cta')}
onCta={() => router.push(`/${locale}${ROUTES.PATIENTS}`)}
/>
) : (
<TextField
select
label={t('patient_label')}
value={patientId}
error={attempted && patientId === ''}
helperText={attempted && patientId === '' ? t('error_patient_required') : undefined}
onChange={(event) => setPatientId(Number(event.target.value))}
fullWidth
>
<MenuItem value="" disabled>
{t('patient_placeholder')}
</MenuItem>
{patients.map((patient) => (
<MenuItem key={patient.id} value={patient.id}>
{patient.displayName}
</MenuItem>
))}
</TextField>
)}
{/* Service variant */}
{services.length === 0 ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('service_empty')}
</Typography>
) : (
<TextField
select
label={t('service_label')}
value={variantId}
error={attempted && variantId === ''}
helperText={attempted && variantId === '' ? t('error_service_required') : undefined}
onChange={(event) => setVariantSel(Number(event.target.value))}
fullWidth
>
<MenuItem value="" disabled>
{t('service_placeholder')}
</MenuItem>
{services.map((service) => (
<MenuItem key={service.variantId} value={service.variantId}>
{service.displayName}
</MenuItem>
))}
</TextField>
)}
{selectedVariant ? (
<Box sx={{ mt: -1.5 }}>
<PriceDisplay
price={selectedVariant.priceIrr}
priceUnit={selectedVariant.priceUnit}
sessionCount={selectedVariant.sessionCount}
/>
</Box>
) : null}
{/* Address */}
{addresses.length === 0 ? (
<FieldEmpty
label={t('address_label')}
message={t('address_empty')}
ctaLabel={t('address_add_cta')}
onCta={() => router.push(`/${locale}${ROUTES.ADDRESSES}`)}
/>
) : (
<Stack sx={{ gap: 1 }}>
<TextField
select
label={t('address_label')}
value={addressId}
error={attempted && addressId === ''}
helperText={attempted && addressId === '' ? t('error_address_required') : undefined}
onChange={(event) => setAddressSel(Number(event.target.value))}
fullWidth
>
<MenuItem value="" disabled>
{t('address_placeholder')}
</MenuItem>
{addresses.map((address) => (
<MenuItem key={address.id} value={address.id}>
{address.title} · {locale === 'en' ? address.cityNameEn : address.cityNameFa}
</MenuItem>
))}
</TextField>
{selectedAddress ? (
<Paper elevation={0} sx={{ p: 1.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 1 }}>
{regionLabel()}
{selectedAddress.addressLine ? `${selectedAddress.addressLine}` : ''}
</Typography>
{selectedAddress.latitude != null && selectedAddress.longitude != null ? (
// Read-only preview of the address's stored pin (the pin itself is set in the f3 book).
<Box sx={{ pointerEvents: 'none' }}>
<AddressMapPicker
value={{ latitude: selectedAddress.latitude, longitude: selectedAddress.longitude }}
onChange={() => undefined}
center={cityCentroid(selectedAddress.cityId)}
helperText={regionLabel()}
latLabel={tAddress('map_lat')}
lngLabel={tAddress('map_lng')}
/>
</Box>
) : null}
</Paper>
) : null}
</Stack>
)}
{/* Date + time */}
<Stack direction={{ xs: 'column', sm: 'row' }} sx={{ gap: 2 }}>
<TextField
type="date"
label={t('date_label')}
value={date}
error={(attempted && date === '') || pastError}
helperText={pastError ? t('error_past_date') : attempted && date === '' ? t('error_date_required') : undefined}
onChange={(event) => {
setDate(event.target.value);
if (pastDateError) setPastDateError(false);
}}
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
<TextField
type="time"
label={t('time_start_label')}
value={timeStart}
onChange={(event) => {
setTimeStart(event.target.value);
if (pastDateError) setPastDateError(false);
}}
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
<TextField
type="time"
label={t('time_end_label')}
value={timeEnd}
error={timeError}
helperText={timeError ? t('error_time_range') : undefined}
onChange={(event) => setTimeEnd(event.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
</Stack>
{/* Caregiver gender — first-class, three-way, never silently defaulted */}
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('gender_label')}
</Typography>
<ToggleButtonGroup
exclusive
color="primary"
value={gender || null}
onChange={(_event, next: RequiredCaregiverGender | null) => {
if (next) setGender(next);
}}
sx={{
'& .MuiToggleButton-root': {
flex: 1,
py: 1.25,
fontWeight: 600,
borderColor: attempted && gender === '' ? 'var(--bal-error)' : undefined,
},
}}
>
{GENDER_OPTIONS.map((option) => (
<ToggleButton key={option} value={option} data-gender={option}>
{t(`gender_${option}`)}
</ToggleButton>
))}
</ToggleButtonGroup>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('gender_hint')}
</Typography>
{attempted && gender === '' ? (
<Typography variant="caption" sx={{ color: 'var(--bal-error)' }}>
{t('error_gender_required')}
</Typography>
) : null}
{genderMismatch ? (
<Typography variant="caption" sx={{ color: 'var(--bal-error)' }}>
{t('error_gender_mismatch')}
</Typography>
) : null}
</Stack>
{/* Stage-1 notes */}
<TextField
label={t('notes_label')}
placeholder={t('notes_placeholder')}
value={notes}
onChange={(event) => setNotes(event.target.value.slice(0, CUSTOMER_NOTES_MAX_LENGTH))}
multiline
minRows={3}
fullWidth
helperText={t('notes_hint')}
/>
<Typography variant="caption" sx={{ color: 'text.secondary', textAlign: 'end', mt: -2 }}>
{t('notes_counter', { count: notes.length, max: CUSTOMER_NOTES_MAX_LENGTH })}
</Typography>
{formError ? (
<Typography variant="body2" sx={{ color: 'var(--bal-error)' }}>
{formError}
</Typography>
) : null}
<AppButton
color="primary"
variant="contained"
size="large"
startIcon="requests"
disabled={!requiredChosen || genderMismatch || createRequest.isPending}
onClick={handleSubmit}
sx={{ m: 0, py: 1.5 }}
>
{createRequest.isPending ? t('submitting') : t('submit')}
</AppButton>
</Stack>
);
}
/** Map a create `ApiError` (domain 400/404 codes) to a translated, user-facing message. */
function mapCreateError(error: unknown, t: (key: string) => string): string {
if (error instanceof ApiError) {
switch (error.code) {
case 'gender_required':
return t('error_gender_required');
case 'invalid_time_range':
return t('error_time_range');
case 'past_date':
return t('error_past_date');
case 'notes_too_long':
return t('error_notes_long');
case 'gender_mismatch':
return t('error_gender_mismatch');
case 'inactive_variant':
case 'not_bookable':
return t('error_not_bookable');
case 'not_found':
return t('error_tenancy');
default:
break;
}
if (error.status === 404) return t('error_tenancy');
}
return t('error_generic');
}
function FieldEmpty({
label,
message,
ctaLabel,
onCta,
}: {
label: string;
message: string;
ctaLabel: string;
onCta: () => void;
}) {
return (
<Stack sx={{ gap: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{label}
</Typography>
<Paper elevation={0} sx={{ p: 2, border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{message}
</Typography>
<AppButton variant="outlined" color="primary" startIcon="add" onClick={onCta} sx={{ m: 0 }}>
{ctaLabel}
</AppButton>
</Stack>
</Paper>
</Stack>
);
}
function EmptyState({
icon,
title,
body,
ctaLabel,
onCta,
}: {
icon: string;
title: string;
body: string;
ctaLabel: string;
onCta: () => void;
}) {
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
<AppIcon icon={icon} size={40} color="var(--bal-text-secondary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1, mb: 0.5 }}>
{title}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
{body}
</Typography>
<AppButton variant="contained" color="primary" onClick={onCta} sx={{ m: 0 }}>
{ctaLabel}
</AppButton>
</Paper>
);
}
function FormSkeleton() {
return (
<Stack sx={{ gap: 2.5 }}>
<Skeleton variant="text" width="50%" height={36} />
{[0, 1, 2, 3].map((key) => (
<Skeleton key={key} variant="rounded" height={56} />
))}
<Skeleton variant="rounded" height={96} />
</Stack>
);
}
@@ -0,0 +1,317 @@
'use client';
import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useParams, useRouter } from 'next/navigation';
import { useSnackbar } from 'notistack';
import {
Box,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Divider,
Paper,
Skeleton,
Stack,
TextField,
Typography,
} from '@mui/material';
import { AppButton, AppIcon, CountdownTimer, PriceDisplay, StatusChip } from '@/components';
import { ROUTES } from '@/constants';
import { ApiError } from '@/lib/api/errors';
import { formatShamsiDate } from '@/utils';
import {
useAcceptBookingRequest,
useBookingRequest,
useRejectBookingRequest,
} from '@/services/bookingRequests';
import { REJECTION_REASON_MAX_LENGTH } from '@/services/bookingRequests/constants';
import { isTerminalBookingRequestStatus, type BookingRequestDto } from '@/services/bookingRequests/types';
/**
* Nurse request detail (نمای پرستار). Renders the request summary and **only `customerNotes`** as the
* clinical context (two-stage disclosure — the address is masked to city/district and no clinical field
* exists pre-accept). A pending request offers accept / reject (with a reason); both invalidate the inbox
* + this detail so the request leaves the pending list and the customer's C5 reflects it. A stale action
* returns `409`, surfaced then refetched.
*/
export default function NurseRequestDetailPage() {
const t = useTranslations('booking');
const locale = useLocale();
const router = useRouter();
const { enqueueSnackbar } = useSnackbar();
const params = useParams<{ id: string }>();
const id = Number(params.id);
const { data: request, isLoading, isError, refetch } = useBookingRequest(
Number.isInteger(id) && id > 0 ? id : undefined,
'nurse',
);
const acceptRequest = useAcceptBookingRequest();
const rejectRequest = useRejectBookingRequest();
const [rejectOpen, setRejectOpen] = useState(false);
const [reason, setReason] = useState('');
const [reasonError, setReasonError] = 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 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1 }}>
{t('not_found_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
{t('not_found_body')}
</Typography>
<AppButton variant="outlined" color="primary" onClick={() => router.push(`/${locale}${ROUTES.NURSE_REQUESTS}`)} sx={{ m: 0 }}>
{t('inbox_title')}
</AppButton>
</Paper>
);
}
const pending = request.status === 'pending_nurse_response';
const isTerminal = isTerminalBookingRequestStatus(request.status);
const location = coarseLocation(request, locale, t('address_whole_city'));
const handleStaleError = (error: unknown) => {
if (error instanceof ApiError && error.status === 409) {
enqueueSnackbar(t('action_stale'), { variant: 'warning' });
refetch();
} else {
enqueueSnackbar(t('error_generic'), { variant: 'error' });
}
};
const handleAccept = () => {
acceptRequest.mutate(request.id, {
onSuccess: () => enqueueSnackbar(t('accepted_toast'), { variant: 'success' }),
onError: handleStaleError,
});
};
const handleReject = () => {
const trimmed = reason.trim();
if (!trimmed) {
setReasonError(true);
return;
}
rejectRequest.mutate(
{ id: request.id, payload: { reason: trimmed } },
{
onSuccess: () => {
setRejectOpen(false);
setReason('');
enqueueSnackbar(t('rejected_toast'), { variant: 'success' });
},
onError: (error) => {
setRejectOpen(false);
handleStaleError(error);
},
},
);
};
const startDate = new Date(`${request.requestedDate}T${request.requestedTimeStart}`);
const endDate = new Date(`${request.requestedDate}T${request.requestedTimeEnd}`);
const timeFmt = new Intl.DateTimeFormat(locale === 'fa' ? 'fa-IR' : 'en-US', { hour: '2-digit', minute: '2-digit' });
const whenLabel = `${formatShamsiDate(startDate, locale)} · ${timeFmt.format(startDate)} ${timeFmt.format(endDate)}`;
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 640 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}>
<Typography variant="h5" component="h1">
{t('detail_title')}
</Typography>
<StatusChip status={statusKind(request.status)} label={t(`status_${request.status}`)} />
</Stack>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1.5 }}>
<DetailRow caption={t('summary_patient')}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{request.patientName}
</Typography>
</DetailRow>
<DetailRow caption={t('summary_service')}>
<Stack sx={{ gap: 0.25, alignItems: 'flex-end' }}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{request.variantLabel}
</Typography>
{request.variantPrice ? (
<PriceDisplay price={request.variantPrice} priceUnit={request.variantPriceUnit} />
) : null}
</Stack>
</DetailRow>
<DetailRow caption={t('location_label')}>
<Typography variant="body2" sx={{ fontWeight: 600, textAlign: 'end' }}>
{location}
</Typography>
</DetailRow>
<DetailRow caption={t('summary_when')}>
<Typography variant="body2" sx={{ fontWeight: 600, textAlign: 'end' }}>
{whenLabel}
</Typography>
</DetailRow>
{request.requiredCaregiverGender ? (
<DetailRow caption={t('gender_label')}>
<Chip
size="small"
label={t(`gender_${request.requiredCaregiverGender}`)}
sx={{ bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 600 }}
/>
</DetailRow>
) : null}
</Stack>
</Paper>
{/* Stage-1 clinical context — ONLY the family's notes, never a clinical/care field. */}
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('inbox_notes_label')}
</Typography>
<Typography variant="body2" sx={{ color: request.customerNotes ? 'text.primary' : 'text.secondary' }}>
{request.customerNotes || '—'}
</Typography>
<Divider sx={{ my: 0.5 }} />
<Stack direction="row" sx={{ gap: 1, alignItems: 'flex-start' }}>
<AppIcon icon="info" size={18} color="var(--bal-info)" />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('disclosure_note')}
</Typography>
</Stack>
</Stack>
</Paper>
{pending ? (
<Stack sx={{ gap: 1.5 }}>
<CountdownTimer
deadlineIso={request.nurseResponseDeadlineAt}
label={t('response_countdown_label')}
elapsedText={t('response_elapsed')}
onElapsed={() => refetch()}
/>
<Stack direction="row" sx={{ gap: 1 }}>
<AppButton
color="primary"
variant="contained"
startIcon="verified"
disabled={acceptRequest.isPending}
onClick={handleAccept}
sx={{ m: 0, flex: 1, py: 1.25 }}
>
{acceptRequest.isPending ? t('accepting') : t('accept')}
</AppButton>
<AppButton
color="error"
variant="outlined"
startIcon="rejected"
disabled={acceptRequest.isPending}
onClick={() => setRejectOpen(true)}
sx={{ m: 0, flex: 1, py: 1.25 }}
>
{t('reject')}
</AppButton>
</Stack>
</Stack>
) : (
<Paper
elevation={0}
sx={{
p: 2,
borderRadius: 2,
border: '1px solid',
borderColor: 'divider',
borderInlineStartWidth: 4,
borderInlineStartColor: isTerminal ? 'var(--bal-text-secondary)' : 'var(--bal-secondary)',
}}
>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t(`status_${request.status}`)}
</Typography>
</Paper>
)}
<Dialog open={rejectOpen} onClose={() => setRejectOpen(false)} fullWidth maxWidth="xs">
<DialogTitle>{t('reject_dialog_title')}</DialogTitle>
<DialogContent>
<TextField
autoFocus
label={t('reject_reason_label')}
placeholder={t('reject_reason_placeholder')}
value={reason}
onChange={(event) => {
setReason(event.target.value.slice(0, REJECTION_REASON_MAX_LENGTH));
if (reasonError) setReasonError(false);
}}
error={reasonError}
helperText={reasonError ? t('reason_required') : undefined}
multiline
minRows={2}
fullWidth
sx={{ mt: 1 }}
/>
</DialogContent>
<DialogActions>
<AppButton variant="text" onClick={() => setRejectOpen(false)}>
{t('cancel_request')}
</AppButton>
<AppButton color="error" variant="contained" disabled={rejectRequest.isPending} onClick={handleReject}>
{rejectRequest.isPending ? t('rejecting') : t('reject_submit')}
</AppButton>
</DialogActions>
</Dialog>
</Box>
);
}
/** Coarse, masked location (city · district) — the nurse view never receives the full address. */
function coarseLocation(request: BookingRequestDto, locale: string, wholeCityLabel: string): string {
const city = locale === 'en' ? request.cityNameEn : request.cityNameFa;
const district =
request.districtId == null ? wholeCityLabel : locale === 'en' ? request.districtNameEn : request.districtNameFa;
return `${city} · ${district}`;
}
function statusKind(status: BookingRequestDto['status']) {
switch (status) {
case 'accepted_awaiting_payment':
return 'active' as const;
case 'converted':
return 'verified' as const;
case 'rejected_by_nurse':
case 'payment_deadline_expired':
return 'rejected' as const;
case 'expired_no_response':
case 'cancelled_by_customer':
return 'neutral' as const;
default:
return 'pending' as const;
}
}
function DetailRow({ caption, children }: { caption: string; children: React.ReactNode }) {
return (
<Stack direction="row" sx={{ gap: 2, justifyContent: 'space-between', alignItems: 'flex-start' }}>
<Typography variant="body2" sx={{ color: 'text.secondary', flexShrink: 0 }}>
{caption}
</Typography>
{children}
</Stack>
);
}
function DetailSkeleton() {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 640 }}>
<Skeleton variant="text" width="40%" height={36} />
<Skeleton variant="rounded" height={180} />
<Skeleton variant="rounded" height={120} />
<Skeleton variant="rounded" height={56} />
</Box>
);
}
@@ -0,0 +1,115 @@
'use client';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { Box, Chip, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, CountdownTimer } from '@/components';
import { ROUTES } from '@/constants';
import { formatShamsiDate } from '@/utils';
import { useNurseRequestInbox } from '@/services/bookingRequests';
import type { BookingRequestListItem } from '@/services/bookingRequests/types';
/**
* 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.
*/
export default function NurseRequestsPage() {
const t = useTranslations('booking');
const { data, isLoading } = useNurseRequestInbox();
const items = data?.items ?? [];
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>
{isLoading ? (
<Stack sx={{ gap: 2 }}>
{[0, 1].map((key) => (
<Skeleton key={key} variant="rounded" height={140} />
))}
</Stack>
) : items.length === 0 ? (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
<AppIcon icon="requests" size={40} color="var(--bal-text-secondary)" />
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 1 }}>
{t('inbox_empty')}
</Typography>
</Paper>
) : (
<Stack sx={{ gap: 2 }}>
{items.map((item) => (
<InboxCard key={item.id} item={item} />
))}
</Stack>
)}
</Box>
);
}
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(locale === 'fa' ? 'fa-IR' : 'en-US', { hour: '2-digit', minute: '2-digit' });
const whenLabel = `${formatShamsiDate(startDate, locale)} · ${timeFmt.format(startDate)} ${timeFmt.format(endDate)}`;
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>
</Stack>
<CountdownTimer deadlineIso={item.nurseResponseDeadlineAt} elapsedText={t('response_elapsed')} />
</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: 600 }}
/>
</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={{ m: 0, alignSelf: 'flex-start' }}
>
{t('open_detail')}
</AppButton>
</Stack>
</Paper>
);
}