backend phase 14 & frontend phase 7
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user