manual improvement 2 & add telegram bot

This commit is contained in:
hamid
2026-07-27 23:58:16 +03:30
parent baa3cc63cd
commit e6a8f93a1e
57 changed files with 4181 additions and 2484 deletions
@@ -2,11 +2,13 @@
import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { Checkbox, FormControlLabel, MenuItem, Paper, Stack, TextField, Typography } from '@mui/material';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { Checkbox, FormControlLabel, MenuItem, Paper, Stack, Typography } from '@mui/material';
import AppButton from '@/components/common/AppButton';
import AppAlert from '@/components/common/AppAlert';
import AppLoading from '@/components/common/AppLoading';
import Money from '@/components/common/Money';
import { RhfControlGroup, RhfTextField } from '@/components/common/form';
import StepperHeader from '@/components/StepperHeader';
import CancellationPolicyDisclosure from '@/components/CancellationPolicyDisclosure';
import { ContactSupportDialog } from '@/components/messaging';
@@ -34,6 +36,12 @@ function cancelErrorKey(error: unknown): string {
return 'err_generic';
}
interface CancelFormValues {
reasonCategory: CancelReasonCategory | '';
reasonNotes: string;
acknowledged: boolean;
}
/**
* Cancellation flow (f10) — the trust-first exit. Step 1 **discloses** the resolved policy tier, the
* refund % + fee %, and the concrete Toman amounts (refunded vs kept) **before** anything is submitted;
@@ -56,11 +64,16 @@ export default function CancelBookingPage() {
const cancel = useCancelBooking();
const [step, setStep] = useState<0 | 1>(0);
const [acknowledged, setAcknowledged] = useState(false);
// Never pre-defaulted (keeps the reason analytics honest) — confirm stays disabled until chosen.
const [reasonCategory, setReasonCategory] = useState<CancelReasonCategory | ''>('');
const [reasonNotes, setReasonNotes] = useState('');
const [supportDialogCategory, setSupportDialogCategory] = useState<TicketCategory | null>(null);
// `reasonCategory` is never pre-defaulted (that would make the reason analytics lie) — the continue
// CTA stays disabled until it and the acknowledgement are both set.
const form = useForm<CancelFormValues>({
mode: 'onTouched',
defaultValues: { reasonCategory: '', reasonNotes: '', acknowledged: false },
});
const { control, getValues } = form;
const reasonCategory = useWatch({ control, name: 'reasonCategory' });
const acknowledged = useWatch({ control, name: 'acknowledged' });
const bookingHref = `/${locale}${ROUTES.BOOKINGS}/${bookingId}`;
@@ -108,19 +121,22 @@ export default function CancelBookingPage() {
);
}
const submit = () =>
const submit = () => {
const values = getValues();
cancel.mutate(
{
bookingId,
sessionIds: preview.refundableSessionIds,
// Guaranteed non-empty: step 1 is only reachable once a reason is chosen (the continue CTA gate).
reasonCategory: reasonCategory as CancelReasonCategory,
reasonNotes: reasonNotes.trim() || undefined,
reasonCategory: values.reasonCategory as CancelReasonCategory,
reasonNotes: values.reasonNotes.trim() || undefined,
},
{ onSuccess: () => router.push(`/${locale}${bookingRefundStatusPath(bookingId)}`) },
);
};
return (
<FormProvider {...form}>
<Stack sx={{ gap: 3, maxWidth: 640, mx: 'auto', width: '100%', py: 2 }}>
<Typography variant="h5" component="h1" sx={{ fontWeight: 800 }}>
{t('cancel_title')}
@@ -158,13 +174,7 @@ export default function CancelBookingPage() {
<CancellationPolicyDisclosure preview={preview} />
<TextField
select
label={t('reason_field_label')}
value={reasonCategory}
onChange={(event) => setReasonCategory(event.target.value as CancelReasonCategory)}
fullWidth
>
<RhfTextField<CancelFormValues> name="reasonCategory" select label={t('reason_field_label')} fullWidth>
<MenuItem value="" disabled>
{t('reason_placeholder')}
</MenuItem>
@@ -173,19 +183,24 @@ export default function CancelBookingPage() {
{t(`reason_cat_${category}`)}
</MenuItem>
))}
</TextField>
<TextField
</RhfTextField>
<RhfTextField<CancelFormValues>
name="reasonNotes"
label={t('reason_notes_label')}
value={reasonNotes}
onChange={(event) => setReasonNotes(event.target.value)}
multiline
minRows={2}
fullWidth
/>
<FormControlLabel
control={<Checkbox checked={acknowledged} onChange={(event) => setAcknowledged(event.target.checked)} />}
label={t('acknowledge_label')}
/>
<RhfControlGroup<CancelFormValues> name="acknowledged">
{({ field }) => (
<FormControlLabel
control={
<Checkbox checked={Boolean(field.value)} onChange={(event) => field.onChange(event.target.checked)} />
}
label={t('acknowledge_label')}
/>
)}
</RhfControlGroup>
<Stack direction="row" sx={{ gap: 1, justifyContent: 'space-between', flexWrap: 'wrap' }}>
<AppButton variant="text" color="inherit" onClick={() => router.push(bookingHref)}>
@@ -252,5 +267,6 @@ export default function CancelBookingPage() {
</>
)}
</Stack>
</FormProvider>
);
}
@@ -1,10 +1,20 @@
'use client';
import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useParams, useRouter } from 'next/navigation';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { useSnackbar } from 'notistack';
import { Avatar, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material';
import { AppButton, AppIcon, EmptyState, RatingInput, ReviewTagSelector, StatusChip, SurfaceCard } from '@/components';
import { Avatar, Paper, Skeleton, Stack, Typography } from '@mui/material';
import {
AppButton,
AppIcon,
EmptyState,
RatingInput,
ReviewTagSelector,
RhfControlGroup,
RhfTextField,
StatusChip,
SurfaceCard,
} from '@/components';
import type { StatusKind } from '@/components';
import { formatShamsiDate } from '@/utils';
import { useBookingDetail } from '@/services/bookings';
@@ -24,6 +34,12 @@ function variantName(snapshotJson: string): string | null {
const REVIEW_BODY_MAX = 2000;
interface ReviewFormValues {
rating: number;
body: string;
tagCodes: string[];
}
/** moderationStatus → StatusChip kind (published=success, pending=warning, rejected=error, hidden=neutral). */
const STATUS_KIND: Record<ModerationStatus, StatusKind> = {
pending_moderation: 'pending',
@@ -57,19 +73,19 @@ export default function LeaveReviewPage() {
const myReview = useMyReviewForBooking(bookingId, { enabled: reviewable });
const createReview = useCreateReview();
const [rating, setRating] = useState(0);
const [body, setBody] = useState('');
const [tagCodes, setTagCodes] = useState<string[]>([]);
const form = useForm<ReviewFormValues>({ mode: 'onTouched', defaultValues: { rating: 0, body: '', tagCodes: [] } });
const { control, handleSubmit } = form;
const rating = useWatch({ control, name: 'rating' });
const body = useWatch({ control, name: 'body' });
const tagCodes = useWatch({ control, name: 'tagCodes' });
const nurseName = booking?.nurseName?.trim();
const submit = () => {
if (rating < 1) return;
const submit = (values: ReviewFormValues) =>
createReview.mutate(
{ bookingId, body: { rating, body: body.trim() || null, tagCodes } },
{ bookingId, body: { rating: values.rating, body: values.body.trim() || null, tagCodes: values.tagCodes } },
{ onError: () => enqueueSnackbar(t('error_submit'), { variant: 'error' }) },
);
};
// ── Already reviewed → the persistent under-review / published state (never a second form) ───────────────
const existing = myReview.data;
@@ -144,7 +160,8 @@ export default function LeaveReviewPage() {
// ── Eligible → the review form ───────────────────────────────────────────────────────────────────────────
return (
<Stack sx={{ gap: 3, maxWidth: 560, mx: 'auto', width: '100%' }}>
<FormProvider {...form}>
<Stack component="form" noValidate onSubmit={handleSubmit(submit)} sx={{ gap: 3, maxWidth: 560, mx: 'auto', width: '100%' }}>
<PageHeading title={t('title')} subtitle={nurseName ? t('for_nurse', { name: nurseName }) : t('subtitle')} />
{booking ? <ReviewContextRecap booking={booking} locale={locale} /> : null}
@@ -156,44 +173,46 @@ export default function LeaveReviewPage() {
</Typography>
</Stack>
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('rating_label')}
</Typography>
<RatingInput value={rating} onChange={setRating} ariaLabel={t('rating_label')} />
</Stack>
<RhfControlGroup<ReviewFormValues>
name="rating"
label={t('rating_label')}
rules={{ validate: (value) => Number(value ?? 0) >= 1 }}
>
{({ field }) => (
<RatingInput value={Number(field.value) || 0} onChange={field.onChange} ariaLabel={t('rating_label')} />
)}
</RhfControlGroup>
<TextField
<RhfTextField<ReviewFormValues>
name="body"
label={t('body_label')}
placeholder={t('body_placeholder')}
value={body}
onChange={(e) => setBody(e.target.value.slice(0, REVIEW_BODY_MAX))}
transform={(raw) => raw.slice(0, REVIEW_BODY_MAX)}
multiline
minRows={3}
fullWidth
/>
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('tags_label')}
</Typography>
<ReviewTagSelector
codes={REVIEW_TAG_CODES}
selected={tagCodes}
onChange={setTagCodes}
labelFor={(code) => (t.has(`tag_${code}`) ? t(`tag_${code}`) : code)}
disabled={createReview.isPending}
/>
</Stack>
<RhfControlGroup<ReviewFormValues> name="tagCodes" label={t('tags_label')}>
{({ field }) => (
<ReviewTagSelector
codes={REVIEW_TAG_CODES}
selected={(field.value as string[]) ?? []}
onChange={field.onChange}
labelFor={(code) => (t.has(`tag_${code}`) ? t(`tag_${code}`) : code)}
disabled={createReview.isPending}
/>
)}
</RhfControlGroup>
<Stack direction="row" sx={{ gap: 1, justifyContent: 'flex-end' }}>
<AppButton variant="text" color="primary" onClick={() => router.back()} disabled={createReview.isPending}>
{tc('cancel')}
</AppButton>
<AppButton
type="submit"
variant="contained"
color="primary"
onClick={submit}
disabled={rating < 1 || createReview.isPending}
startIcon="star"
>
@@ -201,6 +220,7 @@ export default function LeaveReviewPage() {
</AppButton>
</Stack>
</Stack>
</FormProvider>
);
}
@@ -1,13 +1,19 @@
'use client';
import { FunctionComponent, useState } from 'react';
import { FunctionComponent } from 'react';
import { useTranslations } from 'next-intl';
import { Checkbox, CircularProgress, FormControlLabel, Paper, Stack, TextField, Typography } from '@mui/material';
import { AppButton, AppIcon, Money, PhoneNumberField } from '@/components';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { Checkbox, CircularProgress, FormControlLabel, Paper, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, Money, PhoneNumberField, RhfControlGroup, RhfTextField } from '@/components';
import { digitsOnly } from '@/utils';
import { useCheckEligibility } from '@/services/bnpl';
import { NATIONAL_ID_LENGTH, NATIONAL_ID_PATTERN } from '@/services/bnpl/constants';
import type { BnplEligibilityResult, ProviderCode } from '@/services/bnpl/types';
interface EligibilityFormValues {
nationalId: string;
consent: boolean;
}
interface EligibilityStepProps {
bookingRequestId: number;
providerCode: ProviderCode;
@@ -36,22 +42,23 @@ const EligibilityStep: FunctionComponent<EligibilityStepProps> = ({
const t = useTranslations('bnpl');
const tc = useTranslations('common');
const [nationalId, setNationalId] = useState('');
const [consent, setConsent] = useState(false);
const [submitted, setSubmitted] = useState(false);
const form = useForm<EligibilityFormValues>({ mode: 'onTouched', defaultValues: { nationalId: '', consent: false } });
const { control, handleSubmit } = form;
const consent = useWatch({ control, name: 'consent' });
const check = useCheckEligibility();
// A fresh check wins; otherwise re-show a prior approval carried back from D4.
const result = check.data ?? initialResult ?? undefined;
const nationalIdValid = NATIONAL_ID_PATTERN.test(nationalId);
const nationalIdError = submitted && !nationalIdValid;
const providerName = t(`provider_${providerCode}`);
const handleSubmit = () => {
setSubmitted(true);
if (!nationalIdValid || !consent) return;
check.mutate({ bookingRequestId, providerCode, nationalId, mobile: sessionMobile, consent });
};
const submit = (values: EligibilityFormValues) =>
check.mutate({
bookingRequestId,
providerCode,
nationalId: values.nationalId,
mobile: sessionMobile,
consent: values.consent,
});
// Approved — show the ceiling + advance.
if (result?.isEligible) {
@@ -111,63 +118,74 @@ const EligibilityStep: FunctionComponent<EligibilityStepProps> = ({
body={t('eligibility_error')}
cardLabel={t('pay_with_card')}
onPayWithCard={onPayWithCard}
onRetry={handleSubmit}
onRetry={handleSubmit(submit)}
retryLabel={tc('retry')}
/>
);
}
return (
<Stack sx={{ gap: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('eligibility_title')}
</Typography>
<FormProvider {...form}>
<Stack component="form" noValidate onSubmit={handleSubmit(submit)} sx={{ gap: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('eligibility_title')}
</Typography>
<TextField
label={t('national_id_label')}
placeholder={t('national_id_placeholder')}
value={nationalId}
onChange={(e) => setNationalId(digitsOnly(e.target.value).slice(0, NATIONAL_ID_LENGTH))}
error={nationalIdError}
helperText={nationalIdError ? t('national_id_invalid') : undefined}
slotProps={{ htmlInput: { dir: 'ltr', inputMode: 'numeric', maxLength: NATIONAL_ID_LENGTH, style: { textAlign: 'start' } } }}
fullWidth
/>
<RhfTextField<EligibilityFormValues>
name="nationalId"
label={t('national_id_label')}
placeholder={t('national_id_placeholder')}
transform={(raw) => digitsOnly(raw).slice(0, NATIONAL_ID_LENGTH)}
rules={{ validate: (value) => NATIONAL_ID_PATTERN.test(String(value ?? '')) || t('national_id_invalid') }}
slotProps={{ htmlInput: { dir: 'ltr', inputMode: 'numeric', maxLength: NATIONAL_ID_LENGTH, style: { textAlign: 'start' } } }}
fullWidth
/>
<PhoneNumberField
label={t('mobile_label')}
value={sessionMobile}
onChange={() => undefined}
slotProps={{ input: { readOnly: true } }}
fullWidth
/>
<PhoneNumberField
label={t('mobile_label')}
value={sessionMobile}
onChange={() => undefined}
slotProps={{ input: { readOnly: true } }}
fullWidth
/>
<FormControlLabel
control={<Checkbox checked={consent} onChange={(e) => setConsent(e.target.checked)} color="secondary" />}
label={
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('consent_label', { provider: providerName })}
</Typography>
}
sx={{ alignItems: 'flex-start', m: 0 }}
/>
<RhfControlGroup<EligibilityFormValues> name="consent">
{({ field }) => (
<FormControlLabel
control={
<Checkbox
checked={Boolean(field.value)}
onChange={(event) => field.onChange(event.target.checked)}
color="secondary"
/>
}
label={
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('consent_label', { provider: providerName })}
</Typography>
}
sx={{ alignItems: 'flex-start', m: 0 }}
/>
)}
</RhfControlGroup>
<Stack sx={{ gap: 1 }}>
<AppButton
color="secondary"
variant="contained"
size="large"
disabled={!consent || check.isPending}
onClick={handleSubmit}
startIcon={check.isPending ? <CircularProgress size={18} color="inherit" /> : undefined}
>
{check.isPending ? t('checking_eligibility') : t('check_eligibility')}
</AppButton>
<AppButton variant="text" color="primary" onClick={onPayWithCard}>
{t('pay_with_card')}
</AppButton>
<Stack sx={{ gap: 1 }}>
<AppButton
type="submit"
color="secondary"
variant="contained"
size="large"
disabled={!consent || check.isPending}
startIcon={check.isPending ? <CircularProgress size={18} color="inherit" /> : undefined}
>
{check.isPending ? t('checking_eligibility') : t('check_eligibility')}
</AppButton>
<AppButton variant="text" color="primary" onClick={onPayWithCard}>
{t('pay_with_card')}
</AppButton>
</Stack>
</Stack>
</Stack>
</FormProvider>
);
};
@@ -2,6 +2,7 @@
import { Suspense, useMemo, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import {
Avatar,
Box,
@@ -21,6 +22,8 @@ import {
EmptyState,
JalaliDateIntentPicker,
PriceDisplay,
RhfControlGroup,
RhfTextField,
StepperHeader,
TrustBadge,
} from '@/components';
@@ -39,6 +42,7 @@ import type {
RequiredCaregiverGender,
} from '@/services/bookingRequests/types';
import type { CustomerAddress } from '@/services/addresses/types';
import type { Patient } from '@/services/patients/types';
const GENDER_OPTIONS: RequiredCaregiverGender[] = ['female', 'male', 'any'];
@@ -54,7 +58,17 @@ const TIME_WINDOWS: TimeWindowOption[] = [
{ key: 'evening', start: '16:00', end: '20:00' },
];
type TouchedField = 'patient' | 'service' | 'address' | 'date' | 'time' | 'gender';
interface RequestFormValues {
patientId: number | '';
variantId: number | '';
addressId: number | '';
gender: RequiredCaregiverGender | '';
date: string;
window: TimeWindowOption['key'] | 'custom' | null;
timeStart: string;
timeEnd: string;
notes: string;
}
/**
* C4 — Booking-request form (فرم درخواست). The destination of the C3 "درخواست رزرو" CTA (it carries the
@@ -72,6 +86,16 @@ export default function BookingRequestFormPage() {
);
}
/**
* Resolves the URL hand-off and waits for every list the form defaults off before mounting it.
*
* The wait is load-bearing rather than cosmetic: the variant and address fields default to "the one
* carried in the URL, else the nurse's first service / the primary address", and those defaults can
* only be computed once the lists exist. Previously the form mounted immediately and re-derived the
* effective value on every render (`variantSel !== '' ? variantSel : firstVariantId`), which meant the
* *stored* value and the *shown* value could disagree, and neither field could carry a plain required
* rule. Mounting once with real `defaultValues` makes the stored value the only value.
*/
function BookingRequestForm() {
const t = useTranslations('booking');
const locale = useLocale();
@@ -90,68 +114,115 @@ function BookingRequestForm() {
const profileQuery = useNurseProfile(hasNurse ? nurseId : undefined);
const patientsQuery = usePatients();
const addressesQuery = useAddresses();
if (!hasNurse) {
return (
<EmptyState
icon="search"
title={t('missing_nurse_title')}
body={t('missing_nurse_body')}
action={
<AppButton
variant="contained"
color="primary"
onClick={() => router.push(`/${locale}${ROUTES.SEARCH}`)}
sx={{ m: 0 }}
>
{t('missing_nurse_cta')}
</AppButton>
}
/>
);
}
if (profileQuery.isLoading || patientsQuery.isLoading || addressesQuery.isLoading) return <FormSkeleton />;
return (
<RequestForm
nurseId={nurseId}
profile={profileQuery.data}
patients={patientsQuery.data?.items ?? []}
addresses={addressesQuery.data?.items ?? []}
carried={{
variantId: variantIdParam,
patientId: patientIdParam,
addressId: addressIdParam,
gender: genderParam === 'male' || genderParam === 'female' ? genderParam : null,
}}
/>
);
}
function RequestForm({
nurseId,
profile,
patients,
addresses,
carried,
}: {
nurseId: number;
profile: NurseProfile | undefined;
patients: Patient[];
addresses: CustomerAddress[];
carried: {
variantId: number | null;
patientId: number | null;
addressId: number | null;
gender: RequiredCaregiverGender | null;
};
}) {
const t = useTranslations('booking');
const locale = useLocale();
const router = useRouter();
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 | ''>(patientIdParam ?? '');
const [variantSel, setVariantSel] = useState<number | ''>(variantIdParam ?? '');
const [addressSel, setAddressSel] = useState<number | ''>(addressIdParam ?? '');
const [addressEditing, setAddressEditing] = useState(false);
const [gender, setGender] = useState<RequiredCaregiverGender | ''>(
genderParam === 'male' || genderParam === 'female' ? genderParam : '',
);
const [date, setDate] = useState('');
const [windowSel, setWindowSel] = useState<TimeWindowOption['key'] | 'custom' | null>(null);
const [timeStart, setTimeStart] = useState('');
const [timeEnd, setTimeEnd] = useState('');
const [notes, setNotes] = useState('');
const [touched, setTouched] = useState<Record<TouchedField, boolean>>({
patient: false,
service: false,
address: false,
date: false,
time: false,
gender: false,
});
const [pastDateError, setPastDateError] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const markTouched = (field: TouchedField) =>
setTouched((prev) => (prev[field] ? prev : { ...prev, [field]: true }));
// 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],
);
const form = useForm<RequestFormValues>({
mode: 'onTouched',
defaultValues: {
patientId: carried.patientId ?? '',
variantId: carried.variantId ?? (services.length > 0 ? services[0].variantId : ''),
addressId: carried.addressId ?? primaryAddressId,
gender: carried.gender ?? '',
date: '',
window: null,
timeStart: '',
timeEnd: '',
notes: '',
},
});
const { control, handleSubmit, setValue, getValues } = form;
const values = useWatch({ control });
const patientId = values.patientId ?? '';
const variantId = values.variantId ?? '';
const addressId = values.addressId ?? '';
const gender = values.gender ?? '';
const notes = values.notes ?? '';
const windowSel = values.window ?? null;
const selectedVariant = services.find((service) => service.variantId === variantId);
const selectedAddress = addresses.find((address) => address.id === addressId);
const selectedPatient = patients.find((patient) => patient.id === 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 genderMismatch = gender !== '' && gender !== 'any' && profile != null && gender !== profile.nurseGender;
const requiredChosen =
patientId !== '' && variantId !== '' && addressId !== '' && gender !== '' && date !== '' && timeStart !== '' && timeEnd !== '';
const missingFieldLabels: string[] = [];
if (patientId === '') missingFieldLabels.push(t('cta_missing_patient'));
if (variantId === '') missingFieldLabels.push(t('cta_missing_service'));
if (addressId === '') missingFieldLabels.push(t('cta_missing_address'));
if (values.date === '') missingFieldLabels.push(t('cta_missing_date'));
if (values.timeStart === '' || values.timeEnd === '') missingFieldLabels.push(t('cta_missing_time'));
if (gender === '') missingFieldLabels.push(t('cta_missing_gender'));
const requiredChosen = missingFieldLabels.length === 0;
const regionLabel = (address: CustomerAddress): string => {
const city = locale === 'en' ? address.cityNameEn : address.cityNameFa;
@@ -165,31 +236,15 @@ function BookingRequestForm() {
};
const selectWindow = (option: TimeWindowOption) => {
setWindowSel(option.key);
setTimeStart(option.start);
setTimeEnd(option.end);
if (pastDateError) setPastDateError(false);
setValue('window', option.key, { shouldDirty: true });
setValue('timeStart', option.start, { shouldValidate: true });
setValue('timeEnd', option.end, { shouldValidate: true });
// The date's past-guard is a cross-field rule over the start time — re-run it now that one exists.
if (getValues('date')) void form.trigger('date');
};
const missingFieldLabels: string[] = [];
if (patientId === '') missingFieldLabels.push(t('cta_missing_patient'));
if (variantId === '') missingFieldLabels.push(t('cta_missing_service'));
if (addressId === '') missingFieldLabels.push(t('cta_missing_address'));
if (date === '') missingFieldLabels.push(t('cta_missing_date'));
if (timeStart === '' || timeEnd === '') missingFieldLabels.push(t('cta_missing_time'));
if (gender === '') missingFieldLabels.push(t('cta_missing_gender'));
const handleSubmit = () => {
const submit = (formValues: RequestFormValues) => {
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 =
@@ -220,363 +275,321 @@ function BookingRequestForm() {
{
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,
variantId: formValues.variantId as number,
patientId: formValues.patientId as number,
customerAddressId: formValues.addressId as number,
requestedDate: formValues.date,
requestedTimeStart: `${formValues.timeStart}:00`,
requestedTimeEnd: `${formValues.timeEnd}:00`,
requiredCaregiverGender: formValues.gender as RequiredCaregiverGender,
customerNotes: formValues.notes.trim() || null,
},
context,
},
{
onSuccess: (dto) => {
router.push(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${dto.id}`);
},
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')}
action={
<AppButton
variant="contained"
color="primary"
onClick={() => router.push(`/${locale}${ROUTES.SEARCH}`)}
sx={{ m: 0 }}
>
{t('missing_nurse_cta')}
</AppButton>
}
/>
);
}
if (profileQuery.isLoading) return <FormSkeleton />;
const timeError = touched.time && timeStart !== '' && timeEnd !== '' && timeEnd <= timeStart;
const pastError = pastDateError;
return (
<Stack sx={{ gap: 3 }}>
{profile ? <NurseIdentityBar profile={profile} /> : null}
<FormProvider {...form}>
<Stack component="form" noValidate onSubmit={handleSubmit(submit)} sx={{ gap: 3 }}>
{profile ? <NurseIdentityBar profile={profile} /> : null}
<Box>
<Typography variant="h5" component="h1">
{t('request_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('form_subtitle')}
</Typography>
</Box>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5 }}>
{t('whathappens_title')}
</Typography>
<StepperHeader steps={[t('step_submitted'), t('step_awaiting'), t('step_payment')]} activeStep={0} />
</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={touched.patient && patientId === ''}
helperText={touched.patient && patientId === '' ? t('error_patient_required') : undefined}
onChange={(event) => setPatientId(Number(event.target.value))}
onBlur={() => markTouched('patient')}
fullWidth
>
<MenuItem value="" disabled>
{t('patient_placeholder')}
</MenuItem>
{patients.map((patient) => (
<MenuItem key={patient.id} value={patient.id}>
{patient.displayName}
</MenuItem>
))}
</TextField>
)}
{/* Service variant */}
<Stack sx={{ gap: 1 }}>
{services.length === 0 ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('service_empty')}
<Box>
<Typography variant="h5" component="h1">
{t('request_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('form_subtitle')}
</Typography>
</Box>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5 }}>
{t('whathappens_title')}
</Typography>
<StepperHeader steps={[t('step_submitted'), t('step_awaiting'), t('step_payment')]} activeStep={0} />
</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
<RhfTextField<RequestFormValues>
name="patientId"
select
label={t('service_label')}
value={variantId}
error={touched.service && variantId === ''}
helperText={touched.service && variantId === '' ? t('error_service_required') : undefined}
onChange={(event) => setVariantSel(Number(event.target.value))}
onBlur={() => markTouched('service')}
label={t('patient_label')}
rules={{ validate: (value) => value !== '' || t('error_patient_required') }}
fullWidth
>
<MenuItem value="" disabled>
{t('service_placeholder')}
{t('patient_placeholder')}
</MenuItem>
{services.map((service) => (
<MenuItem key={service.variantId} value={service.variantId}>
{service.displayName}
{patients.map((patient) => (
<MenuItem key={patient.id} value={patient.id}>
{patient.displayName}
</MenuItem>
))}
</TextField>
</RhfTextField>
)}
{selectedVariant ? (
<PriceDisplay
price={selectedVariant.priceIrr}
priceUnit={selectedVariant.priceUnit}
sessionCount={selectedVariant.sessionCount}
/>
) : null}
</Stack>
{/* Address — a compact confirmation row once resolved, with a way back to the select. */}
{addresses.length === 0 ? (
<FieldEmpty
label={t('address_label')}
message={t('address_empty')}
ctaLabel={t('address_add_cta')}
onCta={() => router.push(`/${locale}${ROUTES.ADDRESSES}`)}
/>
) : addressEditing || !selectedAddress ? (
<TextField
select
label={t('address_label')}
value={addressId}
error={touched.address && addressId === ''}
helperText={touched.address && addressId === '' ? t('error_address_required') : undefined}
onChange={(event) => {
setAddressSel(Number(event.target.value));
setAddressEditing(false);
}}
onBlur={() => markTouched('address')}
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>
) : (
<Stack
direction="row"
sx={{
gap: 1.5,
alignItems: 'center',
p: 1.5,
border: '1px solid',
borderColor: 'divider',
borderRadius: 'var(--bal-radius-md)',
}}
>
<AppIcon icon="location" size={20} color="var(--bal-text-secondary)" />
<Stack sx={{ gap: 0.25, minWidth: 0, flexGrow: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 500 }} noWrap>
{regionLabel(selectedAddress)}
{/* Service variant */}
<Stack sx={{ gap: 1 }}>
{services.length === 0 ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('service_empty')}
</Typography>
{selectedAddress.addressLine ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }} noWrap>
{selectedAddress.addressLine}
</Typography>
) : null}
</Stack>
<AppButton variant="text" size="small" onClick={() => setAddressEditing(true)} sx={{ flexShrink: 0 }}>
{t('address_change_cta')}
</AppButton>
</Stack>
)}
{/* Date */}
<Stack sx={{ gap: 1 }} onBlur={() => markTouched('date')}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('date_label')}
</Typography>
<JalaliDateIntentPicker
value={date}
onChange={(iso) => {
setDate(iso);
if (pastDateError) setPastDateError(false);
}}
min={todayIso()}
todayLabel={t('date_today')}
tomorrowLabel={t('date_tomorrow')}
pickOtherLabel={t('date_pick_other')}
/>
{pastError ? (
<Typography variant="caption" sx={{ color: 'var(--bal-error)' }}>
{t('error_past_date')}
</Typography>
) : touched.date && date === '' ? (
<Typography variant="caption" sx={{ color: 'var(--bal-error)' }}>
{t('error_date_required')}
</Typography>
) : null}
</Stack>
{/* Time window — presets kill the end<=start error class; «زمان دلخواه» reveals free time fields. */}
<Stack sx={{ gap: 1 }} onBlur={() => markTouched('time')}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('time_window_label')}
</Typography>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
{TIME_WINDOWS.map((option) => (
<Chip
key={option.key}
clickable
label={t(`window_${option.key}`)}
onClick={() => selectWindow(option)}
color={windowSel === option.key ? 'primary' : undefined}
variant={windowSel === option.key ? 'filled' : 'outlined'}
data-window={option.key}
) : (
<RhfTextField<RequestFormValues>
name="variantId"
select
label={t('service_label')}
rules={{ validate: (value) => value !== '' || t('error_service_required') }}
fullWidth
>
<MenuItem value="" disabled>
{t('service_placeholder')}
</MenuItem>
{services.map((service) => (
<MenuItem key={service.variantId} value={service.variantId}>
{service.displayName}
</MenuItem>
))}
</RhfTextField>
)}
{selectedVariant ? (
<PriceDisplay
price={selectedVariant.priceIrr}
priceUnit={selectedVariant.priceUnit}
sessionCount={selectedVariant.sessionCount}
/>
))}
<Chip
clickable
label={t('window_custom')}
onClick={() => setWindowSel('custom')}
color={windowSel === 'custom' ? 'primary' : undefined}
variant={windowSel === 'custom' ? 'filled' : 'outlined'}
data-window="custom"
) : null}
</Stack>
{/* Address — a compact confirmation row once resolved, with a way back to the select. */}
{addresses.length === 0 ? (
<FieldEmpty
label={t('address_label')}
message={t('address_empty')}
ctaLabel={t('address_add_cta')}
onCta={() => router.push(`/${locale}${ROUTES.ADDRESSES}`)}
/>
</Stack>
{windowSel === 'custom' ? (
<Stack direction={{ xs: 'column', sm: 'row' }} sx={{ gap: 2 }}>
<TextField
type="time"
label={t('time_start_label')}
value={timeStart}
onChange={(event) => setTimeStart(event.target.value)}
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
/>
) : addressEditing || !selectedAddress ? (
<RhfTextField<RequestFormValues>
name="addressId"
select
label={t('address_label')}
rules={{ validate: (value) => value !== '' || t('error_address_required') }}
// Collapses back to the compact summary row once the menu closes — picking a different
// address is the normal exit, and dismissing without picking leaves the current one shown.
slotProps={{ select: { onClose: () => setAddressEditing(false) } }}
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>
))}
</RhfTextField>
) : (
<Stack
direction="row"
sx={{
gap: 1.5,
alignItems: 'center',
p: 1.5,
border: '1px solid',
borderColor: 'divider',
borderRadius: 'var(--bal-radius-md)',
}}
>
<AppIcon icon="location" size={20} color="var(--bal-text-secondary)" />
<Stack sx={{ gap: 0.25, minWidth: 0, flexGrow: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 500 }} noWrap>
{regionLabel(selectedAddress)}
</Typography>
{selectedAddress.addressLine ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }} noWrap>
{selectedAddress.addressLine}
</Typography>
) : null}
</Stack>
<AppButton variant="text" size="small" onClick={() => setAddressEditing(true)} sx={{ flexShrink: 0 }}>
{t('address_change_cta')}
</AppButton>
</Stack>
) : null}
{touched.time && (timeStart === '' || timeEnd === '') ? (
<Typography variant="caption" sx={{ color: 'var(--bal-error)' }}>
{t('error_time_required')}
</Typography>
) : null}
</Stack>
)}
{/* Caregiver gender — first-class, three-way, never silently defaulted */}
<Stack sx={{ gap: 1 }} onBlur={() => markTouched('gender')}>
<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: 700,
borderColor: touched.gender && gender === '' ? 'var(--bal-error)' : undefined,
{/* Date — the past-date guard is a cross-field rule against the chosen start time. */}
<RhfControlGroup<RequestFormValues>
name="date"
label={t('date_label')}
rules={{
validate: {
chosen: (value) => value !== '' || t('error_date_required'),
future: (value, all) =>
!all.timeStart || Date.parse(`${value}T${all.timeStart}`) >= Date.now() || t('error_past_date'),
},
}}
>
{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>
{touched.gender && gender === '' ? (
<Typography variant="caption" sx={{ color: 'var(--bal-error)' }}>
{t('error_gender_required')}
{({ field }) => (
<JalaliDateIntentPicker
value={(field.value as string) ?? ''}
onChange={field.onChange}
min={todayIso()}
todayLabel={t('date_today')}
tomorrowLabel={t('date_tomorrow')}
pickOtherLabel={t('date_pick_other')}
/>
)}
</RhfControlGroup>
{/* Time window — presets kill the end<=start error class; «زمان دلخواه» reveals free time fields. */}
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('time_window_label')}
</Typography>
) : null}
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
{TIME_WINDOWS.map((option) => (
<Chip
key={option.key}
clickable
label={t(`window_${option.key}`)}
onClick={() => selectWindow(option)}
color={windowSel === option.key ? 'primary' : undefined}
variant={windowSel === option.key ? 'filled' : 'outlined'}
data-window={option.key}
/>
))}
<Chip
clickable
label={t('window_custom')}
onClick={() => setValue('window', 'custom', { shouldDirty: true })}
color={windowSel === 'custom' ? 'primary' : undefined}
variant={windowSel === 'custom' ? 'filled' : 'outlined'}
data-window="custom"
/>
</Stack>
{windowSel === 'custom' ? (
<Stack direction={{ xs: 'column', sm: 'row' }} sx={{ gap: 2 }}>
<RhfTextField<RequestFormValues>
name="timeStart"
type="time"
label={t('time_start_label')}
rules={{ validate: (value) => value !== '' || t('error_time_required') }}
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
<RhfTextField<RequestFormValues>
name="timeEnd"
type="time"
label={t('time_end_label')}
rules={{
validate: {
chosen: (value) => value !== '' || t('error_time_required'),
after: (value, all) => !all.timeStart || String(value) > all.timeStart || t('error_time_range'),
},
}}
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
</Stack>
) : null}
</Stack>
{/* Caregiver gender — first-class, three-way, never silently defaulted */}
<RhfControlGroup<RequestFormValues>
name="gender"
label={t('gender_label')}
hint={t('gender_hint')}
rules={{ validate: (value) => value !== '' || t('error_gender_required') }}
>
{({ field, hasError }) => (
<ToggleButtonGroup
exclusive
color="primary"
value={field.value || null}
onChange={(_event, next: RequiredCaregiverGender | null) => {
if (next) field.onChange(next);
}}
sx={{
'& .MuiToggleButton-root': {
flex: 1,
py: 1.25,
fontWeight: 700,
borderColor: hasError ? 'var(--bal-error)' : undefined,
},
}}
>
{GENDER_OPTIONS.map((option) => (
<ToggleButton key={option} value={option} data-gender={option}>
{t(`gender_${option}`)}
</ToggleButton>
))}
</ToggleButtonGroup>
)}
</RhfControlGroup>
{genderMismatch ? (
<Typography variant="caption" sx={{ color: 'var(--bal-error)' }}>
{t('error_gender_mismatch')}
</Typography>
) : null}
</Stack>
{/* Stage-1 notes */}
<Stack sx={{ gap: 0.5 }}>
<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' }}>
{t('notes_counter', { count: notes.length, max: CUSTOMER_NOTES_MAX_LENGTH })}
</Typography>
</Stack>
{/* Stage-1 notes */}
<Stack sx={{ gap: 0.5 }}>
<RhfTextField<RequestFormValues>
name="notes"
label={t('notes_label')}
placeholder={t('notes_placeholder')}
helperText={t('notes_hint')}
transform={(raw) => raw.slice(0, CUSTOMER_NOTES_MAX_LENGTH)}
multiline
minRows={3}
fullWidth
/>
<Typography variant="caption" sx={{ color: 'text.secondary', textAlign: 'end' }}>
{t('notes_counter', { count: notes.length, max: CUSTOMER_NOTES_MAX_LENGTH })}
</Typography>
</Stack>
{formError ? (
<Typography variant="body2" sx={{ color: 'var(--bal-error)' }}>
{formError}
</Typography>
) : null}
<Stack sx={{ gap: 1 }}>
<AppButton
color="primary"
variant="contained"
size="large"
startIcon="requests"
disabled={!requiredChosen || genderMismatch || createRequest.isPending}
onClick={handleSubmit}
sx={{ py: 1.5 }}
>
{createRequest.isPending ? t('submitting') : t('submit')}
</AppButton>
{!requiredChosen && missingFieldLabels.length > 0 ? (
<Typography variant="caption" sx={{ color: 'text.secondary', textAlign: 'center' }}>
{t('cta_missing_caption', { fields: missingFieldLabels.join(locale === 'fa' ? '، ' : ', ') })}
{formError ? (
<Typography variant="body2" sx={{ color: 'var(--bal-error)' }}>
{formError}
</Typography>
) : null}
<Stack sx={{ gap: 1 }}>
<AppButton
type="submit"
color="primary"
variant="contained"
size="large"
startIcon="requests"
disabled={!requiredChosen || genderMismatch || createRequest.isPending}
sx={{ py: 1.5 }}
>
{createRequest.isPending ? t('submitting') : t('submit')}
</AppButton>
{!requiredChosen ? (
<Typography variant="caption" sx={{ color: 'text.secondary', textAlign: 'center' }}>
{t('cta_missing_caption', { fields: missingFieldLabels.join(locale === 'fa' ? '، ' : ', ') })}
</Typography>
) : null}
</Stack>
</Stack>
</Stack>
</FormProvider>
);
}
@@ -596,7 +609,9 @@ function NurseIdentityBar({ profile }: { profile: NurseProfile }) {
data-nurse-identity-bar
sx={{
position: 'sticky',
top: 0,
// Sticks just below the shell's pinned header rather than behind it (AppFrame publishes the
// height); `0px` in a chrome-free shell.
top: 'var(--bal-chrome-top, 0px)',
zIndex: 2,
gap: 1.5,
alignItems: 'center',
@@ -3,6 +3,7 @@ import { FunctionComponent, ReactNode, useEffect, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useParams, useRouter } from 'next/navigation';
import { useQueryClient } from '@tanstack/react-query';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { useSnackbar } from 'notistack';
import {
Box,
@@ -23,7 +24,16 @@ import {
useMediaQuery,
} from '@mui/material';
import { useTheme } from '@mui/material/styles';
import { AppButton, AppIcon, ConfirmDialog, EmptyState, PatientHeader, VisitNoteCard } from '@/components';
import {
AppButton,
AppIcon,
ConfirmDialog,
EmptyState,
PatientHeader,
RhfControlGroup,
RhfTextField,
VisitNoteCard,
} from '@/components';
import { ROUTES, bookingDetailPath } from '@/constants';
import { formatShamsiDate, formatShamsiMonthYear } from '@/utils';
import { bookingKeys } from '@/services/bookings/keys';
@@ -259,15 +269,23 @@ function RecordItemSheet({
);
}
/**
* Client-side id for a row that has never been saved. Module scope on purpose: `Date.now()` is impure
* and the lint rule can't tell that a submit callback only ever runs from an event, so keeping the
* call out of the component body states the same thing structurally.
*/
function newTempId(): string {
return `new-${Date.now()}`;
}
/** Save submits the enclosing `<form>`, so each sheet body owns its submit handler rather than a callback. */
function SheetActions({
onCancel,
onSave,
onDelete,
saving,
canSave,
}: {
onCancel: () => void;
onSave: () => void;
onDelete?: () => void;
saving: boolean;
canSave: boolean;
@@ -285,7 +303,7 @@ function SheetActions({
<AppButton variant="text" onClick={onCancel} disabled={saving}>
{tc('cancel')}
</AppButton>
<AppButton variant="contained" color="primary" onClick={onSave} disabled={saving || !canSave}>
<AppButton type="submit" variant="contained" color="primary" disabled={saving || !canSave}>
{saving ? tc('saving') : tc('save')}
</AppButton>
</Stack>
@@ -432,6 +450,16 @@ function MedicationsTab({
);
}
interface MedicationFormValues {
name: string;
doseAmount: string;
doseUnit: DoseUnit | '';
frequencyCode: FrequencyPreset | null;
frequencyText: string;
timeOfDay: TimeOfDayCode[];
timingNote: string;
}
function MedicationSheetBody({
initial,
saving,
@@ -448,107 +476,99 @@ function MedicationSheetBody({
onDirtyChange: (dirty: boolean) => void;
}) {
const t = useTranslations('records');
const [name, setName] = useState(initial?.name ?? '');
const [doseAmount, setDoseAmount] = useState(initial?.doseAmount ?? '');
const [doseUnit, setDoseUnit] = useState<DoseUnit | ''>(initial?.doseUnit ?? '');
const [frequencyCode, setFrequencyCode] = useState<FrequencyPreset | null>(initial?.frequencyCode ?? null);
const [frequencyText, setFrequencyText] = useState(initial?.frequencyText ?? '');
const [timeOfDay, setTimeOfDay] = useState<TimeOfDayCode[]>(initial?.timeOfDay ?? []);
const [timingNote, setTimingNote] = useState(initial?.timingNote ?? '');
const form = useForm<MedicationFormValues>({
mode: 'onTouched',
defaultValues: {
name: initial?.name ?? '',
doseAmount: initial?.doseAmount ?? '',
doseUnit: initial?.doseUnit ?? '',
frequencyCode: initial?.frequencyCode ?? null,
frequencyText: initial?.frequencyText ?? '',
timeOfDay: initial?.timeOfDay ?? [],
timingNote: initial?.timingNote ?? '',
},
});
const { control, formState, handleSubmit, setValue } = form;
const { isDirty } = formState;
const name = useWatch({ control, name: 'name' });
const frequencyCode = useWatch({ control, name: 'frequencyCode' });
useEffect(() => {
const dirty =
name !== (initial?.name ?? '') ||
doseAmount !== (initial?.doseAmount ?? '') ||
doseUnit !== (initial?.doseUnit ?? '') ||
frequencyCode !== (initial?.frequencyCode ?? null) ||
frequencyText !== (initial?.frequencyText ?? '') ||
timingNote !== (initial?.timingNote ?? '') ||
timeOfDay.length !== (initial?.timeOfDay ?? []).length ||
timeOfDay.some((code) => !(initial?.timeOfDay ?? []).includes(code));
onDirtyChange(dirty);
// eslint-disable-next-line react-hooks/exhaustive-deps -- `initial` is a fresh-mount snapshot (the sheet remounts per open), not reactive state.
}, [name, doseAmount, doseUnit, frequencyCode, frequencyText, timeOfDay, timingNote]);
onDirtyChange(isDirty);
}, [isDirty, onDirtyChange]);
const canSave = name.trim().length > 0;
const handleSave = () => {
const submit = (values: MedicationFormValues) => {
onSave({
id: initial?.id ?? `new-${Date.now()}`,
name: name.trim(),
doseAmount: doseAmount.trim() || null,
doseUnit: doseUnit || null,
frequencyCode,
frequencyText: frequencyCode ? null : frequencyText.trim() || null,
timeOfDay,
timingNote: timingNote.trim() || null,
id: initial?.id ?? newTempId(),
name: values.name.trim(),
doseAmount: values.doseAmount.trim() || null,
doseUnit: values.doseUnit || null,
frequencyCode: values.frequencyCode,
frequencyText: values.frequencyCode ? null : values.frequencyText.trim() || null,
timeOfDay: values.timeOfDay,
timingNote: values.timingNote.trim() || null,
});
};
return (
<Stack sx={{ gap: 2 }}>
<TextField label={t('med_name')} value={name} onChange={(e) => setName(e.target.value)} fullWidth required autoFocus />
<FormProvider {...form}>
<Stack component="form" noValidate onSubmit={handleSubmit(submit)} sx={{ gap: 2 }}>
<RhfTextField<MedicationFormValues> name="name" label={t('med_name')} fullWidth required autoFocus />
<Stack direction="row" sx={{ gap: 1.5 }}>
<TextField
label={t('med_dose_amount')}
value={doseAmount}
onChange={(e) => setDoseAmount(e.target.value)}
sx={{ flex: 1 }}
/>
<TextField select label={t('med_dose_unit')} value={doseUnit} onChange={(e) => setDoseUnit(e.target.value as DoseUnit)} sx={{ flex: 1 }}>
<MenuItem value="">{t('med_dose_unit_none')}</MenuItem>
{DOSE_UNITS.map((unit) => (
<MenuItem key={unit} value={unit}>
{t(`dose_unit_${unit}`)}
</MenuItem>
))}
</TextField>
</Stack>
<Stack sx={{ gap: 1 }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('med_frequency')}
</Typography>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
{FREQUENCY_PRESETS.map((preset) => (
<AppButton
key={preset}
variant={frequencyCode === preset ? 'contained' : 'outlined'}
color="primary"
size="small"
onClick={() => {
setFrequencyCode(frequencyCode === preset ? null : preset);
if (frequencyCode !== preset) setFrequencyText('');
}}
sx={{ borderRadius: '999px' }}
>
{t(`frequency_${preset}`)}
</AppButton>
))}
<Stack direction="row" sx={{ gap: 1.5 }}>
<RhfTextField<MedicationFormValues> name="doseAmount" label={t('med_dose_amount')} sx={{ flex: 1 }} />
<RhfTextField<MedicationFormValues> name="doseUnit" select label={t('med_dose_unit')} sx={{ flex: 1 }}>
<MenuItem value="">{t('med_dose_unit_none')}</MenuItem>
{DOSE_UNITS.map((unit) => (
<MenuItem key={unit} value={unit}>
{t(`dose_unit_${unit}`)}
</MenuItem>
))}
</RhfTextField>
</Stack>
{!frequencyCode ? (
<TextField
label={t('med_frequency_text')}
value={frequencyText}
onChange={(e) => setFrequencyText(e.target.value)}
fullWidth
size="small"
/>
) : null}
<Stack sx={{ gap: 1 }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('med_frequency')}
</Typography>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
{FREQUENCY_PRESETS.map((preset) => (
<AppButton
key={preset}
variant={frequencyCode === preset ? 'contained' : 'outlined'}
color="primary"
size="small"
onClick={() => {
const next = frequencyCode === preset ? null : preset;
setValue('frequencyCode', next, { shouldDirty: true });
// A preset and the free-text alternative are mutually exclusive by design.
if (next) setValue('frequencyText', '', { shouldDirty: true });
}}
sx={{ borderRadius: '999px' }}
>
{t(`frequency_${preset}`)}
</AppButton>
))}
</Stack>
{!frequencyCode ? (
<RhfTextField<MedicationFormValues>
name="frequencyText"
label={t('med_frequency_text')}
fullWidth
size="small"
/>
) : null}
</Stack>
<RhfControlGroup<MedicationFormValues> name="timeOfDay" hint={t('time_of_day')}>
{({ field }) => <TimeOfDayChipRow value={(field.value as TimeOfDayCode[]) ?? []} onChange={field.onChange} />}
</RhfControlGroup>
<RhfTextField<MedicationFormValues> name="timingNote" label={t('med_timing')} fullWidth size="small" />
<SheetActions onCancel={onCancel} onDelete={onDelete} saving={saving} canSave={name.trim().length > 0} />
</Stack>
<Stack sx={{ gap: 1 }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('time_of_day')}
</Typography>
<TimeOfDayChipRow value={timeOfDay} onChange={setTimeOfDay} />
</Stack>
<TextField label={t('med_timing')} value={timingNote} onChange={(e) => setTimingNote(e.target.value)} fullWidth size="small" />
<SheetActions onCancel={onCancel} onSave={handleSave} onDelete={onDelete} saving={saving} canSave={canSave} />
</Stack>
</FormProvider>
);
}
@@ -652,42 +672,45 @@ function RoutineSheetBody({
onDirtyChange: (dirty: boolean) => void;
}) {
const t = useTranslations('records');
const [label, setLabel] = useState(initial?.label ?? '');
const [timeOfDay, setTimeOfDay] = useState<TimeOfDayCode[]>(initial?.timeOfDay ?? []);
const [note, setNote] = useState(initial?.note ?? '');
const form = useForm<{ label: string; timeOfDay: TimeOfDayCode[]; note: string }>({
mode: 'onTouched',
defaultValues: {
label: initial?.label ?? '',
timeOfDay: initial?.timeOfDay ?? [],
note: initial?.note ?? '',
},
});
const { control, formState, handleSubmit } = form;
const { isDirty } = formState;
const label = useWatch({ control, name: 'label' });
useEffect(() => {
const dirty =
label !== (initial?.label ?? '') ||
note !== (initial?.note ?? '') ||
timeOfDay.length !== (initial?.timeOfDay ?? []).length ||
timeOfDay.some((code) => !(initial?.timeOfDay ?? []).includes(code));
onDirtyChange(dirty);
// eslint-disable-next-line react-hooks/exhaustive-deps -- `initial` is a fresh-mount snapshot (the sheet remounts per open), not reactive state.
}, [label, note, timeOfDay]);
const canSave = label.trim().length > 0;
const handleSave = () =>
onSave({
id: initial?.id ?? `new-${Date.now()}`,
label: label.trim(),
timeOfDay,
note: note.trim() || null,
});
onDirtyChange(isDirty);
}, [isDirty, onDirtyChange]);
return (
<Stack sx={{ gap: 2 }}>
<TextField label={t('routine_label')} value={label} onChange={(e) => setLabel(e.target.value)} fullWidth required autoFocus />
<Stack sx={{ gap: 1 }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('time_of_day')}
</Typography>
<TimeOfDayChipRow value={timeOfDay} onChange={setTimeOfDay} />
<FormProvider {...form}>
<Stack
component="form"
noValidate
onSubmit={handleSubmit((values) =>
onSave({
id: initial?.id ?? newTempId(),
label: values.label.trim(),
timeOfDay: values.timeOfDay,
note: values.note.trim() || null,
}),
)}
sx={{ gap: 2 }}
>
<RhfTextField name="label" label={t('routine_label')} fullWidth required autoFocus />
<RhfControlGroup name="timeOfDay" hint={t('time_of_day')}>
{({ field }) => <TimeOfDayChipRow value={(field.value as TimeOfDayCode[]) ?? []} onChange={field.onChange} />}
</RhfControlGroup>
<RhfTextField name="note" label={t('routine_note')} fullWidth size="small" />
<SheetActions onCancel={onCancel} onDelete={onDelete} saving={saving} canSave={label.trim().length > 0} />
</Stack>
<TextField label={t('routine_note')} value={note} onChange={(e) => setNote(e.target.value)} fullWidth size="small" />
<SheetActions onCancel={onCancel} onSave={handleSave} onDelete={onDelete} saving={saving} canSave={canSave} />
</Stack>
</FormProvider>
);
}
@@ -788,28 +811,42 @@ function TaskSheetBody({
onDirtyChange: (dirty: boolean) => void;
}) {
const t = useTranslations('records');
const [label, setLabel] = useState(initial?.label ?? '');
const [done, setDone] = useState(initial?.done ?? false);
const form = useForm<{ label: string; done: boolean }>({
mode: 'onTouched',
defaultValues: { label: initial?.label ?? '', done: initial?.done ?? false },
});
const { control, formState, handleSubmit } = form;
const { isDirty } = formState;
const label = useWatch({ control, name: 'label' });
useEffect(() => {
onDirtyChange(label !== (initial?.label ?? '') || done !== (initial?.done ?? false));
// eslint-disable-next-line react-hooks/exhaustive-deps -- `initial` is a fresh-mount snapshot (the sheet remounts per open), not reactive state.
}, [label, done]);
const canSave = label.trim().length > 0;
onDirtyChange(isDirty);
}, [isDirty, onDirtyChange]);
return (
<Stack sx={{ gap: 2 }}>
<TextField label={t('task_label')} value={label} onChange={(e) => setLabel(e.target.value)} fullWidth required autoFocus />
<FormControlLabel control={<Checkbox checked={done} onChange={(e) => setDone(e.target.checked)} />} label={t('task_done')} />
<SheetActions
onCancel={onCancel}
onSave={() => onSave({ id: initial?.id ?? `new-${Date.now()}`, label: label.trim(), done })}
onDelete={onDelete}
saving={saving}
canSave={canSave}
/>
</Stack>
<FormProvider {...form}>
<Stack
component="form"
noValidate
onSubmit={handleSubmit((values) =>
onSave({ id: initial?.id ?? newTempId(), label: values.label.trim(), done: values.done }),
)}
sx={{ gap: 2 }}
>
<RhfTextField name="label" label={t('task_label')} fullWidth required autoFocus />
<RhfControlGroup name="done">
{({ field }) => (
<FormControlLabel
control={
<Checkbox checked={Boolean(field.value)} onChange={(event) => field.onChange(event.target.checked)} />
}
label={t('task_done')}
/>
)}
</RhfControlGroup>
<SheetActions onCancel={onCancel} onDelete={onDelete} saving={saving} canSave={label.trim().length > 0} />
</Stack>
</FormProvider>
);
}
@@ -2,8 +2,9 @@
import { FunctionComponent, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { useSnackbar } from 'notistack';
import { Box, Divider, MenuItem, Skeleton, Stack, TextField, Typography } from '@mui/material';
import { Box, Divider, MenuItem, Skeleton, Stack, Typography } from '@mui/material';
import {
AppButton,
AppIcon,
@@ -12,6 +13,8 @@ import {
FormDialogShell,
PhoneNumberField,
ProfileSummary,
RhfControlGroup,
RhfTextField,
} from '@/components';
import LocaleSwitcher from '@/components/common/LocaleSwitcher';
import { ThemeModeSetting } from '@/components/settings';
@@ -44,6 +47,14 @@ export default function CustomerProfilePage() {
);
}
interface AccountFormValues {
firstName: string;
lastName: string;
language: string;
emergencyName: string;
emergencyPhone: string;
}
const AccountHub: FunctionComponent<{
initial: CustomerProfile | null;
nameFallback: { firstName: string | null; lastName: string | null };
@@ -57,69 +68,63 @@ const AccountHub: FunctionComponent<{
const upsert = useUpsertCustomerProfile();
const logout = useLogout();
const initialFirstName = initial?.firstName ?? nameFallback.firstName ?? '';
const initialLastName = initial?.lastName ?? nameFallback.lastName ?? '';
const initialLanguage = initial?.preferredLanguage ?? 'fa';
const initialEmergencyName = initial?.defaultEmergencyContactName ?? '';
const initialEmergencyPhone = digitsOnly(initial?.defaultEmergencyContactPhone ?? '');
const [firstName, setFirstName] = useState(initialFirstName);
const [lastName, setLastName] = useState(initialLastName);
const [language, setLanguage] = useState(initialLanguage);
const [emergencyName, setEmergencyName] = useState(initialEmergencyName);
const [emergencyPhone, setEmergencyPhone] = useState(initialEmergencyPhone);
const [personalSheetOpen, setPersonalSheetOpen] = useState(false);
const [languageSheetOpen, setLanguageSheetOpen] = useState(false);
const [emergencySheetOpen, setEmergencySheetOpen] = useState(false);
const [signOutOpen, setSignOutOpen] = useState(false);
const [nameError, setNameError] = useState(false);
const [phoneError, setPhoneError] = useState(false);
// ONE form behind all three sheets. Each sheet edits its own slice, but every save writes the whole
// profile (the wire upsert has no PATCH semantics), so the untouched fields have to come from
// somewhere — a single form is that somewhere, and `dirtyFields` then answers per-sheet "is there
// unsaved work here?" without a hand-written comparison per section.
const form = useForm<AccountFormValues>({
mode: 'onTouched',
defaultValues: {
firstName: initial?.firstName ?? nameFallback.firstName ?? '',
lastName: initial?.lastName ?? nameFallback.lastName ?? '',
language: initial?.preferredLanguage ?? 'fa',
emergencyName: initial?.defaultEmergencyContactName ?? '',
emergencyPhone: digitsOnly(initial?.defaultEmergencyContactPhone ?? ''),
},
});
const { control, formState, getValues, reset, trigger } = form;
const { dirtyFields } = formState;
const watched = useWatch({ control });
const displayName = [firstName, lastName].filter(Boolean).join(' ').trim() || phone || '';
const emergencyComplete = Boolean(emergencyName.trim() && emergencyPhone);
const displayName = [watched.firstName, watched.lastName].filter(Boolean).join(' ').trim() || phone || '';
const emergencyComplete = Boolean(watched.emergencyName?.trim() && watched.emergencyPhone);
// Every sheet saves the FULL profile object (the wire upsert has no PATCH semantics) — `patch`
// carries just the fields that sheet owns, the rest come from the shared draft state so editing
// one section never blanks another (the bug the old flat form was one refactor away from).
const save = (
patch: Partial<Record<'firstName' | 'lastName' | 'preferredLanguage', string | null>>,
onDone: () => void,
) => {
const save = (onDone: () => void) => {
const values = getValues();
upsert.mutate(
{
defaultEmergencyContactName: emergencyName.trim(),
defaultEmergencyContactPhone: emergencyPhone,
firstName: firstName.trim() || null,
lastName: lastName.trim() || null,
preferredLanguage: language,
...patch,
defaultEmergencyContactName: values.emergencyName.trim(),
defaultEmergencyContactPhone: values.emergencyPhone,
firstName: values.firstName.trim() || null,
lastName: values.lastName.trim() || null,
preferredLanguage: values.language,
},
{
onSuccess: () => {
enqueueSnackbar(t('saved'), { variant: 'success' });
// Re-baseline so the saved slice stops counting as unsaved work in its sheet's discard guard.
reset(getValues());
onDone();
},
},
);
};
const savePersonal = () =>
save({ firstName: firstName.trim() || null, lastName: lastName.trim() || null }, () => setPersonalSheetOpen(false));
const saveLanguage = () => save({ preferredLanguage: language }, () => setLanguageSheetOpen(false));
const saveEmergency = () => {
const nameInvalid = emergencyName.trim().length === 0;
const phoneInvalid = !isIranianMobile(emergencyPhone);
setNameError(nameInvalid);
setPhoneError(phoneInvalid);
if (nameInvalid || phoneInvalid) return;
save({}, () => setEmergencySheetOpen(false));
const savePersonal = () => save(() => setPersonalSheetOpen(false));
const saveLanguage = () => save(() => setLanguageSheetOpen(false));
const saveEmergency = async () => {
if (!(await trigger(['emergencyName', 'emergencyPhone']))) return;
save(() => setEmergencySheetOpen(false));
};
const personalDirty = firstName !== initialFirstName || lastName !== initialLastName;
const languageDirty = language !== initialLanguage;
const emergencyDirty = emergencyName !== initialEmergencyName || emergencyPhone !== initialEmergencyPhone;
const personalDirty = Boolean(dirtyFields.firstName || dirtyFields.lastName);
const languageDirty = Boolean(dirtyFields.language);
const emergencyDirty = Boolean(dirtyFields.emergencyName || dirtyFields.emergencyPhone);
const goTo = (path: string) => router.push(`/${locale}${path}`);
@@ -131,6 +136,7 @@ const AccountHub: FunctionComponent<{
const cancelLabel = tc('cancel');
return (
<FormProvider {...form}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 520 }}>
<ProfileSummary displayName={displayName} phone={phone} initialsFallback={displayName || undefined} />
@@ -138,8 +144,8 @@ const AccountHub: FunctionComponent<{
<AccountRow icon="account" label={t('row_personal')} onClick={() => setPersonalSheetOpen(true)} />
<EmergencyContactCard
complete={emergencyComplete}
name={emergencyName}
phone={emergencyPhone}
name={watched.emergencyName ?? ''}
phone={watched.emergencyPhone ?? ''}
onEdit={() => setEmergencySheetOpen(true)}
/>
<AccountRow icon="location" label={t('row_addresses')} onClick={() => goTo(ROUTES.ADDRESSES)} />
@@ -172,8 +178,8 @@ const AccountHub: FunctionComponent<{
discardCancelLabel={cancelLabel}
>
<Stack sx={{ gap: 2.5 }}>
<TextField label={t('first_name')} value={firstName} onChange={(e) => setFirstName(e.target.value)} fullWidth />
<TextField label={t('last_name')} value={lastName} onChange={(e) => setLastName(e.target.value)} fullWidth />
<RhfTextField<AccountFormValues> name="firstName" label={t('first_name')} fullWidth />
<RhfTextField<AccountFormValues> name="lastName" label={t('last_name')} fullWidth />
<SheetActions onCancel={() => setPersonalSheetOpen(false)} onSave={savePersonal} saving={upsert.isPending} saveLabel={tc('save')} cancelLabel={cancelLabel} />
</Stack>
</FormDialogShell>
@@ -201,16 +207,10 @@ const AccountHub: FunctionComponent<{
<LocaleSwitcher />
</Stack>
<Divider />
<TextField
select
label={t('language')}
value={language}
onChange={(e) => setLanguage(e.target.value)}
helperText={t('language_hint')}
>
<RhfTextField<AccountFormValues> name="language" select label={t('language')} helperText={t('language_hint')}>
<MenuItem value="fa">{t('language_fa')}</MenuItem>
<MenuItem value="en">{t('language_en')}</MenuItem>
</TextField>
</RhfTextField>
<SheetActions onCancel={() => setLanguageSheetOpen(false)} onSave={saveLanguage} saving={upsert.isPending} saveLabel={tc('save')} cancelLabel={cancelLabel} />
</Stack>
</FormDialogShell>
@@ -231,27 +231,27 @@ const AccountHub: FunctionComponent<{
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('emergency_hint')}
</Typography>
<TextField
<RhfTextField<AccountFormValues>
name="emergencyName"
label={t('emergency_name')}
value={emergencyName}
onChange={(e) => {
setEmergencyName(e.target.value);
if (nameError) setNameError(false);
}}
error={nameError}
fullWidth
/>
<PhoneNumberField
label={t('emergency_phone')}
value={emergencyPhone}
onChange={(v) => {
setEmergencyPhone(v);
if (phoneError) setPhoneError(false);
}}
error={phoneError}
helperText={phoneError ? t('emergency_phone_invalid') : undefined}
rules={{ validate: (value) => String(value ?? '').trim().length > 0 }}
fullWidth
/>
<RhfControlGroup<AccountFormValues>
name="emergencyPhone"
rules={{ validate: (value) => isIranianMobile(String(value ?? '')) }}
>
{({ field, hasError }) => (
<PhoneNumberField
label={t('emergency_phone')}
value={(field.value as string) ?? ''}
onChange={field.onChange}
error={hasError}
helperText={hasError ? t('emergency_phone_invalid') : undefined}
fullWidth
/>
)}
</RhfControlGroup>
<SheetActions onCancel={() => setEmergencySheetOpen(false)} onSave={saveEmergency} saving={upsert.isPending} saveLabel={tc('save')} cancelLabel={cancelLabel} />
</Stack>
</FormDialogShell>
@@ -270,6 +270,7 @@ const AccountHub: FunctionComponent<{
}}
/>
</Box>
</FormProvider>
);
};