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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+294
-277
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+79
-63
@@ -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}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user