manual improvement 2 & add telegram bot
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+77
-59
@@ -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',
|
||||
|
||||
+180
-143
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user