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>
);
};
@@ -1,6 +1,7 @@
'use client';
import { Suspense, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { FormProvider, useForm } from 'react-hook-form';
import { useSnackbar } from 'notistack';
import {
Box,
@@ -14,9 +15,8 @@ import {
Skeleton,
Stack,
Switch,
TextField,
} from '@mui/material';
import { AppButton, AppLoading, JalaliDateField } from '@/components';
import { AppButton, AppLoading, RhfControlGroup, RhfJalaliDateField, RhfTextField } from '@/components';
import { AdminDataTable, AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager, type AdminTableColumn } from '@/components/admin';
import { formatShamsiDate } from '@/utils';
import { useAdminCapabilities, useAdminListState } from '@/hooks';
@@ -138,19 +138,20 @@ function HolidayDialog({ holiday, onClose }: { holiday: Holiday | null; onClose:
const t = useTranslations('admin');
const { enqueueSnackbar } = useSnackbar();
const upsert = useUpsertHoliday();
const [form, setForm] = useState<HolidayInput>(() => ({
holidayDate: holiday?.holidayDate?.slice(0, 10) ?? todayLocalIso(),
nameFa: holiday?.nameFa ?? '',
type: holiday?.type ?? 'official',
isBankClosed: holiday?.isBankClosed ?? true,
}));
const form = useForm<HolidayInput>({
mode: 'onTouched',
defaultValues: {
holidayDate: holiday?.holidayDate?.slice(0, 10) ?? todayLocalIso(),
nameFa: holiday?.nameFa ?? '',
type: holiday?.type ?? 'official',
isBankClosed: holiday?.isBankClosed ?? true,
},
});
const { handleSubmit, formState } = form;
const valid = form.holidayDate.length > 0 && form.nameFa.trim().length > 0;
const onSave = () => {
if (!valid) return;
const onSave = (values: HolidayInput) =>
upsert.mutate(
{ ...form, nameFa: form.nameFa.trim() },
{ ...values, nameFa: values.nameFa.trim() },
{
onSuccess: () => {
enqueueSnackbar(t('hol_saved'), { variant: 'success' });
@@ -158,50 +159,59 @@ function HolidayDialog({ holiday, onClose }: { holiday: Holiday | null; onClose:
},
},
);
};
return (
<Dialog open onClose={upsert.isPending ? undefined : onClose} fullWidth maxWidth="xs">
<DialogTitle sx={{ fontWeight: 800 }}>{holiday ? t('hol_edit') : t('hol_add')}</DialogTitle>
<DialogContent>
<Stack sx={{ gap: 2, mt: 1 }}>
<JalaliDateField
label={t('hol_col_date')}
value={form.holidayDate || null}
onChange={(iso) => setForm((f) => ({ ...f, holidayDate: iso }))}
disabled={!!holiday}
/>
<TextField
label={t('hol_name_fa')}
value={form.nameFa}
onChange={(e) => setForm((f) => ({ ...f, nameFa: e.target.value }))}
/>
<TextField
select
label={t('hol_col_type')}
value={form.type}
onChange={(e) => setForm((f) => ({ ...f, type: e.target.value as HolidayType }))}
<FormProvider {...form}>
<DialogContent>
{/* A real <form> so Enter submits from any field; the footer button (outside DialogContent)
calls the same handler directly rather than relying on cross-element form association. */}
<Stack component="form" noValidate onSubmit={handleSubmit(onSave)} sx={{ gap: 2, mt: 1 }}>
<RhfJalaliDateField<HolidayInput>
name="holidayDate"
label={t('hol_col_date')}
rules={{ validate: (value) => String(value ?? '').length > 0 }}
disabled={!!holiday}
/>
<RhfTextField<HolidayInput>
name="nameFa"
label={t('hol_name_fa')}
rules={{ validate: (value) => String(value ?? '').trim().length > 0 }}
/>
<RhfTextField<HolidayInput> name="type" select label={t('hol_col_type')}>
{HOLIDAY_TYPES.map((ty) => (
<MenuItem key={ty} value={ty}>
{t(`htype_${ty}`)}
</MenuItem>
))}
</RhfTextField>
<RhfControlGroup<HolidayInput> name="isBankClosed">
{({ field }) => (
<FormControlLabel
control={
<Switch checked={Boolean(field.value)} onChange={(event) => field.onChange(event.target.checked)} />
}
label={t('hol_bank_hint')}
/>
)}
</RhfControlGroup>
</Stack>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<AppButton variant="text" color="inherit" onClick={onClose} disabled={upsert.isPending}>
{t('cancel')}
</AppButton>
<AppButton
variant="contained"
color="primary"
onClick={handleSubmit(onSave)}
disabled={!formState.isValid || upsert.isPending}
>
{HOLIDAY_TYPES.map((ty) => (
<MenuItem key={ty} value={ty}>
{t(`htype_${ty}`)}
</MenuItem>
))}
</TextField>
<FormControlLabel
control={<Switch checked={form.isBankClosed} onChange={(e) => setForm((f) => ({ ...f, isBankClosed: e.target.checked }))} />}
label={t('hol_bank_hint')}
/>
</Stack>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<AppButton variant="text" color="inherit" onClick={onClose} disabled={upsert.isPending}>
{t('cancel')}
</AppButton>
<AppButton variant="contained" color="primary" onClick={onSave} disabled={!valid || upsert.isPending}>
{upsert.isPending ? t('saving') : t('save')}
</AppButton>
</DialogActions>
{upsert.isPending ? t('saving') : t('save')}
</AppButton>
</DialogActions>
</FormProvider>
</Dialog>
);
}
@@ -2,6 +2,7 @@
import { Suspense, 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,
@@ -13,10 +14,9 @@ import {
Skeleton,
Stack,
Switch,
TextField,
Typography,
} from '@mui/material';
import { AppButton, AppLoading, StatusChip } from '@/components';
import { AppButton, AppLoading, RhfControlGroup, RhfTextField, StatusChip } from '@/components';
import type { StatusKind } from '@/components';
import {
AdminDataTable,
@@ -183,37 +183,30 @@ export function PartnerCenterFormDialog({ center, onClose }: { center: PartnerCe
const create = useCreatePartnerCenter();
const update = useUpdatePartnerCenter(center?.id ?? 0);
const mutation = isEdit ? update : create;
const [form, setForm] = useState<CenterFormState>(() => initialForm(center));
const form = useForm<CenterFormState>({ mode: 'onTouched', defaultValues: initialForm(center) });
const { control, handleSubmit, formState } = form;
const isMerchantOfRecord = useWatch({ control, name: 'isMerchantOfRecord' });
const adminUser = useWatch({ control, name: 'adminUser' });
// Edit mode: the center already has an adminUserId (a plain number) — resolve it to a name so the picker
// opens pre-filled with a person, never a bare id (3.2). Derived in render (never synced into state via an
// effect): once the admin actually picks someone, `form.adminUser` wins over the resolved existing one.
// opens pre-filled with a person, never a bare id (3.2). Derived in render (never synced into form state):
// once the admin actually picks someone, the form value wins over the resolved existing one.
const existingAdminId = center?.adminUserId ?? null;
const existingAdminLookup = useUserLookup(existingAdminId != null ? [existingAdminId] : []);
const resolvedExistingAdmin = existingAdminId != null ? (existingAdminLookup.data?.get(existingAdminId) ?? null) : null;
const displayedAdminUser = form.adminUser !== undefined ? form.adminUser : resolvedExistingAdmin;
const displayedAdminUser = adminUser !== undefined ? adminUser : resolvedExistingAdmin;
const set = <K extends keyof CenterFormState>(key: K, value: CenterFormState[K]) =>
setForm((f) => ({ ...f, [key]: value }));
const commission = Number(form.commissionRate);
const commissionValid = form.commissionRate.trim() !== '' && Number.isFinite(commission) && commission >= 0 && commission < 1;
// On edit a blank IBAN is allowed (it keeps the stored value); on create an MoR center must supply one.
const ibanValid = !form.isMerchantOfRecord || isEdit || form.settlementIban.trim() !== '';
const valid =
form.name.trim() !== '' && form.mohEstablishmentPermitNo.trim() !== '' && commissionValid && ibanValid;
const onSave = () => {
if (!valid) return;
const onSave = (values: CenterFormState) => {
const input: PartnerCenterInput = {
name: form.name.trim(),
legalEntityType: form.legalEntityType.trim(),
mohEstablishmentPermitNo: form.mohEstablishmentPermitNo.trim(),
technicalDirectorLicenseNo: form.technicalDirectorLicenseNo.trim() || null,
enamadCode: form.enamadCode.trim() || null,
settlementIban: form.settlementIban.trim() || null,
isMerchantOfRecord: form.isMerchantOfRecord,
commissionRate: commission,
name: values.name.trim(),
legalEntityType: values.legalEntityType.trim(),
mohEstablishmentPermitNo: values.mohEstablishmentPermitNo.trim(),
technicalDirectorLicenseNo: values.technicalDirectorLicenseNo.trim() || null,
enamadCode: values.enamadCode.trim() || null,
settlementIban: values.settlementIban.trim() || null,
isMerchantOfRecord: values.isMerchantOfRecord,
commissionRate: Number(values.commissionRate),
adminUserId: displayedAdminUser?.id ?? null,
};
mutation.mutate(input, {
@@ -227,67 +220,95 @@ export function PartnerCenterFormDialog({ center, onClose }: { center: PartnerCe
return (
<Dialog open onClose={mutation.isPending ? undefined : onClose} fullWidth maxWidth="sm">
<DialogTitle sx={{ fontWeight: 800 }}>{isEdit ? t('partner_edit') : t('partner_create')}</DialogTitle>
<DialogContent>
<Stack sx={{ gap: 2, mt: 1 }}>
<TextField label={t('partner_name')} value={form.name} onChange={(e) => set('name', e.target.value)} />
<TextField
label={t('partner_legal_type')}
value={form.legalEntityType}
onChange={(e) => set('legalEntityType', e.target.value)}
/>
<TextField
label={t('partner_permit')}
value={form.mohEstablishmentPermitNo}
onChange={(e) => set('mohEstablishmentPermitNo', e.target.value)}
/>
<TextField
label={t('partner_tech_director_license')}
value={form.technicalDirectorLicenseNo}
onChange={(e) => set('technicalDirectorLicenseNo', e.target.value)}
/>
<TextField label={t('partner_enamad')} value={form.enamadCode} onChange={(e) => set('enamadCode', e.target.value)} />
<TextField
label={t('partner_iban')}
value={form.settlementIban}
onChange={(e) => set('settlementIban', e.target.value)}
helperText={t('partner_iban_write_hint')}
placeholder={center?.settlementIbanMasked ?? undefined}
slotProps={{ htmlInput: { dir: 'ltr' } }}
/>
<Box>
<FormControlLabel
control={<Switch checked={form.isMerchantOfRecord} onChange={(e) => set('isMerchantOfRecord', e.target.checked)} />}
label={t('partner_is_mor')}
<FormProvider {...form}>
<DialogContent>
{/* A real <form> so Enter submits from any field; the footer button (outside DialogContent)
calls the same handler directly rather than relying on cross-element form association. */}
<Stack component="form" noValidate onSubmit={handleSubmit(onSave)} sx={{ gap: 2, mt: 1 }}>
<RhfTextField<CenterFormState>
name="name"
label={t('partner_name')}
rules={{ validate: (value) => String(value ?? '').trim() !== '' }}
/>
<Typography variant="caption" sx={{ display: 'block', color: 'text.secondary' }}>
{t('partner_is_mor_hint')}
</Typography>
</Box>
<TextField
type="number"
label={t('partner_commission')}
value={form.commissionRate}
onChange={(e) => set('commissionRate', e.target.value)}
slotProps={{ htmlInput: { min: 0, max: 0.999, step: 0.01 } }}
/>
<UserPicker
value={displayedAdminUser}
onChange={(u) => set('adminUser', u)}
label={t('partner_admin_user')}
placeholder={t('user_picker_search_ph')}
noOptionsText={t('user_picker_no_options')}
loadingText={t('user_picker_loading')}
/>
</Stack>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<AppButton variant="text" color="inherit" onClick={onClose} disabled={mutation.isPending}>
{t('cancel')}
</AppButton>
<AppButton variant="contained" color="primary" onClick={onSave} disabled={!valid || mutation.isPending}>
{mutation.isPending ? t('saving') : t('save')}
</AppButton>
</DialogActions>
<RhfTextField<CenterFormState> name="legalEntityType" label={t('partner_legal_type')} />
<RhfTextField<CenterFormState>
name="mohEstablishmentPermitNo"
label={t('partner_permit')}
rules={{ validate: (value) => String(value ?? '').trim() !== '' }}
/>
<RhfTextField<CenterFormState>
name="technicalDirectorLicenseNo"
label={t('partner_tech_director_license')}
/>
<RhfTextField<CenterFormState> name="enamadCode" label={t('partner_enamad')} />
<RhfTextField<CenterFormState>
name="settlementIban"
label={t('partner_iban')}
helperText={t('partner_iban_write_hint')}
placeholder={center?.settlementIbanMasked ?? undefined}
// On edit a blank IBAN keeps the stored value; on create an MoR center must supply one.
rules={{ validate: (value) => !isMerchantOfRecord || isEdit || String(value ?? '').trim() !== '' }}
slotProps={{ htmlInput: { dir: 'ltr' } }}
/>
<Box>
<RhfControlGroup<CenterFormState> name="isMerchantOfRecord">
{({ field }) => (
<FormControlLabel
control={
<Switch
checked={Boolean(field.value)}
onChange={(event) => field.onChange(event.target.checked)}
/>
}
label={t('partner_is_mor')}
/>
)}
</RhfControlGroup>
<Typography variant="caption" sx={{ display: 'block', color: 'text.secondary' }}>
{t('partner_is_mor_hint')}
</Typography>
</Box>
<RhfTextField<CenterFormState>
name="commissionRate"
type="number"
label={t('partner_commission')}
rules={{
validate: (value) => {
const raw = String(value ?? '').trim();
const rate = Number(raw);
return raw !== '' && Number.isFinite(rate) && rate >= 0 && rate < 1;
},
}}
slotProps={{ htmlInput: { min: 0, max: 0.999, step: 0.01 } }}
/>
<RhfControlGroup<CenterFormState> name="adminUser">
{({ field }) => (
<UserPicker
value={displayedAdminUser}
onChange={field.onChange}
label={t('partner_admin_user')}
placeholder={t('user_picker_search_ph')}
noOptionsText={t('user_picker_no_options')}
loadingText={t('user_picker_loading')}
/>
)}
</RhfControlGroup>
</Stack>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<AppButton variant="text" color="inherit" onClick={onClose} disabled={mutation.isPending}>
{t('cancel')}
</AppButton>
<AppButton
variant="contained"
color="primary"
onClick={handleSubmit(onSave)}
disabled={!formState.isValid || mutation.isPending}
>
{mutation.isPending ? t('saving') : t('save')}
</AppButton>
</DialogActions>
</FormProvider>
</Dialog>
);
}
@@ -2,6 +2,7 @@
import { Suspense, useCallback, useEffect, useMemo, useState } from 'react';
import { useParams, useRouter, useSearchParams } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { FormProvider, useForm } from 'react-hook-form';
import { useSnackbar } from 'notistack';
import {
Box,
@@ -12,10 +13,9 @@ import {
DialogTitle,
Skeleton,
Stack,
TextField,
Typography,
} from '@mui/material';
import { AppButton, AppLoading, JalaliDateField, PageHeader, StatusChip } from '@/components';
import { AppButton, AppLoading, PageHeader, RhfJalaliDateField, RhfTextField, StatusChip } from '@/components';
import type { StatusKind } from '@/components';
import { AdminEmptyState, AdminErrorState, ConfirmDialog, DocumentViewer } from '@/components/admin';
import { ROUTES, adminVerificationCasePath } from '@/constants';
@@ -398,6 +398,14 @@ function StepCard({
/** The structured credential form recorded on approving a credential-bearing step. `criminal_record`
* requires an expiry date; `credentialNumber` is accepted as input and never echoed back. */
interface CredentialDecisionValues {
credentialNumber: string;
holderName: string;
issuingAuthority: string;
issuedAt: string;
expiresAt: string;
}
function CredentialDialog({
step,
nurseVerificationId,
@@ -410,28 +418,26 @@ function CredentialDialog({
const t = useTranslations('admin');
const { enqueueSnackbar } = useSnackbar();
const decide = useDecideStep();
const [credentialNumber, setCredentialNumber] = useState('');
const [holderName, setHolderName] = useState('');
const [issuingAuthority, setIssuingAuthority] = useState('');
const [issuedAt, setIssuedAt] = useState('');
const [expiresAt, setExpiresAt] = useState('');
const expiryRequired = step.code === 'criminal_record';
const expiryMissing = expiryRequired && expiresAt.trim().length === 0;
const form = useForm<CredentialDecisionValues>({
mode: 'onTouched',
defaultValues: { credentialNumber: '', holderName: '', issuingAuthority: '', issuedAt: '', expiresAt: '' },
});
const { handleSubmit, formState } = form;
const onSubmit = () => {
if (expiryMissing) return;
const onSubmit = (values: CredentialDecisionValues) =>
decide.mutate(
{
stepId: step.id,
nurseVerificationId,
input: {
approve: true,
credentialNumber: credentialNumber.trim() || undefined,
holderName: holderName.trim() || undefined,
issuingAuthority: issuingAuthority.trim() || undefined,
issuedAt: issuedAt || undefined,
expiresAt: expiresAt || undefined,
credentialNumber: values.credentialNumber.trim() || undefined,
holderName: values.holderName.trim() || undefined,
issuingAuthority: values.issuingAuthority.trim() || undefined,
issuedAt: values.issuedAt || undefined,
expiresAt: values.expiresAt || undefined,
},
},
{
@@ -441,58 +447,58 @@ function CredentialDialog({
},
},
);
};
return (
<Dialog open onClose={decide.isPending ? undefined : onClose} fullWidth maxWidth="sm">
<DialogTitle sx={{ fontWeight: 800 }}>{t('ver_credential_title')}</DialogTitle>
<DialogContent>
<Stack sx={{ gap: 2, mt: 1 }}>
<TextField
fullWidth
autoFocus
label={t('ver_credential_number')}
value={credentialNumber}
onChange={(e) => setCredentialNumber(e.target.value)}
/>
<TextField
fullWidth
label={t('ver_holder_name')}
helperText={t('ver_holder_hint')}
value={holderName}
onChange={(e) => setHolderName(e.target.value)}
/>
<TextField
fullWidth
label={t('ver_issuing_authority')}
value={issuingAuthority}
onChange={(e) => setIssuingAuthority(e.target.value)}
/>
<JalaliDateField fullWidth label={t('ver_issued_at')} value={issuedAt || null} onChange={setIssuedAt} />
<JalaliDateField
fullWidth
label={t('ver_expires_at')}
value={expiresAt || null}
onChange={setExpiresAt}
required={expiryRequired}
error={expiryMissing}
helperText={expiryMissing ? t('ver_expiry_required') : undefined}
/>
</Stack>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<AppButton variant="text" color="inherit" onClick={onClose} disabled={decide.isPending}>
{t('cancel')}
</AppButton>
<AppButton
variant="contained"
color="primary"
onClick={onSubmit}
disabled={decide.isPending || expiryMissing}
>
{decide.isPending ? t('saving') : t('save')}
</AppButton>
</DialogActions>
<FormProvider {...form}>
<DialogContent>
{/* A real <form> so Enter submits from any field; the footer button (outside DialogContent)
calls the same handler directly rather than relying on cross-element form association. */}
<Stack component="form" noValidate onSubmit={handleSubmit(onSubmit)} sx={{ gap: 2, mt: 1 }}>
<RhfTextField<CredentialDecisionValues>
name="credentialNumber"
fullWidth
autoFocus
label={t('ver_credential_number')}
/>
<RhfTextField<CredentialDecisionValues>
name="holderName"
fullWidth
label={t('ver_holder_name')}
helperText={t('ver_holder_hint')}
/>
<RhfTextField<CredentialDecisionValues>
name="issuingAuthority"
fullWidth
label={t('ver_issuing_authority')}
/>
<RhfJalaliDateField<CredentialDecisionValues> name="issuedAt" fullWidth label={t('ver_issued_at')} />
<RhfJalaliDateField<CredentialDecisionValues>
name="expiresAt"
fullWidth
label={t('ver_expires_at')}
required={expiryRequired}
rules={{
validate: (value) => !expiryRequired || String(value ?? '').trim().length > 0 || t('ver_expiry_required'),
}}
/>
</Stack>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<AppButton variant="text" color="inherit" onClick={onClose} disabled={decide.isPending}>
{t('cancel')}
</AppButton>
<AppButton
variant="contained"
color="primary"
onClick={handleSubmit(onSubmit)}
disabled={decide.isPending || !formState.isValid}
>
{decide.isPending ? t('saving') : t('save')}
</AppButton>
</DialogActions>
</FormProvider>
</Dialog>
);
}
@@ -9,19 +9,15 @@ import {
CountdownTimer,
EmptyState,
ErrorState,
InitialsAvatar,
Money,
PageHeader,
SurfaceCard,
TrustBadge,
} from '@/components';
import { ROUTES } from '@/constants';
import { formatRelativeTime, formatShamsiDate, localeTag, parseIrr } from '@/utils';
import { useMe } from '@/services/auth';
import { useNurseRequestInbox } from '@/services/bookingRequests';
import { useTodaySessions } from '@/services/bookings';
import { useNurseEarningsBalance } from '@/services/payouts';
import { useVerificationStatus } from '@/services/verification';
import { ownBadgeState } from '@/services/verification/types';
import { coarseResponseLabel } from '@/services/bookingRequests/format';
import DashboardActivationSlot from './DashboardActivationSlot';
@@ -35,17 +31,25 @@ const WARN_THRESHOLD_SECONDS = 2 * 60 * 60;
* Rebuilt for the phone-width frame. The previous version stacked five same-weight cards, each
* repeating its own icon + bold heading + inline "see all" button; at 480px the buttons wrapped
* mid-word, the countdown collided with the request title, and nothing on the screen looked more
* important than anything else. This version gives the page one visual hierarchy: a quiet identity
* strip, then exactly one hero action (the next visit), then sections introduced by a plain label
* with a text link instead of a competing button.
* important than anything else. This version gives the page one visual hierarchy: exactly one hero
* action (the next visit), then sections introduced by a plain label with a text link instead of a
* competing button.
*
* The greeting/identity strip that used to sit above all of it is gone: it spent the most valuable
* row on the screen restating the signed-in name to the person who typed the phone number, and the
* badge beside it duplicated the activation tracker further down. Identity now lives in the shell's
* top bar (`NurseAccountButton`), where it costs no content height and opens the account hub.
*
* Composition only — every widget reads a query that is already cached elsewhere in the shell, and
* order encodes urgency: a missed request expires, an unread earnings figure does not.
*/
export default function NurseDashboardScreen() {
const t = useTranslations('dashboard');
return (
<Stack sx={{ gap: 2.5 }}>
<GreetingHeader />
{/* Load-bearing now that the bottom nav is icon-only: this is the only place the current
section is named, and the page's only h1. */}
<PageHeader title={t('title')} />
<NextVisitCard />
<RequestsSection />
<EarningsSection />
@@ -74,40 +78,6 @@ function SectionHeader({ title, actionLabel, actionTo }: { title: string; action
);
}
/** Greeting + own trust badge. The avatar is initials-based — a nurse photo lives on the profile. */
function GreetingHeader() {
const t = useTranslations('dashboard');
const { data: me, isLoading } = useMe();
const verification = useVerificationStatus();
const displayName = me ? [me.firstName, me.lastName].filter(Boolean).join(' ').trim() || me.phone : '';
if (isLoading) {
return (
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
<Skeleton variant="circular" width={44} height={44} />
<Skeleton variant="text" width={180} height={28} />
</Stack>
);
}
return (
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center', minWidth: 0 }}>
<InitialsAvatar name={displayName} size={44} />
<Stack sx={{ gap: 0.5, minWidth: 0 }}>
<Typography variant="subtitle1" component="h1" noWrap sx={{ fontWeight: 700 }}>
{t('greeting', { name: displayName })}
</Typography>
{verification.isLoading ? null : (
<Box>
<TrustBadge state={ownBadgeState(verification.data)} />
</Box>
)}
</Stack>
</Stack>
);
}
/** The page's one hero action: the next actionable session, with a full-width primary CTA. */
function NextVisitCard() {
const t = useTranslations('dashboard');
@@ -1,13 +1,19 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { FormProvider, useForm } from 'react-hook-form';
import { useSnackbar } from 'notistack';
import { Box, Stack, TextField, Typography } from '@mui/material';
import { AppButton, AppLoading, BankStatusPanel, EmptyState, ErrorState } from '@/components';
import { Box, Stack, Typography } from '@mui/material';
import { AppButton, AppLoading, BankStatusPanel, EmptyState, ErrorState, RhfTextField } from '@/components';
import { useNurseBankAccounts, useAddNurseBankAccount, useSetPrimaryBankAccount } from '@/services/nurse';
import { isValidSheba } from '@/services/nurse/iban';
import { deriveBankStatus } from '@/services/nurse/types';
interface BankFormValues {
iban: string;
holder: string;
}
/**
* Nurse payout bank settings an **accounts section**, not a one-shot form (ui-phase-8 §3.7): submit
* an IBAN (شبا) + account-holder name, then watch the ownership inquiry resolve through its three
@@ -26,28 +32,19 @@ export default function NurseBankPage() {
const addAccount = useAddNurseBankAccount();
const setPrimary = useSetPrimaryBankAccount();
const [iban, setIban] = useState('');
const [holder, setHolder] = useState('');
const [ibanError, setIbanError] = useState(false);
const [holderError, setHolderError] = useState(false);
const [showForm, setShowForm] = useState(false);
const form = useForm<BankFormValues>({ mode: 'onTouched', defaultValues: { iban: '', holder: '' } });
const { handleSubmit, reset } = form;
const accounts = data ?? [];
const showFormNow = !isLoading && !isError && (accounts.length === 0 || showForm);
const submit = () => {
const ibanInvalid = !isValidSheba(iban);
const holderInvalid = holder.trim().length === 0;
setIbanError(ibanInvalid);
setHolderError(holderInvalid);
if (ibanInvalid || holderInvalid) return;
const submit = (values: BankFormValues) => {
addAccount.mutate(
{ iban, accountHolderName: holder.trim() },
{ iban: values.iban, accountHolderName: values.holder.trim() },
{
onSuccess: () => {
setIban('');
setHolder('');
reset();
setShowForm(false);
enqueueSnackbar(t('added'), { variant: 'success' });
},
@@ -131,51 +128,43 @@ export default function NurseBankPage() {
) : null}
{showFormNow ? (
<Stack sx={{ gap: 2 }}>
<TextField
label={t('iban_label')}
value={iban}
onChange={(e) => {
setIban(e.target.value.toUpperCase());
if (ibanError) setIbanError(false);
}}
error={ibanError}
helperText={ibanError ? t('iban_invalid') : t('iban_hint')}
slotProps={{ htmlInput: { dir: 'ltr', style: { textAlign: 'start', letterSpacing: 1 } } }}
fullWidth
/>
<TextField
label={t('holder_label')}
value={holder}
onChange={(e) => {
setHolder(e.target.value);
if (holderError) setHolderError(false);
}}
error={holderError}
helperText={holderError ? t('holder_required') : t('holder_hint')}
fullWidth
/>
<Stack direction="row" sx={{ gap: 1 }}>
<AppButton color="primary" variant="contained" startIcon="bank" onClick={submit} disabled={addAccount.isPending}>
{addAccount.isPending ? t('submitting') : t('submit')}
</AppButton>
{accounts.length > 0 ? (
<AppButton
variant="text"
onClick={() => {
setShowForm(false);
setIban('');
setHolder('');
setIbanError(false);
setHolderError(false);
}}
disabled={addAccount.isPending}
>
{tc('cancel')}
<FormProvider {...form}>
<Stack component="form" noValidate onSubmit={handleSubmit(submit)} sx={{ gap: 2 }}>
<RhfTextField<BankFormValues>
name="iban"
label={t('iban_label')}
helperText={t('iban_hint')}
transform={(raw) => raw.toUpperCase()}
rules={{ validate: (value) => isValidSheba(String(value ?? '')) || t('iban_invalid') }}
slotProps={{ htmlInput: { dir: 'ltr', style: { textAlign: 'start', letterSpacing: 1 } } }}
fullWidth
/>
<RhfTextField<BankFormValues>
name="holder"
label={t('holder_label')}
helperText={t('holder_hint')}
rules={{ validate: (value) => String(value ?? '').trim().length > 0 || t('holder_required') }}
fullWidth
/>
<Stack direction="row" sx={{ gap: 1 }}>
<AppButton type="submit" color="primary" variant="contained" startIcon="bank" disabled={addAccount.isPending}>
{addAccount.isPending ? t('submitting') : t('submit')}
</AppButton>
) : null}
{accounts.length > 0 ? (
<AppButton
variant="text"
onClick={() => {
setShowForm(false);
reset();
}}
disabled={addAccount.isPending}
>
{tc('cancel')}
</AppButton>
) : null}
</Stack>
</Stack>
</Stack>
</FormProvider>
) : null}
</Box>
);
@@ -1,20 +1,45 @@
'use client';
import { ChangeEvent, FunctionComponent, useEffect, useRef, useState } from 'react';
import { ChangeEvent, FunctionComponent, useEffect, useRef } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useForm, FormProvider, useWatch } from 'react-hook-form';
import { useSnackbar } from 'notistack';
import { Avatar, Box, Chip, MenuItem, Paper, Stack, TextField, Typography } from '@mui/material';
import { AppButton, AppIcon, AppLoading, TrustBadge } from '@/components';
import { Avatar, Box, MenuItem, Stack, Typography } from '@mui/material';
import {
AccentCard,
AppButton,
AppIcon,
AppLoading,
FormSection,
PageHeader,
RhfChipSelect,
RhfTextField,
TrustBadge,
} from '@/components';
import { CONTENT_MAX_WIDTH } from '@/components/config';
import { ROUTES } from '@/constants';
import { digitsOnly } from '@/utils';
import { useNurseProfile, useUpsertNurseProfile, useUploadAvatar } from '@/services/profiles';
import type { NurseProfile } from '@/services/profiles/types';
import { useVerificationStatus } from '@/services/verification';
import { ownBadgeState, SPECIALTY_PRESETS } from '@/services/verification/types';
const MAX_YEARS = 80;
const MAX_YEARS_DIGITS = 2;
const OTHER_CODE = '__other';
const EDUCATION_LEVELS = ['diploma', 'associate', 'bachelor', 'master', 'doctorate'] as const;
const EDUCATION_FIELDS = ['nursing', 'midwifery', 'anesthesia', 'operating_room', 'public_health'] as const;
interface ProfileFormValues {
avatarUrl: string | null;
bio: string;
years: string;
educationLevel: string;
educationLevelOther: string;
educationField: string;
educationFieldOther: string;
specializations: string[];
}
function parseSpecializations(json: string): string[] {
try {
const parsed = JSON.parse(json);
@@ -24,13 +49,36 @@ function parseSpecializations(json: string): string[] {
}
}
/** Nurse profile bootstrap (B7 header): avatar + bio + years + qualifications. */
/**
* Splits a stored free-text value into the (select code, "other" free-text) pair the form edits: a
* value the preset list knows becomes the code, anything else becomes «سایر» + the raw text.
*/
function splitPreset(stored: string, presets: readonly string[]): { code: string; other: string } {
if (presets.includes(stored)) return { code: stored, other: '' };
return stored ? { code: OTHER_CODE, other: stored } : { code: '', other: '' };
}
/** Nurse profile bootstrap (B7 header): avatar + bio + experience + qualifications. */
export default function NurseProfilePage() {
const { data: profile, isLoading } = useNurseProfile();
if (isLoading) return <AppLoading />;
return <NurseProfileForm initial={profile ?? null} />;
}
/**
* The nurse's public-facing profile, rebuilt around three named questions instead of one undivided
* column of inputs.
*
* What it replaced: a flat stack of avatar bio years two selects two conditional "other"
* fields a chip row a save button, with no headings and no statement of what any of it was for.
* The nurse could not tell which fields families actually see, which were required, or how much was
* left and every keystroke re-rendered the trust badge, the verification banner and the uploader
* along with the field being typed into, because each input owned a `useState` at page level.
*
* Now: `FormSection` groups the fields into معرفی / تجربه و تحصیلات / تخصصها, each saying who the
* answer is for; react-hook-form owns the values so a keystroke re-renders one field; and validation
* lives on the field it governs rather than in a hand-rolled block at the top of the submit handler.
*/
const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({ initial }) => {
const t = useTranslations('nurseProfile');
const tv = useTranslations('verification');
@@ -43,28 +91,29 @@ const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({
const badgeState = ownBadgeState(verificationStatus);
const fileInputRef = useRef<HTMLInputElement>(null);
const [avatarUrl, setAvatarUrl] = useState<string | null>(initial?.avatarUrl ?? null);
const [bio, setBio] = useState(initial?.bio ?? '');
const [years, setYears] = useState(initial ? String(initial.yearsOfExperience) : '');
const [yearsError, setYearsError] = useState(false);
const level = splitPreset(initial?.educationLevel ?? '', EDUCATION_LEVELS);
const field = splitPreset(initial?.educationField ?? '', EDUCATION_FIELDS);
const initialLevel = initial?.educationLevel ?? '';
const initialField = initial?.educationField ?? '';
const [educationLevel, setEducationLevel] = useState(
(EDUCATION_LEVELS as readonly string[]).includes(initialLevel) ? initialLevel : initialLevel ? OTHER_CODE : '',
);
const [educationLevelOther, setEducationLevelOther] = useState(
(EDUCATION_LEVELS as readonly string[]).includes(initialLevel) ? '' : initialLevel,
);
const [educationField, setEducationField] = useState(
(EDUCATION_FIELDS as readonly string[]).includes(initialField) ? initialField : initialField ? OTHER_CODE : '',
);
const [educationFieldOther, setEducationFieldOther] = useState(
(EDUCATION_FIELDS as readonly string[]).includes(initialField) ? '' : initialField,
);
const [specializations, setSpecializations] = useState<string[]>(
parseSpecializations(initial?.specializationsJson ?? '[]'),
);
const form = useForm<ProfileFormValues>({
mode: 'onTouched',
defaultValues: {
avatarUrl: initial?.avatarUrl ?? null,
bio: initial?.bio ?? '',
years: initial ? String(initial.yearsOfExperience) : '',
educationLevel: level.code,
educationLevelOther: level.other,
educationField: field.code,
educationFieldOther: field.other,
specializations: parseSpecializations(initial?.specializationsJson ?? '[]'),
},
});
const { control, handleSubmit, setValue, getValues } = form;
// Only these three drive conditional rendering, so they are the only fields worth subscribing the
// page to — the rest re-render nothing but themselves.
const avatarUrl = useWatch({ control, name: 'avatarUrl' });
const educationLevel = useWatch({ control, name: 'educationLevel' });
const educationField = useWatch({ control, name: 'educationField' });
// A staged-but-unsaved avatar must never be silently discarded — warn on reload/tab-close.
const avatarDirty = avatarUrl !== (initial?.avatarUrl ?? null);
@@ -79,224 +128,199 @@ const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({
return () => window.removeEventListener('beforeunload', handler);
}, [avatarDirty]);
const pickFile = () => fileInputRef.current?.click();
const onFileSelected = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) return;
uploadAvatar.mutate(file, {
onSuccess: (result) => setAvatarUrl(result.url),
onSuccess: (result) => setValue('avatarUrl', result.url, { shouldDirty: true }),
onError: () => enqueueSnackbar(t('avatar_upload_error'), { variant: 'error' }),
});
};
const toggleSpecialty = (value: string) =>
setSpecializations((prev) => (prev.includes(value) ? prev.filter((item) => item !== value) : [...prev, value]));
const handleSave = () => {
const trimmed = years.trim();
const yearsNum = trimmed === '' ? 0 : Number(trimmed);
const yearsInvalid = !Number.isInteger(yearsNum) || yearsNum < 0 || yearsNum > MAX_YEARS;
setYearsError(yearsInvalid);
if (yearsInvalid) return;
const resolvedLevel = educationLevel === OTHER_CODE ? educationLevelOther.trim() : educationLevel;
const resolvedField = educationField === OTHER_CODE ? educationFieldOther.trim() : educationField;
const save = (values: ProfileFormValues) => {
const resolvedLevel =
values.educationLevel === OTHER_CODE ? values.educationLevelOther.trim() : values.educationLevel;
const resolvedField =
values.educationField === OTHER_CODE ? values.educationFieldOther.trim() : values.educationField;
upsert.mutate(
{
bio: bio.trim(),
yearsOfExperience: yearsNum,
bio: values.bio.trim(),
yearsOfExperience: values.years.trim() === '' ? 0 : Number(values.years),
educationLevel: resolvedLevel,
educationField: resolvedField,
specializationsJson: JSON.stringify(specializations),
avatarUrl,
specializationsJson: JSON.stringify(values.specializations),
avatarUrl: values.avatarUrl,
},
{
onSuccess: () => enqueueSnackbar(t('saved'), { variant: 'success' }),
onSuccess: () => {
enqueueSnackbar(t('saved'), { variant: 'success' });
// Re-baseline so the beforeunload guard and `isDirty` stop reporting saved work as unsaved.
form.reset(getValues());
},
onError: () => enqueueSnackbar(t('save_error'), { variant: 'error' }),
},
);
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 560 }}>
<Box>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center', flexWrap: 'wrap' }}>
<Typography variant="h5" component="h1">
{t('title')}
</Typography>
{/* The public trust signal on the nurse's own profile — the same badge f6 reuses in search. */}
<TrustBadge state={badgeState} />
</Stack>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('subtitle')}
</Typography>
</Box>
<FormProvider {...form}>
<Box
component="form"
noValidate
onSubmit={handleSubmit(save)}
sx={{ display: 'flex', flexDirection: 'column', gap: 2.5, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}
>
<PageHeader title={t('title')} subtitle={t('subtitle')} meta={<TrustBadge state={badgeState} />} />
{/* Blocked-until-verified banner — shown until the aggregate is approved (incl. the expired state). */}
{badgeState !== 'verified' ? (
<Paper
elevation={0}
sx={{ p: 2, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider', borderInlineStartWidth: 4, borderInlineStartColor: 'var(--bal-warning)' }}
>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'flex-start' }}>
<AppIcon icon="warning" size={24} color="var(--bal-warning)" />
<Stack sx={{ gap: 1, flexGrow: 1 }}>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{/* Blocked-until-verified nudge — shown until the aggregate is approved (incl. the expired state). */}
{badgeState !== 'verified' ? (
<AccentCard tone="warning" padding="sm">
<Stack sx={{ gap: 1 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon="warning" size={20} color="var(--bal-warning)" />
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('unverified_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('unverified_body')}
</Typography>
</Box>
</Stack>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('unverified_body')}
</Typography>
<AppButton
color="primary"
variant="outlined"
variant="text"
endIcon="forward"
to={`/${locale}${ROUTES.NURSE_VERIFICATION}`}
sx={{ alignSelf: 'flex-start' }}
>
{t('unverified_cta')}
</AppButton>
</Stack>
</AccentCard>
) : null}
<FormSection title={t('section_intro_title')} description={t('section_intro_description')} icon="account">
<Stack direction="row" sx={{ gap: 2, alignItems: 'center' }}>
<Avatar src={avatarUrl ?? undefined} sx={{ width: 64, height: 64, bgcolor: 'var(--bal-primary-soft)' }}>
{avatarUrl ? null : <AppIcon icon="account" size={32} color="var(--bal-primary)" />}
</Avatar>
<Stack sx={{ gap: 0.5, minWidth: 0 }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('photo_hint')}
</Typography>
<AppButton
variant="outlined"
color="primary"
startIcon="camera"
onClick={() => fileInputRef.current?.click()}
disabled={uploadAvatar.isPending}
sx={{ alignSelf: 'flex-start' }}
>
{uploadAvatar.isPending ? t('uploading') : t('upload')}
</AppButton>
<input ref={fileInputRef} type="file" accept="image/*" hidden onChange={onFileSelected} />
</Stack>
</Stack>
</Paper>
) : null}
<Stack direction="row" sx={{ gap: 2, alignItems: 'center' }}>
<Avatar src={avatarUrl ?? undefined} sx={{ width: 72, height: 72, bgcolor: 'var(--bal-primary-soft)' }}>
{avatarUrl ? null : <AppIcon icon="account" size={36} color="var(--bal-primary)" />}
</Avatar>
<Stack sx={{ gap: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('photo')}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('photo_hint')}
</Typography>
<AppButton
variant="outlined"
color="primary"
startIcon="camera"
onClick={pickFile}
disabled={uploadAvatar.isPending}
sx={{ mt: 0.5, alignSelf: 'flex-start' }}
>
{uploadAvatar.isPending ? t('uploading') : t('upload')}
<RhfTextField<ProfileFormValues>
name="bio"
label={t('bio')}
helperText={t('bio_hint')}
multiline
minRows={3}
fullWidth
/>
</FormSection>
<FormSection
title={t('section_experience_title')}
description={t('section_experience_description')}
icon="license"
>
<RhfTextField<ProfileFormValues>
name="years"
label={t('years')}
transform={(raw) => digitsOnly(raw).slice(0, MAX_YEARS_DIGITS)}
rules={{
validate: (value) => {
const trimmed = String(value ?? '').trim();
if (trimmed === '') return true;
const parsed = Number(trimmed);
return (Number.isInteger(parsed) && parsed >= 0 && parsed <= MAX_YEARS) || t('years_invalid');
},
}}
slotProps={{ htmlInput: { dir: 'ltr', inputMode: 'numeric', style: { textAlign: 'start' } } }}
sx={{ maxWidth: 200 }}
/>
<Stack direction={{ xs: 'column', sm: 'row' }} sx={{ gap: 2 }}>
<RhfTextField<ProfileFormValues> name="educationLevel" select label={t('education_level_label')} fullWidth>
{EDUCATION_LEVELS.map((code) => (
<MenuItem key={code} value={code}>
{t(`education_level_${code}`)}
</MenuItem>
))}
<MenuItem value={OTHER_CODE}>{t('education_other')}</MenuItem>
</RhfTextField>
<RhfTextField<ProfileFormValues> name="educationField" select label={t('education_field_label')} fullWidth>
{EDUCATION_FIELDS.map((code) => (
<MenuItem key={code} value={code}>
{t(`education_field_${code}`)}
</MenuItem>
))}
<MenuItem value={OTHER_CODE}>{t('education_other')}</MenuItem>
</RhfTextField>
</Stack>
{educationLevel === OTHER_CODE ? (
<RhfTextField<ProfileFormValues>
name="educationLevelOther"
label={t('education_level_other_label')}
rules={{ required: t('education_other_required') }}
fullWidth
/>
) : null}
{educationField === OTHER_CODE ? (
<RhfTextField<ProfileFormValues>
name="educationFieldOther"
label={t('education_field_other_label')}
rules={{ required: t('education_other_required') }}
fullWidth
/>
) : null}
</FormSection>
<FormSection
title={t('specializations_label')}
description={t('section_specializations_description')}
icon="clinical"
optional
optionalLabel={tc('optional')}
>
<RhfChipSelect<ProfileFormValues>
name="specializations"
options={SPECIALTY_PRESETS.map((code) => ({
code,
label: tv.has(`specialty_${code}`) ? tv(`specialty_${code}`) : code,
}))}
allowCustomValues
/>
</FormSection>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<AppButton type="submit" color="primary" variant="contained" disabled={upsert.isPending}>
{upsert.isPending ? tc('saving') : t('save')}
</AppButton>
<AppButton variant="text" color="primary" startIcon="account" to={`/${locale}${ROUTES.NURSE_PROFILE_PREVIEW}`}>
{t('preview_cta')}
</AppButton>
<input ref={fileInputRef} type="file" accept="image/*" hidden onChange={onFileSelected} />
</Stack>
</Stack>
<TextField
label={t('bio')}
value={bio}
onChange={(e) => setBio(e.target.value)}
helperText={t('bio_hint')}
multiline
minRows={3}
fullWidth
/>
<TextField
label={t('years')}
value={years}
onChange={(e) => {
setYears(e.target.value.replace(/\D/g, '').slice(0, 2));
if (yearsError) setYearsError(false);
}}
error={yearsError}
helperText={yearsError ? t('years_invalid') : undefined}
slotProps={{ htmlInput: { dir: 'ltr', inputMode: 'numeric', style: { textAlign: 'start' } } }}
sx={{ maxWidth: 200 }}
/>
<Stack direction={{ xs: 'column', sm: 'row' }} sx={{ gap: 2 }}>
<TextField select label={t('education_level_label')} value={educationLevel} onChange={(e) => setEducationLevel(e.target.value)} fullWidth>
{EDUCATION_LEVELS.map((code) => (
<MenuItem key={code} value={code}>
{t(`education_level_${code}`)}
</MenuItem>
))}
<MenuItem value={OTHER_CODE}>{t('education_other')}</MenuItem>
</TextField>
<TextField select label={t('education_field_label')} value={educationField} onChange={(e) => setEducationField(e.target.value)} fullWidth>
{EDUCATION_FIELDS.map((code) => (
<MenuItem key={code} value={code}>
{t(`education_field_${code}`)}
</MenuItem>
))}
<MenuItem value={OTHER_CODE}>{t('education_other')}</MenuItem>
</TextField>
</Stack>
{educationLevel === OTHER_CODE ? (
<TextField
label={t('education_level_other_label')}
value={educationLevelOther}
onChange={(e) => setEducationLevelOther(e.target.value)}
fullWidth
/>
) : null}
{educationField === OTHER_CODE ? (
<TextField
label={t('education_field_other_label')}
value={educationFieldOther}
onChange={(e) => setEducationFieldOther(e.target.value)}
fullWidth
/>
) : null}
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('specializations_label')}
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('deferred_services')}
</Typography>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
{SPECIALTY_PRESETS.map((code) => {
const selected = specializations.includes(code);
return (
<Chip
key={code}
label={tv.has(`specialty_${code}`) ? tv(`specialty_${code}`) : code}
onClick={() => toggleSpecialty(code)}
variant={selected ? 'filled' : 'outlined'}
sx={{
fontWeight: 500,
backgroundColor: selected ? 'var(--bal-primary)' : 'transparent',
color: selected ? 'var(--bal-primary-contrast)' : 'var(--bal-primary)',
borderColor: 'var(--bal-primary)',
}}
/>
);
})}
</Stack>
</Stack>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('deferred_services')}
</Typography>
<AppButton
variant="text"
color="primary"
startIcon="account"
to={`/${locale}${ROUTES.NURSE_PROFILE_PREVIEW}`}
sx={{ alignSelf: 'flex-start' }}
>
{t('preview_cta')}
</AppButton>
<AppButton
color="primary"
variant="contained"
onClick={handleSave}
disabled={upsert.isPending}
sx={{ alignSelf: 'flex-start' }}
>
{upsert.isPending ? tc('saving') : t('save')}
</AppButton>
</Box>
</Box>
</FormProvider>
);
};
@@ -1,9 +1,24 @@
'use client';
import { FunctionComponent, useMemo, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form';
import { useSnackbar } from 'notistack';
import { Box, Chip, MenuItem, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material';
import { AccentCard, AppButton, AppIcon, AppLoading, CategoryTile, ErrorState, StepperHeader, VariantCard } from '@/components';
import { Box, Chip, MenuItem, Skeleton, Stack, TextField, Typography } from '@mui/material';
import {
AccentCard,
AppButton,
AppIcon,
AppLoading,
CategoryTile,
ErrorState,
FormSection,
PageHeader,
RhfTextField,
StepperHeader,
SurfaceCard,
VariantCard,
} from '@/components';
import { CONTENT_MAX_WIDTH } from '@/components/config';
import { ApiError } from '@/lib/api/errors';
import { digitsOnly, rialToToman, tomanToRial } from '@/utils';
import {
@@ -23,7 +38,7 @@ import {
} from '@/services/catalog/types';
interface VariantBuilderProps {
/** `null` = create (3-step stepper); a variant = edit (category/options locked, price form only). */
/** `null` = create (stepped flow); a variant = edit (category/options locked, price form only). */
initial: NurseServiceVariant | null;
onDone: () => void;
onCancel: () => void;
@@ -35,15 +50,40 @@ const DEFAULT_UNIT: PriceUnit = 'per_hour';
const MAX_PRICE_DIGITS = 12;
const MAX_DURATION_DIGITS = 4;
type StepKey = 'category' | 'options' | 'price';
interface VariantFormValues {
categoryId: number | null;
/** Option-group id → chosen value id. One field, so a category switch clears the whole answer set. */
options: Record<number, number>;
priceToman: string;
priceUnit: PriceUnit;
duration: string;
/** `null` until the nurse types over the auto-generated name — blank means "let the server name it". */
displayNameOverride: string | null;
}
/**
* The nurse variant builder (`CreateVariant` / `UpdateVariant`).
*
* **Create** is a 3-step stepper: pick category answer required/optional option groups price +
* unit + duration. Every `is_required` group must be answered before advancing; the price is entered
* in **Toman** and converted to an IRR digit-string at the field boundary (`tomanToRial`, integer-safe,
* never a float); the estimated total is shown only from `price` × `sessionCount`, never `price` alone;
* `display_name` auto-generates from the chosen labels and is editable (left blank the server
* generates it). A duplicate identical listing (`409`) shows a friendly inline warning.
* **Create** walks category options price. Two things make the flow deterministic where it
* previously was not:
*
* 1. **A step that has nothing to ask is not shown.** Categories with no option groups used to get a
* middle step whose entire content was "این دسته گزینه‌ای برای تنظیم ندارد" plus a Next button.
* The step list is now derived from the loaded groups, so those categories go straight to pricing.
* 2. **Advancing is gated *before* the tap, not after it.** The old Next button was always enabled and
* surfaced an error only once pressed, from a separate `optionsError` flag. Now the unanswered
* required groups are named under the button while it is disabled, so the blocker is visible
* without probing for it.
*
* The final step is a review as well as a form: the chosen category and options are recapped as chips
* beside the live `VariantCard`, so the listing can be checked without stepping backwards.
*
* Money still crosses the field boundary exactly once the price is entered in **Toman** and
* converted to an IRR digit-string via `tomanToRial` (integer-safe, never a float); the estimated
* total is shown only from `price` × `sessionCount`, never `price` alone. A duplicate identical
* listing (`409`) shows a friendly inline warning that offers to edit the colliding listing instead.
*
* **Edit** locks the category + option-set (changing them would change identity) and edits only
* price/unit/duration/display via `update`.
@@ -61,21 +101,29 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
const updateVariant = useUpdateVariant();
const submitting = createVariant.isPending || updateVariant.isPending;
// --- Create-only state (category → options) ---
const [activeStep, setActiveStep] = useState(0);
const [categoryId, setCategoryId] = useState<number | null>(initial?.serviceCategoryId ?? null);
const [selectedOptions, setSelectedOptions] = useState<Record<number, number>>({});
const [optionsError, setOptionsError] = useState(false);
// --- Shared price state (both create step 3 and edit) ---
// Pre-fill the price field in Toman (the wire carries IRR Rials); the money util does the ÷10.
const [priceToman, setPriceToman] = useState(initial ? String(rialToToman(initial.price)) : '');
const [priceUnit, setPriceUnit] = useState<PriceUnit>(initial?.priceUnit ?? DEFAULT_UNIT);
const [durationStr, setDurationStr] = useState(initial?.sessionCount ? String(initial.sessionCount) : '');
const [displayNameOverride, setDisplayNameOverride] = useState<string | null>(null);
const [priceError, setPriceError] = useState(false);
const [step, setStep] = useState<StepKey>(isEdit ? 'price' : 'category');
const [duplicate, setDuplicate] = useState(false);
const form = useForm<VariantFormValues>({
mode: 'onTouched',
defaultValues: {
categoryId: initial?.serviceCategoryId ?? null,
options: {},
// Pre-fill the price field in Toman (the wire carries IRR Rials); the money util does the ÷10.
priceToman: initial ? String(rialToToman(initial.price)) : '',
priceUnit: initial?.priceUnit ?? DEFAULT_UNIT,
duration: initial?.sessionCount ? String(initial.sessionCount) : '',
displayNameOverride: null,
},
});
const { control, handleSubmit, setValue } = form;
const categoryId = useWatch({ control, name: 'categoryId' });
const selectedOptions = useWatch({ control, name: 'options' });
const priceToman = useWatch({ control, name: 'priceToman' });
const priceUnit = useWatch({ control, name: 'priceUnit' });
const duration = useWatch({ control, name: 'duration' });
const displayNameOverride = useWatch({ control, name: 'displayNameOverride' });
const categoriesQuery = useServiceCategories();
const categories = categoriesQuery.data?.items ?? [];
const optionGroupsQuery = useCategoryOptionGroups(isEdit ? null : categoryId);
@@ -85,6 +133,21 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
const selectedCategory = categories.find((category) => category.id === categoryId) ?? null;
const missingRequiredGroups = groups.filter((group) => group.isRequired && selectedOptions[group.id] == null);
// A category with no option groups has no question to ask, so its step doesn't exist. Until the
// groups for the chosen category have actually loaded the answer is unknown, and the assumption is
// "there is an options step" — that way the step count only ever collapses for a category proven to
// have none, instead of starting at two and growing the moment a category is tapped.
const optionsStepUnknown = categoryId == null || optionGroupsQuery.isLoading || optionGroupsQuery.isFetching;
const hasOptionsStep = !isEdit && (optionsStepUnknown || groups.length > 0);
const effectiveStep: StepKey = step === 'options' && !hasOptionsStep ? 'price' : step;
const visibleSteps: StepKey[] = hasOptionsStep ? ['category', 'options', 'price'] : ['category', 'price'];
const stepLabels: Record<StepKey, string> = {
category: t('step_category'),
options: t('step_options'),
price: t('step_price'),
};
// Reuses the already-cached offerings list (MyServicesList holds the same query) to resolve which
// existing listing a 409 duplicate collided with, so the recovery can offer "edit that one" directly.
const myVariantsQuery = useMyVariants();
@@ -118,53 +181,51 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
const priceValid = priceToman.length > 0 && BigInt(priceToman) > BigInt(0);
const irr = priceValid ? tomanToRial(priceToman) : null;
const durationInt = durationStr ? Number(durationStr) : 0;
const durationInt = duration ? Number(duration) : 0;
const sessionCount = durationInt > 0 ? durationInt : null;
const selectCategory = (id: number) => {
if (id === categoryId) return;
// Switching category invalidates the previous category's option answers + auto-name.
setCategoryId(id);
setSelectedOptions({});
setDisplayNameOverride(null);
setOptionsError(false);
setValue('categoryId', id, { shouldDirty: true });
setValue('options', {});
setValue('displayNameOverride', null);
};
const changeOption = (groupId: number, valueId: number | null) => {
setOptionsError(false);
const next = { ...selectedOptions };
if (valueId == null) delete next[groupId];
else next[groupId] = valueId;
// A manual displayName override is intentionally left untouched; the auto-name preview tracks
// option changes only while the field hasn't been overridden (displayValue = override ?? autoName).
setSelectedOptions((prev) => {
const next = { ...prev };
if (valueId == null) delete next[groupId];
else next[groupId] = valueId;
return next;
});
setValue('options', next, { shouldDirty: true });
};
const goNextFromOptions = () => {
if (missingRequiredGroups.length > 0) {
setOptionsError(true);
const goNext = () => {
if (effectiveStep === 'category') {
setStep(hasOptionsStep ? 'options' : 'price');
return;
}
setActiveStep(2);
setStep('price');
};
const validatePrice = () => {
if (!priceValid) {
setPriceError(true);
return false;
const goBack = () => {
if (effectiveStep === 'price' && !isEdit) {
setStep(hasOptionsStep ? 'options' : 'category');
return;
}
return true;
setStep('category');
};
const submit = () => {
if (!validatePrice() || irr == null) return;
const displayName = displayNameOverride?.trim() ? displayNameOverride.trim() : undefined;
const submit = (values: VariantFormValues) => {
// `handleSubmit` has already enforced the price rule; this is the type narrowing that lets the
// IRR string be passed on, not a second gate.
if (irr == null) return;
const displayName = values.displayNameOverride?.trim() ? values.displayNameOverride.trim() : undefined;
if (isEdit) {
updateVariant.mutate(
{ id: initial.id, input: { price: irr, priceUnit, sessionCount, displayName } },
{ id: initial.id, input: { price: irr, priceUnit: values.priceUnit, sessionCount, displayName } },
{
onSuccess: () => {
enqueueSnackbar(t('saved_toast'), { variant: 'success' });
@@ -176,12 +237,19 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
return;
}
const options: VariantOptionSelection[] = Object.entries(selectedOptions).map(([groupId, valueId]) => ({
const options: VariantOptionSelection[] = Object.entries(values.options).map(([groupId, valueId]) => ({
optionGroupId: Number(groupId),
optionValueId: valueId,
}));
createVariant.mutate(
{ serviceCategoryId: categoryId as number, options, price: irr, priceUnit, sessionCount, displayName },
{
serviceCategoryId: values.categoryId as number,
options,
price: irr,
priceUnit: values.priceUnit,
sessionCount,
displayName,
},
{
onSuccess: () => {
enqueueSnackbar(t('created_toast'), { variant: 'success' });
@@ -196,13 +264,13 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
);
};
// Step 3's live preview — the actual VariantCard, so the nurse sees the listing they're composing,
// not just an abstract price readout.
// The price step's live preview — the actual VariantCard, so the nurse sees the listing they're
// composing, not just an abstract price readout.
const previewVariant: NurseServiceVariant = {
id: 0,
serviceCategoryId: categoryId ?? 0,
categoryNameFa: selectedCategory?.nameFa ?? '',
categoryNameEn: selectedCategory?.nameEn ?? '',
categoryNameFa: selectedCategory?.nameFa ?? initial?.categoryNameFa ?? '',
categoryNameEn: selectedCategory?.nameEn ?? initial?.categoryNameEn ?? '',
price: irr ?? '0',
priceUnit,
sessionCount,
@@ -211,68 +279,119 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
options: [],
};
/** Category + chosen options, so the last step doubles as a review of the first two. */
const recapChips = isEdit
? initial.options.map((option) => ({
key: String(option.optionGroupId),
label: `${pickCatalogName({ nameFa: option.groupNameFa, nameEn: option.groupNameEn }, locale)}: ${pickCatalogName({ nameFa: option.valueNameFa, nameEn: option.valueNameEn }, locale)}`,
}))
: groups.flatMap((group) => {
const value = group.values.find((candidate) => candidate.id === selectedOptions[group.id]);
return value
? [{ key: String(group.id), label: `${pickCatalogName(group, locale)}: ${pickCatalogName(value, locale)}` }]
: [];
});
const priceStep = (
<Stack sx={{ gap: 2.5 }}>
<TextField
label={t('price_label')}
value={priceToman}
onChange={(event) => {
setPriceToman(digitsOnly(event.target.value).slice(0, MAX_PRICE_DIGITS));
if (priceError) setPriceError(false);
if (duplicate) setDuplicate(false);
}}
error={priceError}
helperText={priceError ? t('price_required') : t('price_hint')}
slotProps={{ htmlInput: { dir: 'ltr', inputMode: 'numeric', style: { textAlign: 'start' } } }}
fullWidth
/>
<FormSection title={t('section_recap_title')} description={t('section_recap_description')} icon="category">
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{selectedCategory
? pickCatalogName(selectedCategory, locale)
: initial
? pickCatalogName({ nameFa: initial.categoryNameFa, nameEn: initial.categoryNameEn }, locale)
: ''}
</Typography>
{isEdit ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('category_locked')}
</Typography>
) : null}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75 }}>
{recapChips.length > 0 ? (
recapChips.map((chip) => (
<Chip key={chip.key} size="small" label={chip.label} sx={{ bgcolor: 'var(--bal-primary-soft)' }} />
))
) : (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('summary_none')}
</Typography>
)}
</Box>
</Stack>
</FormSection>
<Stack direction={{ xs: 'column', sm: 'row' }} sx={{ gap: 2 }}>
<TextField
select
label={t('unit_label')}
value={priceUnit}
onChange={(event) => setPriceUnit(event.target.value as PriceUnit)}
fullWidth
>
{PRICE_UNITS.map((unit) => (
<MenuItem key={unit} value={unit}>
{tCatalog(`unit_${unit}`)}
</MenuItem>
))}
</TextField>
<TextField
label={t('duration_label')}
value={durationStr}
onChange={(event) => setDurationStr(digitsOnly(event.target.value).slice(0, MAX_DURATION_DIGITS))}
helperText={t('duration_hint')}
<FormSection title={t('section_price_title')} description={t('price_hint')} icon="earnings">
<RhfTextField<VariantFormValues>
name="priceToman"
label={t('price_label')}
transform={(raw) => digitsOnly(raw).slice(0, MAX_PRICE_DIGITS)}
rules={{
validate: (value) => {
const raw = String(value ?? '');
return (raw.length > 0 && BigInt(raw) > BigInt(0)) || t('price_required');
},
}}
slotProps={{ htmlInput: { dir: 'ltr', inputMode: 'numeric', style: { textAlign: 'start' } } }}
fullWidth
/>
</Stack>
{irr ? (
<Stack direction={{ xs: 'column', sm: 'row' }} sx={{ gap: 2 }}>
<RhfTextField<VariantFormValues> name="priceUnit" select label={t('unit_label')} fullWidth>
{PRICE_UNITS.map((unit) => (
<MenuItem key={unit} value={unit}>
{tCatalog(`unit_${unit}`)}
</MenuItem>
))}
</RhfTextField>
<RhfTextField<VariantFormValues>
name="duration"
label={t('duration_label')}
helperText={t('duration_hint')}
transform={(raw) => digitsOnly(raw).slice(0, MAX_DURATION_DIGITS)}
slotProps={{ htmlInput: { dir: 'ltr', inputMode: 'numeric', style: { textAlign: 'start' } } }}
fullWidth
/>
</Stack>
{!sessionCount && irr ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('rate_note')}
</Typography>
) : null}
</FormSection>
<FormSection title={t('section_listing_title')} description={t('display_name_hint')} icon="services">
{/* Not `RhfTextField`: what is *stored* is the override alone (blank the server generates
the name), while what is *shown* falls back to the live auto-generated name. One field,
two values the one case in this form where the display value isn't the form value. */}
<Controller
control={control}
name="displayNameOverride"
render={({ field }) => (
<TextField
label={t('display_name_label')}
name={field.name}
inputRef={field.ref}
value={field.value ?? autoName}
onChange={(event) => field.onChange(event.target.value)}
onBlur={field.onBlur}
fullWidth
/>
)}
/>
<Stack sx={{ gap: 1 }}>
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 700 }}>
{t('preview_heading')}
</Typography>
{/* Shown even before a price is entered the preview is what "comprehensive" means here:
the nurse should see the shape of the listing while composing it, not only once it's valid. */}
<VariantCard variant={previewVariant} interactive={false} />
{!sessionCount ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('rate_note')}
</Typography>
) : null}
</Stack>
) : null}
<TextField
label={t('display_name_label')}
value={displayValue}
onChange={(event) => setDisplayNameOverride(event.target.value)}
helperText={t('display_name_hint')}
fullWidth
/>
</FormSection>
{duplicate ? (
<AccentCard tone="warning" padding="sm">
@@ -301,157 +420,70 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
</Stack>
);
// --- Edit mode: locked category + options summary, then the price form ---
if (isEdit) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 560 }}>
<Typography variant="h5" component="h1">
{t('builder_edit_title')}
</Typography>
<Paper elevation={0} sx={{ p: 2, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider' }}>
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{pickCatalogName({ nameFa: initial.categoryNameFa, nameEn: initial.categoryNameEn }, locale)}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('category_locked')}
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, mt: 0.5 }}>
{initial.options.length > 0 ? (
initial.options.map((option) => (
<Chip
key={option.optionGroupId}
size="small"
label={`${pickCatalogName({ nameFa: option.groupNameFa, nameEn: option.groupNameEn }, locale)}: ${pickCatalogName({ nameFa: option.valueNameFa, nameEn: option.valueNameEn }, locale)}`}
sx={{ bgcolor: 'var(--bal-primary-soft)' }}
/>
))
) : (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('summary_none')}
</Typography>
)}
</Box>
</Stack>
</Paper>
{priceStep}
<Stack direction="row" sx={{ gap: 1, justifyContent: 'flex-end' }}>
<AppButton variant="text" onClick={onCancel} disabled={submitting}>
{tc('cancel')}
</AppButton>
<AppButton color="primary" variant="contained" onClick={submit} disabled={submitting}>
{submitting ? tc('saving') : t('submit_save')}
</AppButton>
</Stack>
</Box>
);
}
// --- Create mode: the 3-step stepper ---
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, maxWidth: 560 }}>
<Typography variant="h5" component="h1">
{t('builder_add_title')}
</Typography>
<FormProvider {...form}>
<Box
component="form"
noValidate
onSubmit={handleSubmit(submit)}
sx={{ display: 'flex', flexDirection: 'column', gap: 2, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}
>
<PageHeader title={isEdit ? t('builder_edit_title') : t('builder_add_title')} />
<StepperHeader
steps={[t('step_category'), t('step_options'), t('step_price')]}
activeStep={activeStep}
/>
{isEdit ? null : (
<StepperHeader
steps={visibleSteps.map((key) => stepLabels[key])}
activeStep={visibleSteps.indexOf(effectiveStep)}
/>
)}
{activeStep === 0 ? (
<Stack sx={{ gap: 1.5 }}>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('category_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('category_subtitle')}
</Typography>
</Box>
{categoriesQuery.isLoading ? (
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 1.5 }}>
{[0, 1, 2, 3].map((key) => (
<Skeleton key={key} variant="rounded" height={116} sx={{ borderRadius: 'var(--bal-radius-md)' }} />
))}
</Box>
) : categoriesQuery.isError ? (
<Stack sx={{ gap: 1, alignItems: 'flex-start' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('categories_error')}
</Typography>
<AppButton variant="outlined" color="primary" onClick={() => categoriesQuery.refetch()}>
{tc('retry')}
</AppButton>
</Stack>
) : (
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 1.5 }}>
{categories.map((category) => (
<CategoryTile
key={category.id}
label={pickCatalogName(category, locale)}
iconKey={category.iconKey}
selected={categoryId === category.id}
onClick={() => selectCategory(category.id)}
/>
))}
</Box>
)}
</Stack>
) : null}
{effectiveStep === 'category' ? (
<FormSection title={t('category_title')} description={t('category_subtitle')} icon="category">
{categoriesQuery.isLoading ? (
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 1.5 }}>
{[0, 1, 2, 3].map((key) => (
<Skeleton key={key} variant="rounded" height={116} sx={{ borderRadius: 'var(--bal-radius-md)' }} />
))}
</Box>
) : categoriesQuery.isError ? (
<ErrorState message={t('categories_error')} retryLabel={tc('retry')} onRetry={() => categoriesQuery.refetch()} />
) : (
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 1.5 }}>
{categories.map((category) => (
<CategoryTile
key={category.id}
label={pickCatalogName(category, locale)}
iconKey={category.iconKey}
selected={categoryId === category.id}
onClick={() => selectCategory(category.id)}
/>
))}
</Box>
)}
</FormSection>
) : null}
{activeStep === 1 ? (
<Stack sx={{ gap: 2 }}>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('options_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('options_subtitle')}
</Typography>
</Box>
{optionGroupsQuery.isLoading ? (
<AppLoading />
) : optionGroupsQuery.isError ? (
// A failed fetch must never read as "this category has zero options" — that would let the
// nurse skip required options entirely. Block progression until the retry succeeds.
<ErrorState
message={t('options_error')}
retryLabel={tc('retry')}
onRetry={() => optionGroupsQuery.refetch()}
/>
) : groups.length === 0 ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('options_none')}
</Typography>
) : (
groups.map((group) => {
const isMissing = optionsError && group.isRequired && selectedOptions[group.id] == null;
return (
{effectiveStep === 'options' ? (
<FormSection title={t('options_title')} description={t('options_subtitle')} icon="tune">
{optionGroupsQuery.isLoading ? (
<AppLoading />
) : optionGroupsQuery.isError ? (
// A failed fetch must never read as "this category has zero options" — that would let the
// nurse skip required options entirely. Block progression until the retry succeeds.
<ErrorState message={t('options_error')} retryLabel={tc('retry')} onRetry={() => optionGroupsQuery.refetch()} />
) : (
groups.map((group) => (
<Stack key={group.id} sx={{ gap: 1 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{pickCatalogName(group, locale)}
</Typography>
{/* The required badge turns red on a blocked advance to point at the unanswered group. */}
<Chip
size="small"
label={group.isRequired ? t('required_badge') : t('optional_badge')}
sx={{
bgcolor: isMissing
? 'var(--bal-error)'
: group.isRequired
? 'var(--bal-primary-soft)'
: 'var(--bal-divider)',
color: isMissing
? 'var(--bal-error-contrast)'
: group.isRequired
? 'var(--bal-primary)'
: 'text.secondary',
bgcolor: group.isRequired ? 'var(--bal-primary-soft)' : 'var(--bal-divider)',
color: group.isRequired ? 'var(--bal-primary)' : 'text.secondary',
fontWeight: 500,
}}
/>
@@ -463,76 +495,64 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
<Chip
key={value.id}
label={pickCatalogName(value, locale)}
onClick={() => changeOption(group.id, selected ? null : value.id)}
aria-pressed={selected}
clickable
color={selected ? 'primary' : 'default'}
variant={selected ? 'filled' : 'outlined'}
sx={{
fontWeight: 500,
backgroundColor: selected ? 'var(--bal-primary)' : 'transparent',
color: selected ? 'var(--bal-primary-contrast)' : 'var(--bal-primary)',
borderColor: 'var(--bal-primary)',
}}
onClick={() => changeOption(group.id, selected ? null : value.id)}
/>
);
})}
</Stack>
</Stack>
);
})
)}
))
)}
</FormSection>
) : null}
{optionsError && missingRequiredGroups.length > 0 ? (
<Typography variant="body2" sx={{ color: 'var(--bal-error)', fontWeight: 500 }}>
{t('options_incomplete')}
</Typography>
) : null}
</Stack>
) : null}
{effectiveStep === 'price' ? priceStep : null}
{activeStep === 2 ? (
<Stack sx={{ gap: 1.5 }}>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('price_title')}
</Typography>
</Box>
{priceStep}
</Stack>
) : null}
{/* Names what is still missing while the button is disabled, instead of revealing it on tap. */}
{effectiveStep === 'options' && missingRequiredGroups.length > 0 ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('options_missing_named', {
groups: missingRequiredGroups.map((group) => pickCatalogName(group, locale)).join('),
})}
</Typography>
) : null}
<Stack direction="row" sx={{ gap: 1, justifyContent: 'space-between', mt: 1 }}>
<AppButton
variant="text"
onClick={activeStep === 0 ? onCancel : () => setActiveStep((step) => step - 1)}
disabled={submitting}
>
{activeStep === 0 ? tc('cancel') : tc('back')}
</AppButton>
<SurfaceCard padding="sm" sx={{ position: 'sticky', bottom: 'var(--bal-chrome-bottom, 0px)', zIndex: 1 }}>
<Stack direction="row" sx={{ gap: 1, justifyContent: 'space-between' }}>
<AppButton
variant="text"
onClick={effectiveStep === 'category' || isEdit ? onCancel : goBack}
disabled={submitting}
>
{effectiveStep === 'category' || isEdit ? tc('cancel') : tc('back')}
</AppButton>
{activeStep === 0 ? (
<AppButton
color="primary"
variant="contained"
onClick={() => setActiveStep(1)}
disabled={categoryId == null}
>
{t('next')}
</AppButton>
) : activeStep === 1 ? (
<AppButton
color="primary"
variant="contained"
onClick={goNextFromOptions}
disabled={optionGroupsQuery.isError}
>
{t('next')}
</AppButton>
) : (
<AppButton color="primary" variant="contained" onClick={submit} disabled={submitting}>
{submitting ? tc('saving') : t('submit_create')}
</AppButton>
)}
</Stack>
</Box>
{effectiveStep === 'price' ? (
<AppButton type="submit" color="primary" variant="contained" disabled={submitting}>
{submitting ? tc('saving') : isEdit ? t('submit_save') : t('submit_create')}
</AppButton>
) : (
<AppButton
color="primary"
variant="contained"
onClick={goNext}
disabled={
effectiveStep === 'category'
? categoryId == null
: optionGroupsQuery.isError || missingRequiredGroups.length > 0
}
>
{t('next')}
</AppButton>
)}
</Stack>
</SurfaceCard>
</Box>
</FormProvider>
);
};
@@ -1,10 +1,20 @@
'use client';
import { useMemo, useState } from 'react';
import { FunctionComponent, useMemo, 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, Chip, Paper, Stack, TextField, Typography } from '@mui/material';
import { AppButton, AppIcon, AppLoading, DocumentUpload, JalaliDateField } from '@/components';
import {
AppButton,
AppIcon,
AppLoading,
DocumentUpload,
FormSection,
RhfChipSelect,
RhfJalaliDateField,
RhfTextField,
} from '@/components';
import type { UploadedDocInfo } from '@/components';
import { CONTENT_MAX_WIDTH } from '@/components/config';
import { ROUTES } from '@/constants';
@@ -15,101 +25,42 @@ import {
useVerificationStatus,
} from '@/services/verification';
import { SPECIALTY_PRESETS } from '@/services/verification/types';
import type { VerificationStep } from '@/services/verification/types';
import type { VerificationStatus, VerificationStep } from '@/services/verification/types';
import { stepDescriptionKey, stepLabelKey } from '../verificationSteps';
import VerificationJourneyHeader from '../VerificationJourneyHeader';
const MANUAL_CREDENTIAL_CODES = ['moh_competency_license', 'ino_membership', 'criminal_record'];
interface CredentialsFormValues {
inoNumber: string;
specialties: string[];
issuingAuthority: string;
issuedAt: string | null;
expiresAt: string | null;
}
/**
* B5 professional credentials. Renders a `DocumentUpload` for **each manual credential step in the
* status** (data-driven a new manual step renders without a code change); each upload moves its step
* to `in_review` (manual admin review copy never claims an automated authority check).
*
* Hydrates from `status.credentialSubmission` (REQ-056, mock-tolerant): once the INO number has been
* recorded, the field locks into a "شمارهٔ نظام ثبت شد" summary never re-prompted as if lost, and
* never re-sent blank (the server's `CredentialDetailsInput.inoNumber` is required; the raw number is
* never read back by design, so re-submitting it isn't possible without the nurse re-entering it via
* "تغییر"). A returning, already-submitted nurse can still fix a **rejected** document directly (each
* upload takes effect immediately, no re-submit needed) and simply returns to the journey no dead
* disabled button, because there is no button to be dead.
* The whole screen used to be one undivided column: a number field, three uploaders, a chip row with
* its own add-a-custom-value sub-form, two date pickers and a submit button, with two independent
* `editingIno ? … : …` branches interleaved through it. Grouping the same content into four named
* sections شماره نظام / مدارک / تخصصها / جزئیات مدرک makes the two genuinely optional groups
* visibly optional and gives the "what still blocks submit?" answer somewhere to live.
*/
export default function CredentialsSubmitPage() {
const t = useTranslations('verification');
const locale = useLocale();
const router = useRouter();
const { enqueueSnackbar } = useSnackbar();
const { data: status, isLoading } = useVerificationStatus();
const uploadDocument = useUploadVerificationDocument();
const submitCredentials = useSubmitCredentials();
const submission = status?.credentialSubmission;
const [inoNumber, setInoNumber] = useState('');
const [inoError, setInoError] = useState(false);
const [editingIno, setEditingIno] = useState(true);
const [specialties, setSpecialties] = useState<string[]>([]);
const [customSpecialty, setCustomSpecialty] = useState('');
const [issuingAuthority, setIssuingAuthority] = useState('');
const [issuedAt, setIssuedAt] = useState<string | null>(null);
const [expiresAt, setExpiresAt] = useState<string | null>(null);
const [uploadedSteps, setUploadedSteps] = useState<Record<number, boolean>>({});
const [hydrated, setHydrated] = useState(false);
// Hydrate once from the server's read-back, adjusted directly during render (never in an effect —
// that would cascade an extra render) — and never overwrite what the nurse is actively editing.
if (!hydrated && submission) {
setHydrated(true);
setEditingIno(!submission.inoNumberSubmitted);
setSpecialties(submission.specialties);
setIssuingAuthority(submission.issuingAuthority ?? '');
setIssuedAt(submission.issuedAt ?? null);
setExpiresAt(submission.expiresAt ?? null);
}
const manualSteps = useMemo(
() => (status?.steps ?? []).filter((step) => MANUAL_CREDENTIAL_CODES.includes(step.code)),
[status],
);
const toggleSpecialty = (value: string) =>
setSpecialties((prev) => (prev.includes(value) ? prev.filter((item) => item !== value) : [...prev, value]));
const addCustomSpecialty = () => {
const value = customSpecialty.trim();
if (value && !specialties.includes(value)) setSpecialties((prev) => [...prev, value]);
setCustomSpecialty('');
};
const uploadToStep = (step: VerificationStep) => async (file: File, onProgress: (percent: number) => void) => {
const doc = await uploadDocument.mutateAsync({ stepId: step.id, file, onProgress });
return { name: doc.originalFileName ?? file.name, sizeBytes: doc.fileSizeBytes } satisfies UploadedDocInfo;
};
const handleSubmit = () => {
const inoValid = inoNumber.trim().length > 0;
setInoError(!inoValid);
if (!inoValid) return;
submitCredentials.mutate(
{
inoNumber: inoNumber.trim(),
specialties,
issuingAuthority: issuingAuthority.trim() || undefined,
issuedAt: issuedAt ?? undefined,
expiresAt: expiresAt ?? undefined,
},
{
onSuccess: () => {
enqueueSnackbar(t('credentials_submitted'), { variant: 'success' });
router.push(`/${locale}${ROUTES.NURSE_VERIFICATION_REVIEW}`);
},
onError: () => enqueueSnackbar(t('credentials_error'), { variant: 'error' }),
},
);
};
if (isLoading) return <AppLoading />;
if (!status || manualSteps.length === 0) {
@@ -126,227 +77,293 @@ export default function CredentialsSubmitPage() {
);
}
return <CredentialsForm status={status} manualSteps={manualSteps} />;
}
/**
* Hydrates from `status.credentialSubmission` (REQ-056, mock-tolerant) as react-hook-form
* `defaultValues` mounted only once the status query has resolved, which is what lets the server
* read-back *be* the initial form state instead of being copied into it by a render-time state
* adjustment. Once the INO number has been recorded the field locks into a "شمارهٔ نظام ثبت شد"
* summary never re-prompted as if lost, and never re-sent blank (the server's
* `CredentialDetailsInput.inoNumber` is required; the raw number is never read back by design, so
* re-submitting it isn't possible without the nurse re-entering it via "تغییر"). A returning,
* already-submitted nurse can still fix a **rejected** document directly (each upload takes effect
* immediately, no re-submit needed) and simply returns to the journey.
*/
const CredentialsForm: FunctionComponent<{ status: VerificationStatus; manualSteps: VerificationStep[] }> = ({
status,
manualSteps,
}) => {
const t = useTranslations('verification');
const tc = useTranslations('common');
const locale = useLocale();
const router = useRouter();
const { enqueueSnackbar } = useSnackbar();
const uploadDocument = useUploadVerificationDocument();
const submitCredentials = useSubmitCredentials();
const submission = status.credentialSubmission;
const [editingIno, setEditingIno] = useState(!submission?.inoNumberSubmitted);
const [customSpecialty, setCustomSpecialty] = useState('');
const [uploadedSteps, setUploadedSteps] = useState<Record<number, boolean>>({});
const form = useForm<CredentialsFormValues>({
mode: 'onTouched',
defaultValues: {
inoNumber: '',
specialties: submission?.specialties ?? [],
issuingAuthority: submission?.issuingAuthority ?? '',
issuedAt: submission?.issuedAt ?? null,
expiresAt: submission?.expiresAt ?? null,
},
});
const { control, handleSubmit, setValue } = form;
const specialties = useWatch({ control, name: 'specialties' });
const issuedAt = useWatch({ control, name: 'issuedAt' });
const expiresAt = useWatch({ control, name: 'expiresAt' });
const issuingAuthority = useWatch({ control, name: 'issuingAuthority' });
const addCustomSpecialty = () => {
const value = customSpecialty.trim();
if (value && !specialties.includes(value)) {
setValue('specialties', [...specialties, value], { shouldDirty: true });
}
setCustomSpecialty('');
};
const uploadToStep = (step: VerificationStep) => async (file: File, onProgress: (percent: number) => void) => {
const doc = await uploadDocument.mutateAsync({ stepId: step.id, file, onProgress });
return { name: doc.originalFileName ?? file.name, sizeBytes: doc.fileSizeBytes } satisfies UploadedDocInfo;
};
// A returning nurse with any manual step already on file (server truth) is never dead-ended: while
// actively (re-)entering the INO number, the gate also counts server-side documents, not only this
// session's uploads.
const hasAnyDocument =
Object.values(uploadedSteps).some(Boolean) ||
manualSteps.some((step) => step.status === 'in_review' || step.status === 'passed');
const canSubmit = hasAnyDocument && !submitCredentials.isPending;
const uploadedCount =
manualSteps.filter((step) => uploadedSteps[step.id] || step.status === 'in_review' || step.status === 'passed')
.length;
const hasAnyDocument = uploadedCount > 0;
const submit = (values: CredentialsFormValues) => {
submitCredentials.mutate(
{
inoNumber: values.inoNumber.trim(),
specialties: values.specialties,
issuingAuthority: values.issuingAuthority.trim() || undefined,
issuedAt: values.issuedAt ?? undefined,
expiresAt: values.expiresAt ?? undefined,
},
{
onSuccess: () => {
enqueueSnackbar(t('credentials_submitted'), { variant: 'success' });
router.push(`/${locale}${ROUTES.NURSE_VERIFICATION_REVIEW}`);
},
onError: () => enqueueSnackbar(t('credentials_error'), { variant: 'error' }),
},
);
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}>
<VerificationJourneyHeader group="credentials" />
<FormProvider {...form}>
<Box
component="form"
noValidate
onSubmit={handleSubmit(submit)}
sx={{ display: 'flex', flexDirection: 'column', gap: 2.5, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}
>
<VerificationJourneyHeader group="credentials" />
<Box>
<Typography variant="h6" component="h2" sx={{ fontWeight: 700 }}>
{t('credentials_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('credentials_subtitle')}
</Typography>
</Box>
{editingIno ? (
<TextField
label={t('ino_number_label')}
value={inoNumber}
onChange={(event) => {
setInoNumber(event.target.value);
if (inoError) setInoError(false);
}}
error={inoError}
helperText={inoError ? t('ino_number_required') : t('ino_number_hint')}
slotProps={{ htmlInput: { dir: 'ltr', style: { textAlign: 'start' } } }}
fullWidth
/>
) : (
<Stack
direction="row"
sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between', p: 1.5, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider' }}
<FormSection title={t('ino_number_label')} description={t('ino_number_hint')} icon="license">
{editingIno ? (
<RhfTextField<CredentialsFormValues>
name="inoNumber"
label={t('ino_number_label')}
rules={{ required: t('ino_number_required') }}
slotProps={{ htmlInput: { dir: 'ltr', style: { textAlign: 'start' } } }}
fullWidth
/>
) : (
<Stack
direction="row"
sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between', p: 1.5, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider' }}
>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon="verified" size={18} color="var(--bal-success)" />
<Typography variant="body2">{t('ino_number_submitted')}</Typography>
</Stack>
<AppButton variant="text" color="primary" onClick={() => setEditingIno(true)}>
{t('ino_number_change')}
</AppButton>
</Stack>
)}
</FormSection>
{/* One uploader per manual credential step data-driven from the status. Re-uploading a
rejected document always takes effect immediately, whether or not the INO number is locked. */}
<FormSection
title={t('credentials_documents_title')}
description={t('credentials_documents_description')}
icon="document"
status={
<Typography variant="caption" sx={{ color: 'text.secondary', flexShrink: 0 }}>
{t('credentials_documents_count', { done: uploadedCount, total: manualSteps.length })}
</Typography>
}
>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon="verified" size={18} color="var(--bal-success)" />
<Typography variant="body2">{t('ino_number_submitted')}</Typography>
</Stack>
<AppButton variant="text" color="primary" onClick={() => setEditingIno(true)}>
{t('ino_number_change')}
</AppButton>
</Stack>
)}
{manualSteps.map((step) => (
<DocumentUpload
key={step.code}
label={t.has(stepLabelKey(step.code)) ? t(stepLabelKey(step.code)) : step.displayName}
hint={t.has(stepDescriptionKey(step.code)) ? t(stepDescriptionKey(step.code)) : undefined}
onUpload={uploadToStep(step)}
onUploaded={() => setUploadedSteps((prev) => ({ ...prev, [step.id]: true }))}
rejected={step.status === 'failed'}
rejectionReason={step.failureReason ?? undefined}
existingDoc={step.status === 'in_review' || step.status === 'passed' ? { name: t('doc_uploaded') } : null}
/>
))}
{/* One uploader per manual credential step data-driven from the status. Re-uploading a
rejected document always takes effect immediately, whether or not the INO number is locked. */}
<Stack sx={{ gap: 2 }}>
{manualSteps.map((step) => (
<DocumentUpload
key={step.code}
label={t.has(stepLabelKey(step.code)) ? t(stepLabelKey(step.code)) : step.displayName}
hint={t.has(stepDescriptionKey(step.code)) ? t(stepDescriptionKey(step.code)) : undefined}
onUpload={uploadToStep(step)}
onUploaded={() => setUploadedSteps((prev) => ({ ...prev, [step.id]: true }))}
rejected={step.status === 'failed'}
rejectionReason={step.failureReason ?? undefined}
existingDoc={step.status === 'in_review' || step.status === 'passed' ? { name: t('doc_uploaded') } : null}
/>
))}
</Stack>
{editingIno ? (
<DocumentUpload label={t('education_label')} hint={t('education_hint')} onUpload={async (file) => ({ name: file.name })} />
) : null}
</FormSection>
{editingIno ? (
<DocumentUpload label={t('education_label')} hint={t('education_hint')} onUpload={async (file) => ({ name: file.name })} />
) : null}
<FormSection
title={t('specialties_label')}
description={t('specialties_hint')}
icon="clinical"
optional
optionalLabel={tc('optional')}
>
{editingIno ? (
<>
<RhfChipSelect<CredentialsFormValues>
name="specialties"
options={SPECIALTY_PRESETS.map((preset) => ({
code: preset,
label: t.has(`specialty_${preset}`) ? t(`specialty_${preset}`) : preset,
}))}
allowCustomValues
/>
<Stack direction="row" sx={{ gap: 1, alignItems: 'flex-start' }}>
<TextField
size="small"
placeholder={t('specialty_add_placeholder')}
value={customSpecialty}
onChange={(event) => setCustomSpecialty(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
addCustomSpecialty();
}
}}
/>
<AppButton variant="outlined" color="primary" startIcon="add" onClick={addCustomSpecialty}>
{t('specialty_add')}
</AppButton>
</Stack>
</>
) : specialties.length > 0 ? (
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
{specialties.map((value) => (
<Chip
key={value}
label={t.has(`specialty_${value}`) ? t(`specialty_${value}`) : value}
sx={{ fontWeight: 500, backgroundColor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)' }}
/>
))}
</Stack>
) : (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('summary_none')}
</Typography>
)}
</FormSection>
{/* Optional registry details the admin cross-checks issue/expiry feed the credential-expiry
sweep, so wrong dates are a correctness risk; a Jalali picker replaces the Gregorian-only
native input. */}
{editingIno ? (
<FormSection
title={t('registry_details_title')}
description={t('registry_details_description')}
icon="audit"
optional
optionalLabel={tc('optional')}
>
<RhfTextField<CredentialsFormValues> name="issuingAuthority" label={t('issuing_authority_label')} fullWidth />
<Stack direction="row" sx={{ gap: 1.5, flexWrap: 'wrap' }}>
<RhfJalaliDateField<CredentialsFormValues>
name="issuedAt"
label={t('issued_at_label')}
max={expiresAt ?? undefined}
sx={{ flex: 1, minWidth: 160 }}
/>
<RhfJalaliDateField<CredentialsFormValues>
name="expiresAt"
label={t('expires_at_label')}
min={issuedAt ?? undefined}
sx={{ flex: 1, minWidth: 160 }}
/>
</Stack>
</FormSection>
) : issuingAuthority || issuedAt || expiresAt ? (
<FormSection title={t('registry_details_title')} icon="audit">
{issuingAuthority ? <Typography variant="body2">{issuingAuthority}</Typography> : null}
{issuedAt || expiresAt ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{issuedAt ? formatShamsiDate(issuedAt, locale) : ''}
{issuedAt && expiresAt ? ' ' : ''}
{expiresAt ? formatShamsiDate(expiresAt, locale) : ''}
</Typography>
) : null}
</FormSection>
) : null}
<Paper
elevation={0}
sx={{ p: 1.5, borderRadius: 'var(--bal-radius-md)', bgcolor: 'var(--bal-primary-soft)', display: 'flex', gap: 1, alignItems: 'center' }}
>
<AppIcon icon="info" size={18} color="var(--bal-primary)" />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('manual_review_note')}
</Typography>
</Paper>
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('specialties_label')}
</Typography>
{editingIno ? (
<>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('specialties_hint')}
</Typography>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
{SPECIALTY_PRESETS.map((preset) => {
const selected = specialties.includes(preset);
return (
<Chip
key={preset}
label={t.has(`specialty_${preset}`) ? t(`specialty_${preset}`) : preset}
onClick={() => toggleSpecialty(preset)}
icon={selected ? <AppIcon icon="verified" size={16} color="var(--bal-primary-contrast)" /> : undefined}
variant={selected ? 'filled' : 'outlined'}
sx={{
fontWeight: 500,
backgroundColor: selected ? 'var(--bal-primary)' : 'transparent',
color: selected ? 'var(--bal-primary-contrast)' : 'var(--bal-primary)',
borderColor: 'var(--bal-primary)',
}}
/>
);
})}
{specialties
.filter((value) => !SPECIALTY_PRESETS.includes(value))
.map((value) => (
<Chip
key={value}
label={value}
onDelete={() => toggleSpecialty(value)}
sx={{ fontWeight: 500, backgroundColor: 'var(--bal-primary)', color: 'var(--bal-primary-contrast)' }}
/>
))}
</Stack>
<Stack direction="row" sx={{ gap: 1, alignItems: 'flex-start' }}>
<TextField
size="small"
placeholder={t('specialty_add_placeholder')}
value={customSpecialty}
onChange={(event) => setCustomSpecialty(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
addCustomSpecialty();
}
}}
/>
<AppButton variant="outlined" color="primary" startIcon="add" onClick={addCustomSpecialty}>
{t('specialty_add')}
{!hasAnyDocument ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('credentials_needs_document')}
</Typography>
) : null}
<Stack direction="row" sx={{ gap: 1 }}>
<AppButton
type="submit"
color="primary"
variant="contained"
startIcon="license"
disabled={!hasAnyDocument || submitCredentials.isPending}
>
{submitCredentials.isPending ? t('credentials_submitting') : t('credentials_submit')}
</AppButton>
<AppButton variant="text" color="primary" to={`/${locale}${ROUTES.NURSE_VERIFICATION}`}>
{t('back_to_checklist')}
</AppButton>
</Stack>
</>
) : specialties.length > 0 ? (
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
{specialties.map((value) => (
<Chip
key={value}
label={t.has(`specialty_${value}`) ? t(`specialty_${value}`) : value}
sx={{ fontWeight: 500, backgroundColor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)' }}
/>
))}
</Stack>
) : (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('summary_none')}
</Typography>
<AppButton color="primary" variant="contained" to={`/${locale}${ROUTES.NURSE_VERIFICATION}`} sx={{ alignSelf: 'flex-start' }}>
{t('back_to_checklist')}
</AppButton>
)}
</Stack>
{/* Optional registry details the admin cross-checks issue/expiry feed the credential-expiry sweep, so
wrong dates are a correctness risk; a Jalali picker replaces the Gregorian-only native input. */}
{editingIno ? (
<Stack sx={{ gap: 1.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('registry_details_label')}
</Typography>
<TextField
label={t('issuing_authority_label')}
value={issuingAuthority}
onChange={(event) => setIssuingAuthority(event.target.value)}
fullWidth
/>
<Stack direction="row" sx={{ gap: 1.5, flexWrap: 'wrap' }}>
<JalaliDateField
label={t('issued_at_label')}
value={issuedAt}
onChange={setIssuedAt}
max={expiresAt ?? undefined}
sx={{ flex: 1, minWidth: 160 }}
/>
<JalaliDateField
label={t('expires_at_label')}
value={expiresAt}
onChange={setExpiresAt}
min={issuedAt ?? undefined}
sx={{ flex: 1, minWidth: 160 }}
/>
</Stack>
</Stack>
) : issuingAuthority || issuedAt || expiresAt ? (
<Stack sx={{ gap: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('registry_details_label')}
</Typography>
{issuingAuthority ? <Typography variant="body2">{issuingAuthority}</Typography> : null}
{issuedAt || expiresAt ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{issuedAt ? formatShamsiDate(issuedAt, locale) : ''}
{issuedAt && expiresAt ? ' ' : ''}
{expiresAt ? formatShamsiDate(expiresAt, locale) : ''}
</Typography>
) : null}
</Stack>
) : null}
<Paper
elevation={0}
sx={{ p: 1.5, borderRadius: 'var(--bal-radius-md)', bgcolor: 'var(--bal-primary-soft)', display: 'flex', gap: 1, alignItems: 'center' }}
>
<AppIcon icon="info" size={18} color="var(--bal-primary)" />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('manual_review_note')}
</Typography>
</Paper>
{editingIno ? (
<>
{!hasAnyDocument ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('credentials_needs_document')}
</Typography>
) : null}
<Stack direction="row" sx={{ gap: 1 }}>
<AppButton color="primary" variant="contained" startIcon="license" onClick={handleSubmit} disabled={!canSubmit}>
{submitCredentials.isPending ? t('credentials_submitting') : t('credentials_submit')}
</AppButton>
<AppButton variant="text" color="primary" to={`/${locale}${ROUTES.NURSE_VERIFICATION}`}>
{t('back_to_checklist')}
</AppButton>
</Stack>
</>
) : (
<AppButton color="primary" variant="contained" to={`/${locale}${ROUTES.NURSE_VERIFICATION}`} sx={{ alignSelf: 'flex-start' }}>
{t('back_to_checklist')}
</AppButton>
)}
</Box>
</Box>
</FormProvider>
);
}
};
@@ -2,9 +2,10 @@
import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { useForm, FormProvider, useWatch } from 'react-hook-form';
import { useSnackbar } from 'notistack';
import { Box, Paper, Stack, TextField, Typography } from '@mui/material';
import { AppAlert, AppButton, AppIcon, DocumentUpload } from '@/components';
import { Box, Paper, Stack, Typography } from '@mui/material';
import { AppAlert, AppButton, AppIcon, DocumentUpload, FormSection, RhfTextField } from '@/components';
import { CONTENT_MAX_WIDTH } from '@/components/config';
import { ROUTES } from '@/constants';
import { toEnglishDigits } from '@/utils';
@@ -16,6 +17,12 @@ import VerificationJourneyHeader from '../VerificationJourneyHeader';
type SubmitError = { key: 'national_id_mismatch' | 'shared_sim' | 'shahkar_mismatch' } | null;
interface IdentityFormValues {
nationalId: string;
cardCaptured: boolean;
selfieCaptured: boolean;
}
/**
* B4 identity submission. Collects the national id (10-digit + checksum), a national-ID card image,
* and a liveness selfie, then runs the automated civil-registry KYC + the chained Shahkar match. The
@@ -23,6 +30,12 @@ type SubmitError = { key: 'national_id_mismatch' | 'shared_sim' | 'shahkar_misma
* so `DocumentUpload` runs in local mode here. The auto-query note is honest this check is performed.
* The shared-SIM Shahkar failure surfaces as a clear, non-accusatory message; a national-ID mismatch on
* its own step.
*
* Structured as three named steps rather than one column of controls: the screen asks for a number, a
* card photo and a selfie, and the old layout gave no clue that the selfie was the only *required* one
* of the three the submit button simply stayed dead with the reason buried in a caption near the
* bottom. Each capture's completion is a real form field, so the requirement is declared where it
* applies instead of re-derived in the submit handler.
*/
export default function IdentitySubmitPage() {
const t = useTranslations('verification');
@@ -31,25 +44,23 @@ export default function IdentitySubmitPage() {
const { enqueueSnackbar } = useSnackbar();
const submitIdentity = useSubmitIdentity();
const [nationalId, setNationalId] = useState('');
const [idError, setIdError] = useState(false);
const [cardCaptured, setCardCaptured] = useState(false);
const [selfieCaptured, setSelfieCaptured] = useState(false);
const [submitError, setSubmitError] = useState<SubmitError>(null);
const form = useForm<IdentityFormValues>({
mode: 'onTouched',
defaultValues: { nationalId: '', cardCaptured: false, selfieCaptured: false },
});
const { control, handleSubmit, setValue } = form;
const cardCaptured = useWatch({ control, name: 'cardCaptured' });
const selfieCaptured = useWatch({ control, name: 'selfieCaptured' });
// Local capture: the card/selfie feed the automated KYC (no stored document) — resolve immediately.
const captureLocally = async (file: File) => ({ name: file.name });
const canSubmit = isValidNationalId(nationalId) && selfieCaptured && !submitIdentity.isPending;
const handleSubmit = () => {
const idValid = isValidNationalId(nationalId);
setIdError(!idValid);
const submit = (values: IdentityFormValues) => {
setSubmitError(null);
if (!idValid || !selfieCaptured) return;
submitIdentity.mutate(
{ nationalId, livenessCaptured: selfieCaptured },
{ nationalId: values.nationalId, livenessCaptured: values.selfieCaptured },
{
onSuccess: (result: SubmitIdentityResult) => {
if (result.identity.stepStatus === 'failed') {
@@ -68,92 +79,126 @@ export default function IdentitySubmitPage() {
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}>
<VerificationJourneyHeader group="identity" />
<FormProvider {...form}>
<Box
component="form"
noValidate
onSubmit={handleSubmit(submit)}
sx={{ display: 'flex', flexDirection: 'column', gap: 2.5, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}
>
<VerificationJourneyHeader group="identity" />
<Box>
<Typography variant="h6" component="h2" sx={{ fontWeight: 700 }}>
{t('identity_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('identity_subtitle')}
</Typography>
</Box>
<TextField
label={t('national_id_label')}
value={nationalId}
onChange={(event) => {
setNationalId(toEnglishDigits(event.target.value).replace(/\D/g, '').slice(0, NATIONAL_ID_LENGTH));
if (idError) setIdError(false);
}}
error={idError}
helperText={idError ? t('national_id_invalid') : t('national_id_hint')}
slotProps={{ htmlInput: { dir: 'ltr', inputMode: 'numeric', style: { textAlign: 'start', letterSpacing: 2 } } }}
fullWidth
/>
<FormSection title={t('identity_step_number_title')} description={t('national_id_hint')} icon="identity">
<RhfTextField<IdentityFormValues>
name="nationalId"
label={t('national_id_label')}
transform={(raw) => toEnglishDigits(raw).replace(/\D/g, '').slice(0, NATIONAL_ID_LENGTH)}
rules={{ validate: (value) => isValidNationalId(String(value ?? '')) || t('national_id_invalid') }}
slotProps={{ htmlInput: { dir: 'ltr', inputMode: 'numeric', style: { textAlign: 'start', letterSpacing: 2 } } }}
fullWidth
/>
</FormSection>
<Stack sx={{ gap: 1 }}>
<CaptureGuideFrame variant="card" />
<DocumentUpload
label={t('card_label')}
hint={t('card_hint')}
accept={ACCEPTED_IMAGE_TYPES}
capture="environment"
onUpload={captureLocally}
onUploaded={() => setCardCaptured(true)}
/>
</Stack>
<Stack sx={{ gap: 1 }}>
<CaptureGuideFrame variant="selfie" />
<DocumentUpload
label={t('selfie_label')}
hint={t('selfie_hint')}
accept={ACCEPTED_IMAGE_TYPES}
capture="user"
onUpload={captureLocally}
onUploaded={() => setSelfieCaptured(true)}
/>
</Stack>
<Paper
elevation={0}
sx={{ p: 1.5, borderRadius: 'var(--bal-radius-md)', bgcolor: 'var(--bal-primary-soft)', display: 'flex', gap: 1, alignItems: 'center' }}
>
<AppIcon icon="info" size={18} color="var(--bal-primary)" />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('auto_registry_note')}
</Typography>
</Paper>
{submitError ? (
<AppAlert severity={submitError.key === 'shared_sim' ? 'warning' : 'error'} variant="outlined">
{t(`error_${submitError.key}`)}
</AppAlert>
) : null}
{!cardCaptured ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('card_recommended')}
</Typography>
) : null}
<Stack direction="row" sx={{ gap: 1 }}>
<AppButton
color="primary"
variant="contained"
startIcon="identity"
onClick={handleSubmit}
disabled={!canSubmit}
<FormSection
title={t('card_label')}
description={t('card_hint')}
icon="camera"
optional
optionalLabel={t('card_recommended_short')}
status={cardCaptured ? <CapturedMark label={t('capture_done')} /> : undefined}
>
{submitIdentity.isPending ? t('identity_submitting') : t('identity_submit')}
</AppButton>
<AppButton variant="text" color="primary" to={`/${locale}${ROUTES.NURSE_VERIFICATION}`}>
{t('back_to_checklist')}
</AppButton>
</Stack>
</Box>
<CaptureGuideFrame variant="card" />
<DocumentUpload
label={t('card_label')}
hint={t('capture_hint_card')}
accept={ACCEPTED_IMAGE_TYPES}
capture="environment"
onUpload={captureLocally}
onUploaded={() => setValue('cardCaptured', true, { shouldValidate: true })}
/>
</FormSection>
<FormSection
title={t('selfie_label')}
description={t('selfie_hint')}
icon="account"
status={selfieCaptured ? <CapturedMark label={t('capture_done')} /> : <RequiredMark label={t('required_badge')} />}
>
<CaptureGuideFrame variant="selfie" />
<DocumentUpload
label={t('selfie_label')}
hint={t('capture_hint_selfie')}
accept={ACCEPTED_IMAGE_TYPES}
capture="user"
onUpload={captureLocally}
onUploaded={() => setValue('selfieCaptured', true, { shouldValidate: true })}
/>
</FormSection>
<Paper
elevation={0}
sx={{ p: 1.5, borderRadius: 'var(--bal-radius-md)', bgcolor: 'var(--bal-primary-soft)', display: 'flex', gap: 1, alignItems: 'center' }}
>
<AppIcon icon="info" size={18} color="var(--bal-primary)" />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('auto_registry_note')}
</Typography>
</Paper>
{submitError ? (
<AppAlert severity={submitError.key === 'shared_sim' ? 'warning' : 'error'} variant="outlined">
{t(`error_${submitError.key}`)}
</AppAlert>
) : null}
{/* The one thing that still gates submit is the selfie, so it says so here rather than leaving
a dead button to be explained by a caption three sections up. */}
{!selfieCaptured ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('identity_needs_selfie')}
</Typography>
) : null}
<Stack direction="row" sx={{ gap: 1 }}>
<AppButton
type="submit"
color="primary"
variant="contained"
startIcon="identity"
disabled={!selfieCaptured || submitIdentity.isPending}
>
{submitIdentity.isPending ? t('identity_submitting') : t('identity_submit')}
</AppButton>
<AppButton variant="text" color="primary" to={`/${locale}${ROUTES.NURSE_VERIFICATION}`}>
{t('back_to_checklist')}
</AppButton>
</Stack>
</Box>
</FormProvider>
);
}
/** A section's "done" marker — the completion cue a wall of uploaders otherwise never gives. */
function CapturedMark({ label }: { label: string }) {
return (
<Stack direction="row" sx={{ gap: 0.5, alignItems: 'center', flexShrink: 0 }}>
<AppIcon icon="verified" size={16} color="var(--bal-success)" />
<Typography variant="caption" sx={{ color: 'var(--bal-success)' }}>
{label}
</Typography>
</Stack>
);
}
function RequiredMark({ label }: { label: string }) {
return (
<Typography variant="caption" sx={{ color: 'var(--bal-warning)', flexShrink: 0 }}>
{label}
</Typography>
);
}
@@ -168,36 +213,33 @@ const CORNER_POSITIONS = [
/**
* A cheap, dependency-free capture guide a dashed frame (viewfinder corners for the card, an oval
* for the selfie) plus a static hint line, shown above the corresponding `DocumentUpload`. There is no
* live camera preview to overlay (the native camera app owns capture via the file input's `capture`
* attribute), so this illustrates *how to frame the shot* rather than tracking the actual photo.
* for the selfie), shown above the corresponding `DocumentUpload`. There is no live camera preview to
* overlay (the native camera app owns capture via the file input's `capture` attribute), so this
* illustrates *how to frame the shot* rather than tracking the actual photo. The hint line that used
* to sit under it now rides on the uploader itself, where the tap target is.
*/
function CaptureGuideFrame({ variant }: { variant: 'card' | 'selfie' }) {
const t = useTranslations('verification');
const isCard = variant === 'card';
return (
<Stack sx={{ alignItems: 'center', gap: 0.75 }}>
<Box
sx={{
position: 'relative',
width: isCard ? 180 : 112,
height: isCard ? 112 : 140,
borderRadius: isCard ? 2 : '50%',
border: '2px dashed var(--bal-divider)',
}}
>
{isCard
? CORNER_POSITIONS.map((pos, index) => (
<Box
key={index}
sx={{ position: 'absolute', width: CORNER_SIZE, height: CORNER_SIZE, borderColor: 'var(--bal-primary)', ...pos }}
/>
))
: null}
</Box>
<Typography variant="caption" sx={{ color: 'text.secondary', textAlign: 'center', maxWidth: 220 }}>
{t(isCard ? 'capture_hint_card' : 'capture_hint_selfie')}
</Typography>
</Stack>
<Box
aria-hidden
sx={{
alignSelf: 'center',
position: 'relative',
width: isCard ? 180 : 112,
height: isCard ? 112 : 140,
borderRadius: isCard ? 2 : '50%',
border: '2px dashed var(--bal-divider)',
}}
>
{isCard
? CORNER_POSITIONS.map((pos, index) => (
<Box
key={index}
sx={{ position: 'absolute', width: CORNER_SIZE, height: CORNER_SIZE, borderColor: 'var(--bal-primary)', ...pos }}
/>
))
: null}
</Box>
);
}
@@ -1,9 +1,9 @@
'use client';
import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { useSnackbar } from 'notistack';
import { Checkbox, FormControlLabel, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material';
import { AppButton, AppIcon, VisitNoteCard } from '@/components';
import { Checkbox, FormControlLabel, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, RhfControlGroup, RhfTextField, VisitNoteCard } from '@/components';
import { formatShamsiDate } from '@/utils';
import { useBookingDetail } from '@/services/bookings';
import { isBookingConfirmedOrBeyond } from '@/services/bookings/types';
@@ -11,6 +11,12 @@ import { useRecordAccess, usePatientCareRecord, usePatientHistory, useCreateVisi
import { VISIT_NOTE_MAX_LENGTH } from '@/services/patientRecords/constants';
import type { TaskResult } from '@/services/patientRecords/types';
interface VisitNoteFormValues {
note: string;
/** Task id → ticked. One field so a whole submitted checklist resets in a single `reset`. */
checked: Record<string, boolean>;
}
/**
* E3 (نمای پرستار) the nurse visit-note authoring, mounted **below** the f8 EVV banner on the nurse booking
* detail. **Append-only:** the nurse ticks today's task checklist and writes a free-text note, then submits
@@ -37,26 +43,22 @@ export default function NurseVisitNotesPanel({ bookingId }: { bookingId: number
const history = usePatientHistory(patientId, 1, { enabled: engaged && patientId > 0 && canView });
const createNote = useCreateVisitNote(patientId);
const [note, setNote] = useState('');
const [checked, setChecked] = useState<Record<string, boolean>>({});
const form = useForm<VisitNoteFormValues>({ mode: 'onTouched', defaultValues: { note: '', checked: {} } });
const { control, handleSubmit, reset } = form;
const note = useWatch({ control, name: 'note' });
if (!booking.data || !engaged) return null;
const tasks = record.data?.tasks ?? [];
const submit = () => {
if (!note.trim()) {
enqueueSnackbar(t('note_required'), { variant: 'error' });
return;
}
const taskResults: TaskResult[] = tasks.map((task) => ({ label: task.label, done: Boolean(checked[task.id]) }));
const submit = (values: VisitNoteFormValues) => {
const taskResults: TaskResult[] = tasks.map((task) => ({ label: task.label, done: Boolean(values.checked[task.id]) }));
createNote.mutate(
{ bookingId, body: note.trim(), taskResults },
{ bookingId, body: values.note.trim(), taskResults },
{
onSuccess: () => {
enqueueSnackbar(t('note_saved'), { variant: 'success' });
setNote('');
setChecked({});
reset({ note: '', checked: {} });
},
onError: () => enqueueSnackbar(t('note_error'), { variant: 'error' }),
},
@@ -72,59 +74,73 @@ export default function NurseVisitNotesPanel({ bookingId }: { bookingId: number
elevation={0}
sx={{ p: 2.5, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider', borderTop: '3px solid var(--bal-secondary)' }}
>
<Stack sx={{ gap: 2 }}>
<Stack direction="row" sx={{ alignItems: 'center', gap: 1 }}>
<AppIcon icon="notes" size={20} color="var(--bal-secondary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('notes_title')}
</Typography>
</Stack>
{record.isLoading || tasks.length > 0 ? (
<Stack sx={{ gap: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('tasks_checklist_title')}
<FormProvider {...form}>
<Stack component="form" noValidate onSubmit={handleSubmit(submit)} sx={{ gap: 2 }}>
<Stack direction="row" sx={{ alignItems: 'center', gap: 1 }}>
<AppIcon icon="notes" size={20} color="var(--bal-secondary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('notes_title')}
</Typography>
{record.isLoading ? (
<Skeleton variant="rounded" height={80} />
) : (
tasks.map((task) => (
<FormControlLabel
key={task.id}
control={
<Checkbox
checked={Boolean(checked[task.id])}
onChange={(e) => setChecked((c) => ({ ...c, [task.id]: e.target.checked }))}
/>
}
label={task.label}
/>
))
)}
</Stack>
) : null}
<TextField
label={t('note_label')}
placeholder={t('note_placeholder')}
value={note}
onChange={(e) => setNote(e.target.value.slice(0, VISIT_NOTE_MAX_LENGTH))}
multiline
minRows={3}
fullWidth
/>
{record.isLoading || tasks.length > 0 ? (
<Stack sx={{ gap: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('tasks_checklist_title')}
</Typography>
{record.isLoading ? (
<Skeleton variant="rounded" height={80} />
) : (
<RhfControlGroup<VisitNoteFormValues> name="checked">
{({ field }) => {
const ticked = (field.value as Record<string, boolean>) ?? {};
return (
<Stack>
{tasks.map((task) => (
<FormControlLabel
key={task.id}
control={
<Checkbox
checked={Boolean(ticked[task.id])}
onChange={(event) =>
field.onChange({ ...ticked, [task.id]: event.target.checked })
}
/>
}
label={task.label}
/>
))}
</Stack>
);
}}
</RhfControlGroup>
)}
</Stack>
) : null}
<AppButton
variant="contained"
color="secondary"
startIcon="notes"
onClick={submit}
disabled={createNote.isPending || !note.trim()}
sx={{ alignSelf: 'flex-end' }}
>
{createNote.isPending ? tc('saving') : t('note_submit')}
</AppButton>
</Stack>
<RhfTextField<VisitNoteFormValues>
name="note"
label={t('note_label')}
placeholder={t('note_placeholder')}
transform={(raw) => raw.slice(0, VISIT_NOTE_MAX_LENGTH)}
rules={{ validate: (value) => String(value ?? '').trim().length > 0 || t('note_required') }}
multiline
minRows={3}
fullWidth
/>
<AppButton
type="submit"
variant="contained"
color="secondary"
startIcon="notes"
disabled={createNote.isPending || !note.trim()}
sx={{ alignSelf: 'flex-end' }}
>
{createNote.isPending ? tc('saving') : t('note_submit')}
</AppButton>
</Stack>
</FormProvider>
</Paper>
) : null}