frontend phase 2: onboarding & profiles — customer/patient, nurse profile & bank
Turns a logged-in user into a usable account, consuming the b3 identity-profiles
contract behind the services/{domain} seam.
Services (mock default true; real HTTP clients wired for a one-line flip):
- services/patients: rewritten to b3 PatientDto + client-augmented relation/conditions;
full CRUD, optimistic soft-archive, cache-splice on create, age<->birthDate helper.
- services/profiles: customer + nurse profile get/upsert + avatar (404->null mapping).
- services/nurse: payout bank accounts + IBAN(Sheba) util + pending-only polling.
Screens: A3->A4 onboarding wizard, E1 patients list/CRUD, A5 home (first-login gate +
nudge), customer profile (no national-ID), nurse profile bootstrap (unverified
placeholder), nurse bank settings (pending/verified/mismatch + make-primary).
Shared composites (each tested): GenderToggle, ConditionChips, RelationSelect,
PatientForm, PatientCard, BankStatusPanel; reuses f0 StepperHeader/StatusChip/PhoneField.
Adds onboarding/home/profile/nurseProfile/bank i18n namespaces (both locales, in sync),
the --bal-primary-soft token, and nurse sidebar Profile + Bank entries.
Contract gaps filed: REQ-005 (patient relation/conditions), REQ-006 (avatar route),
REQ-007 (customer name/language). Gate: check + 112 tests + build all green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import { Box, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, PatientForm, RelationSelect, StepperHeader } from '@/components';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { useCreatePatient } from '@/services/patients';
|
||||
import { RELATION_CODES } from '@/services/patients/constants';
|
||||
import type { CreatePatientInput, Relation } from '@/services/patients/types';
|
||||
|
||||
const ONBOARDING_MAX_WIDTH = 520;
|
||||
|
||||
/**
|
||||
* A3 → A4 onboarding wizard: pick who care is for, then register the first patient. The
|
||||
* chosen relation pre-shapes the patient (it is hidden on the A4 form since it's already
|
||||
* chosen here). On save it creates the patient and lands on Home (A5).
|
||||
*/
|
||||
export default function OnboardingPage() {
|
||||
const t = useTranslations('onboarding');
|
||||
const tc = useTranslations('common');
|
||||
const router = useRouter();
|
||||
const locale = useLocale();
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const createPatient = useCreatePatient();
|
||||
|
||||
const [step, setStep] = useState(0);
|
||||
const [relation, setRelation] = useState<Relation | null>(null);
|
||||
|
||||
const relationOptions = RELATION_CODES.map((code) => ({ code, label: t(`relation_${code}`), icon: 'account' }));
|
||||
|
||||
const handleCreate = (input: CreatePatientInput) => {
|
||||
createPatient.mutate(
|
||||
{ ...input, relation },
|
||||
{
|
||||
onSuccess: () => {
|
||||
enqueueSnackbar(t('saved'), { variant: 'success' });
|
||||
router.replace(`/${locale}${ROUTES.HOME}`);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: ONBOARDING_MAX_WIDTH, mx: 'auto', display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<StepperHeader steps={[t('step_relation'), t('step_patient')]} activeStep={step} />
|
||||
|
||||
{step === 0 ? (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<Stack sx={{ gap: 0.5 }}>
|
||||
<Typography variant="h6" component="h1">
|
||||
{t('relation_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('relation_subtitle')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<RelationSelect
|
||||
options={relationOptions}
|
||||
value={relation}
|
||||
onChange={(code) => setRelation(code as Relation)}
|
||||
/>
|
||||
<AppButton
|
||||
color="primary"
|
||||
variant="contained"
|
||||
fullWidth
|
||||
disabled={!relation}
|
||||
onClick={() => setStep(1)}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{t('continue')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<Stack sx={{ gap: 0.5 }}>
|
||||
<Typography variant="h6" component="h1">
|
||||
{t('patient_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('patient_subtitle')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<PatientForm
|
||||
initial={{ relation: relation ?? undefined }}
|
||||
submitLabel={t('save_continue')}
|
||||
submitting={createPatient.isPending}
|
||||
onSubmit={handleCreate}
|
||||
onCancel={() => setStep(0)}
|
||||
cancelLabel={tc('back')}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,100 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { PlaceholderScreen } from '@/components';
|
||||
'use client';
|
||||
import { FunctionComponent, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Box, Paper, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, AppLoading } from '@/components';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { useMe } from '@/services/auth';
|
||||
import { usePatients } from '@/services/patients';
|
||||
|
||||
export default async function CustomerHomePage() {
|
||||
const t = await getTranslations('nav');
|
||||
const tShell = await getTranslations('shell');
|
||||
return <PlaceholderScreen icon="home" title={t('home')} description={tShell('placeholder_body')} />;
|
||||
interface NudgeCardProps {
|
||||
icon: string;
|
||||
title: string;
|
||||
body: string;
|
||||
ctaLabel: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
const NudgeCard: FunctionComponent<NudgeCardProps> = ({ icon, title, body, ctaLabel, to }) => (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2, display: 'flex', gap: 2 }}
|
||||
>
|
||||
<AppIcon icon={icon} size={28} color="var(--bal-primary)" />
|
||||
<Stack sx={{ gap: 1, flexGrow: 1 }}>
|
||||
<Stack sx={{ gap: 0.25 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{body}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<AppButton color="primary" variant="outlined" to={to} sx={{ m: 0, alignSelf: 'flex-start' }}>
|
||||
{ctaLabel}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
/**
|
||||
* A5 — the family Home. First-login gate: a customer with no patients is sent into onboarding
|
||||
* (A3). Once a patient exists it shows the "complete patient record" nudge (and a profile
|
||||
* nudge until the profile is complete). The redirect waits for a settled list so a post-create
|
||||
* refetch never bounces the user back to onboarding.
|
||||
*/
|
||||
export default function CustomerHomePage() {
|
||||
const t = useTranslations('home');
|
||||
const router = useRouter();
|
||||
const locale = useLocale();
|
||||
|
||||
const { data: me } = useMe();
|
||||
const { data } = usePatients();
|
||||
|
||||
// A customer with no patients is a first-login user → onboarding. `useCreatePatient` primes
|
||||
// the list cache on success, so a just-onboarded user never transiently reads total===0 here
|
||||
// (no bounce back); a genuinely empty list always renders loading, never a flash of Home.
|
||||
const isEmpty = data?.total === 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (isEmpty) router.replace(`/${locale}${ROUTES.ONBOARDING}`);
|
||||
}, [isEmpty, router, locale]);
|
||||
|
||||
if (data == null || isEmpty) {
|
||||
return <AppLoading />;
|
||||
}
|
||||
|
||||
const href = (path: string) => `/${locale}${path}`;
|
||||
const profileComplete = me?.hasCustomerProfile ?? false;
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<Box>
|
||||
<Typography variant="h5" component="h1">
|
||||
{t('greeting')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<NudgeCard
|
||||
icon="patients"
|
||||
title={t('nudge_patient_title')}
|
||||
body={t('nudge_patient_body')}
|
||||
ctaLabel={t('nudge_patient_cta')}
|
||||
to={href(ROUTES.PATIENTS)}
|
||||
/>
|
||||
{!profileComplete ? (
|
||||
<NudgeCard
|
||||
icon="profile"
|
||||
title={t('nudge_profile_title')}
|
||||
body={t('nudge_profile_body')}
|
||||
ctaLabel={t('nudge_profile_cta')}
|
||||
to={href(ROUTES.PROFILE)}
|
||||
/>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,101 +1,200 @@
|
||||
'use client';
|
||||
import { ChangeEvent, useState } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Box, Chip, List, ListItem, ListItemText, MenuItem, Stack, TextField, Typography } from '@mui/material';
|
||||
import { useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import { AppButton, AppLoading } from '@/components';
|
||||
import { usePatients, useAddPatient } from '@/services/patients';
|
||||
import type { Gender } from '@/services/patients/types';
|
||||
import { formatShamsiDate } from '@/utils';
|
||||
import {
|
||||
Box,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Paper,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { AppButton, AppIcon, PatientCard, PatientForm } from '@/components';
|
||||
import { usePatients, useCreatePatient, useUpdatePatient, useArchivePatient } from '@/services/patients';
|
||||
import { birthDateToAge } from '@/services/patients/age';
|
||||
import type { CreatePatientInput, Patient } from '@/services/patients/types';
|
||||
|
||||
/**
|
||||
* Reference screen for the services/{domain} + React Query pattern (§3.3). It reads the
|
||||
* mocked patients list via usePatients (cached with a staleTime) and adds one via
|
||||
* useAddPatient, whose onSuccess invalidates the list so the new row appears without a
|
||||
* manual refetch — visible in the React Query Devtools.
|
||||
* E1 — the Patients tab: a cached, invalidate-on-mutation list of the customer's patients
|
||||
* with add/edit (the A4 form reused in a dialog) and soft archive (confirm). Loading skeleton
|
||||
* and an empty state with the add CTA are both handled.
|
||||
*/
|
||||
export default function PatientsPage() {
|
||||
const t = useTranslations('patients');
|
||||
const locale = useLocale();
|
||||
const to = useTranslations('onboarding');
|
||||
const tc = useTranslations('common');
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
|
||||
const { data, isLoading } = usePatients();
|
||||
const addPatient = useAddPatient();
|
||||
const createPatient = useCreatePatient();
|
||||
const updatePatient = useUpdatePatient();
|
||||
const archivePatient = useArchivePatient();
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [gender, setGender] = useState<Gender>('female');
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<Patient | null>(null);
|
||||
const [archiveTarget, setArchiveTarget] = useState<Patient | null>(null);
|
||||
|
||||
const genderLabel = (value: Gender) => (value === 'male' ? t('gender_male') : t('gender_female'));
|
||||
|
||||
const handleAdd = () => {
|
||||
const fullName = name.trim();
|
||||
if (!fullName) return;
|
||||
addPatient.mutate(
|
||||
{ fullName, gender },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setName('');
|
||||
enqueueSnackbar(t('added'), { variant: 'success' });
|
||||
},
|
||||
}
|
||||
);
|
||||
const openAdd = () => {
|
||||
setEditing(null);
|
||||
setFormOpen(true);
|
||||
};
|
||||
const openEdit = (patient: Patient) => {
|
||||
setEditing(patient);
|
||||
setFormOpen(true);
|
||||
};
|
||||
const closeForm = () => setFormOpen(false);
|
||||
|
||||
const handleSubmit = (input: CreatePatientInput) => {
|
||||
const onSuccess = () => {
|
||||
closeForm();
|
||||
enqueueSnackbar(to('saved'), { variant: 'success' });
|
||||
};
|
||||
if (editing) {
|
||||
updatePatient.mutate(
|
||||
{ id: editing.id, input },
|
||||
{ onSuccess, onError: () => enqueueSnackbar(t('unavailable'), { variant: 'error' }) },
|
||||
);
|
||||
} else {
|
||||
createPatient.mutate(input, { onSuccess });
|
||||
}
|
||||
};
|
||||
|
||||
const confirmArchive = () => {
|
||||
if (!archiveTarget) return;
|
||||
const id = archiveTarget.id;
|
||||
setArchiveTarget(null);
|
||||
archivePatient.mutate(id, {
|
||||
onSuccess: () => enqueueSnackbar(t('archived'), { variant: 'success' }),
|
||||
// A cross-tenant/stale id returns 404 (not toasted by the fetch layer) — the archive was
|
||||
// optimistic, so tell the user why the card reappeared.
|
||||
onError: () => enqueueSnackbar(t('unavailable'), { variant: 'error' }),
|
||||
});
|
||||
};
|
||||
|
||||
const patients = data?.items ?? [];
|
||||
const isEmpty = !isLoading && patients.length === 0;
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<Box>
|
||||
<Typography variant="h5" component="h1">
|
||||
{t('title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1} sx={{ alignItems: { sm: 'flex-start' } }}>
|
||||
<TextField
|
||||
label={t('name_label')}
|
||||
value={name}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => setName(event.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
select
|
||||
label={t('gender_label')}
|
||||
value={gender}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => setGender(event.target.value as Gender)}
|
||||
sx={{ minWidth: 140 }}
|
||||
>
|
||||
<MenuItem value="female">{t('gender_female')}</MenuItem>
|
||||
<MenuItem value="male">{t('gender_male')}</MenuItem>
|
||||
</TextField>
|
||||
<AppButton
|
||||
color="primary"
|
||||
startIcon="add"
|
||||
onClick={handleAdd}
|
||||
disabled={!name.trim() || addPatient.isPending}
|
||||
>
|
||||
{t('add')}
|
||||
</AppButton>
|
||||
<Stack direction="row" sx={{ alignItems: 'flex-start', justifyContent: 'space-between', gap: 2 }}>
|
||||
<Box>
|
||||
<Typography variant="h5" component="h1">
|
||||
{t('title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
{!isEmpty ? (
|
||||
<AppButton color="primary" variant="contained" startIcon="add" onClick={openAdd} sx={{ m: 0, flexShrink: 0 }}>
|
||||
{t('add')}
|
||||
</AppButton>
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
{isLoading ? (
|
||||
<AppLoading />
|
||||
) : !data || data.items.length === 0 ? (
|
||||
<Typography sx={{ color: 'text.secondary' }}>{t('empty')}</Typography>
|
||||
) : (
|
||||
<List>
|
||||
{data.items.map((patient) => (
|
||||
<ListItem
|
||||
key={patient.id}
|
||||
divider
|
||||
secondaryAction={<Chip size="small" label={genderLabel(patient.gender)} />}
|
||||
>
|
||||
<ListItemText primary={patient.fullName} secondary={formatShamsiDate(patient.createdAtUtc, locale)} />
|
||||
</ListItem>
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
{[0, 1].map((key) => (
|
||||
<Skeleton key={key} variant="rounded" height={96} />
|
||||
))}
|
||||
</List>
|
||||
</Stack>
|
||||
) : isEmpty ? (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 4,
|
||||
textAlign: 'center',
|
||||
border: '1px dashed',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
}}
|
||||
>
|
||||
<AppIcon icon="patients" size={40} color="var(--bal-primary)" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('empty_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('empty_body')}
|
||||
</Typography>
|
||||
<AppButton color="primary" variant="contained" startIcon="add" onClick={openAdd} sx={{ mt: 1 }}>
|
||||
{t('add')}
|
||||
</AppButton>
|
||||
</Paper>
|
||||
) : (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
{patients.map((patient) => {
|
||||
const age = birthDateToAge(patient.birthDate);
|
||||
return (
|
||||
<PatientCard
|
||||
key={patient.id}
|
||||
patient={patient}
|
||||
relationLabel={patient.relation ? to(`relation_${patient.relation}`) : undefined}
|
||||
genderLabel={to(`gender_${patient.gender}`)}
|
||||
ageLabel={age == null ? undefined : t('age_years', { age })}
|
||||
conditionLabels={patient.conditions.map((code) => to(`condition_${code}`))}
|
||||
noConditionsLabel={t('conditions_none')}
|
||||
onEdit={() => openEdit(patient)}
|
||||
onArchive={() => setArchiveTarget(patient)}
|
||||
editLabel={t('edit')}
|
||||
archiveLabel={t('archive')}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Dialog open={formOpen} onClose={closeForm} fullWidth maxWidth="sm">
|
||||
<DialogTitle>{editing ? t('edit_title') : t('add_title')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ pt: 1 }}>
|
||||
<PatientForm
|
||||
key={editing?.id ?? 'new'}
|
||||
initial={
|
||||
editing
|
||||
? {
|
||||
displayName: editing.displayName,
|
||||
birthDate: editing.birthDate,
|
||||
gender: editing.gender,
|
||||
conditions: editing.conditions,
|
||||
relation: editing.relation,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
showRelation
|
||||
submitLabel={tc('save')}
|
||||
submitting={createPatient.isPending || updatePatient.isPending}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={closeForm}
|
||||
cancelLabel={tc('cancel')}
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(archiveTarget)} onClose={() => setArchiveTarget(null)}>
|
||||
<DialogTitle>{t('archive_title')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('archive_body')}
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<AppButton variant="text" onClick={() => setArchiveTarget(null)}>
|
||||
{tc('cancel')}
|
||||
</AppButton>
|
||||
<AppButton color="error" variant="contained" onClick={confirmArchive}>
|
||||
{t('archive_confirm')}
|
||||
</AppButton>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,128 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { PlaceholderScreen } from '@/components';
|
||||
'use client';
|
||||
import { FunctionComponent, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import { Box, Divider, MenuItem, Stack, TextField, Typography } from '@mui/material';
|
||||
import { AppButton, AppLoading, PhoneNumberField } from '@/components';
|
||||
import { isIranianMobile } from '@/components/PhoneNumberField';
|
||||
import { digitsOnly } from '@/utils';
|
||||
import { useCustomerProfile, useUpsertCustomerProfile } from '@/services/profiles';
|
||||
import type { CustomerProfile } from '@/services/profiles/types';
|
||||
|
||||
export default async function ProfilePage() {
|
||||
const t = await getTranslations('nav');
|
||||
const tShell = await getTranslations('shell');
|
||||
return <PlaceholderScreen icon="profile" title={t('profile')} description={tShell('placeholder_body')} />;
|
||||
/** Customer profile — name, preferred language, and the emergency contact. No national-ID KYC. */
|
||||
export default function CustomerProfilePage() {
|
||||
const { data: profile, isLoading } = useCustomerProfile();
|
||||
if (isLoading) return <AppLoading />;
|
||||
return <CustomerProfileForm initial={profile ?? null} />;
|
||||
}
|
||||
|
||||
const CustomerProfileForm: FunctionComponent<{ initial: CustomerProfile | null }> = ({ initial }) => {
|
||||
const t = useTranslations('profile');
|
||||
const tc = useTranslations('common');
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const upsert = useUpsertCustomerProfile();
|
||||
|
||||
const [firstName, setFirstName] = useState(initial?.firstName ?? '');
|
||||
const [lastName, setLastName] = useState(initial?.lastName ?? '');
|
||||
const [language, setLanguage] = useState(initial?.preferredLanguage ?? 'fa');
|
||||
const [emergencyName, setEmergencyName] = useState(initial?.defaultEmergencyContactName ?? '');
|
||||
const [emergencyPhone, setEmergencyPhone] = useState(digitsOnly(initial?.defaultEmergencyContactPhone ?? ''));
|
||||
const [nameError, setNameError] = useState(false);
|
||||
const [phoneError, setPhoneError] = useState(false);
|
||||
|
||||
const isComplete = Boolean(initial?.defaultEmergencyContactName && initial?.defaultEmergencyContactPhone);
|
||||
|
||||
const handleSave = () => {
|
||||
const nameInvalid = emergencyName.trim().length === 0;
|
||||
const phoneInvalid = !isIranianMobile(emergencyPhone);
|
||||
setNameError(nameInvalid);
|
||||
setPhoneError(phoneInvalid);
|
||||
if (nameInvalid || phoneInvalid) return;
|
||||
|
||||
upsert.mutate(
|
||||
{
|
||||
defaultEmergencyContactName: emergencyName.trim(),
|
||||
defaultEmergencyContactPhone: emergencyPhone,
|
||||
firstName: firstName.trim() || null,
|
||||
lastName: lastName.trim() || null,
|
||||
preferredLanguage: language,
|
||||
},
|
||||
{ onSuccess: () => enqueueSnackbar(t('saved'), { variant: 'success' }) },
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 520 }}>
|
||||
<Box>
|
||||
<Typography variant="h5" component="h1">
|
||||
{t('title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('subtitle')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mt: 0.5, color: isComplete ? 'var(--bal-success)' : 'text.secondary' }}>
|
||||
{isComplete ? t('completion_done') : t('completion_todo')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} sx={{ gap: 2 }}>
|
||||
<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 />
|
||||
</Stack>
|
||||
|
||||
<TextField
|
||||
select
|
||||
label={t('language')}
|
||||
value={language}
|
||||
onChange={(e) => setLanguage(e.target.value)}
|
||||
sx={{ maxWidth: 220 }}
|
||||
>
|
||||
<MenuItem value="fa">{t('language_fa')}</MenuItem>
|
||||
<MenuItem value="en">{t('language_en')}</MenuItem>
|
||||
</TextField>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('emergency_section')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('emergency_hint')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<TextField
|
||||
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={(value) => {
|
||||
setEmergencyPhone(value);
|
||||
if (phoneError) setPhoneError(false);
|
||||
}}
|
||||
error={phoneError}
|
||||
helperText={phoneError ? t('emergency_phone_invalid') : undefined}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<AppButton
|
||||
color="primary"
|
||||
variant="contained"
|
||||
onClick={handleSave}
|
||||
disabled={upsert.isPending}
|
||||
sx={{ m: 0, alignSelf: 'flex-start' }}
|
||||
>
|
||||
{upsert.isPending ? tc('saving') : t('save')}
|
||||
</AppButton>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user