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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import { Box, Paper, Stack, TextField, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, AppLoading, BankStatusPanel } from '@/components';
|
||||
import { useNurseBankAccounts, useAddNurseBankAccount, useSetPrimaryBankAccount } from '@/services/nurse';
|
||||
import { isValidSheba } from '@/services/nurse/iban';
|
||||
import { deriveBankStatus } from '@/services/nurse/types';
|
||||
|
||||
/**
|
||||
* Nurse payout bank settings — submit an IBAN (شبا) + account-holder name, then watch the
|
||||
* ownership inquiry resolve through its three states (pending → verified / mismatch). The list
|
||||
* polls only while pending; a verified account shows the masked IBAN; mismatch offers re-enter.
|
||||
*/
|
||||
export default function NurseBankPage() {
|
||||
const t = useTranslations('bank');
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
|
||||
const { data, isLoading } = useNurseBankAccounts();
|
||||
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 accounts = data ?? [];
|
||||
const showFormNow = !isLoading && (accounts.length === 0 || showForm);
|
||||
|
||||
const submit = () => {
|
||||
const ibanInvalid = !isValidSheba(iban);
|
||||
const holderInvalid = holder.trim().length === 0;
|
||||
setIbanError(ibanInvalid);
|
||||
setHolderError(holderInvalid);
|
||||
if (ibanInvalid || holderInvalid) return;
|
||||
|
||||
addAccount.mutate(
|
||||
{ iban, accountHolderName: holder.trim() },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setIban('');
|
||||
setHolder('');
|
||||
setShowForm(false);
|
||||
enqueueSnackbar(t('added'), { variant: 'success' });
|
||||
},
|
||||
onError: () => enqueueSnackbar(t('add_error'), { variant: 'error' }),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 560 }}>
|
||||
<Box>
|
||||
<Typography variant="h5" component="h1">
|
||||
{t('title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{isLoading ? <AppLoading /> : null}
|
||||
|
||||
{accounts.map((account) => {
|
||||
const status = deriveBankStatus(account);
|
||||
return (
|
||||
<Stack key={account.id} sx={{ gap: 1 }}>
|
||||
<BankStatusPanel
|
||||
status={status}
|
||||
chipLabel={t(`status_${status}_chip`)}
|
||||
title={t(`status_${status}_title`)}
|
||||
body={t(`status_${status}_body`)}
|
||||
ibanMasked={status === 'verified' ? account.ibanMasked : undefined}
|
||||
ibanLabel={t('iban_masked_label')}
|
||||
bankName={account.bankName || undefined}
|
||||
isPrimary={account.isPrimary}
|
||||
primaryLabel={t('primary')}
|
||||
onReenter={status === 'mismatch' ? () => setShowForm(true) : undefined}
|
||||
reenterLabel={t('reenter')}
|
||||
/>
|
||||
{/* Promote a verified non-primary account so payouts (gated on matchedNationalId) target it. */}
|
||||
{status === 'verified' && !account.isPrimary ? (
|
||||
<AppButton
|
||||
variant="text"
|
||||
color="primary"
|
||||
disabled={setPrimary.isPending}
|
||||
onClick={() =>
|
||||
setPrimary.mutate(account.id, {
|
||||
onSuccess: () => enqueueSnackbar(t('primary_set'), { variant: 'success' }),
|
||||
})
|
||||
}
|
||||
sx={{ m: 0, alignSelf: 'flex-start' }}
|
||||
>
|
||||
{t('make_primary')}
|
||||
</AppButton>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
|
||||
{!isLoading && accounts.length === 0 ? (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{ p: 3, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}
|
||||
>
|
||||
<AppIcon icon="bank" size={36} 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>
|
||||
</Paper>
|
||||
) : 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
|
||||
/>
|
||||
<AppButton
|
||||
color="primary"
|
||||
variant="contained"
|
||||
startIcon="bank"
|
||||
onClick={submit}
|
||||
disabled={addAccount.isPending}
|
||||
sx={{ m: 0, alignSelf: 'flex-start' }}
|
||||
>
|
||||
{addAccount.isPending ? t('submitting') : t('submit')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
'use client';
|
||||
import { ChangeEvent, FunctionComponent, useRef, useState } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import { Avatar, Box, Paper, Stack, TextField, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, AppLoading } from '@/components';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { useNurseProfile, useUpsertNurseProfile, useUploadAvatar } from '@/services/profiles';
|
||||
import type { NurseProfile } from '@/services/profiles/types';
|
||||
|
||||
const MAX_YEARS = 80;
|
||||
|
||||
/** Nurse profile bootstrap (B7 header): avatar + bio + years. Services/availability are deferred (f4). */
|
||||
export default function NurseProfilePage() {
|
||||
const { data: profile, isLoading } = useNurseProfile();
|
||||
if (isLoading) return <AppLoading />;
|
||||
return <NurseProfileForm initial={profile ?? null} />;
|
||||
}
|
||||
|
||||
const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({ initial }) => {
|
||||
const t = useTranslations('nurseProfile');
|
||||
const tc = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const upsert = useUpsertNurseProfile();
|
||||
const uploadAvatar = useUploadAvatar();
|
||||
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 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) });
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
upsert.mutate(
|
||||
{
|
||||
bio: bio.trim(),
|
||||
yearsOfExperience: yearsNum,
|
||||
educationLevel: initial?.educationLevel ?? '',
|
||||
educationField: initial?.educationField ?? '',
|
||||
specializationsJson: initial?.specializationsJson ?? '[]',
|
||||
avatarUrl,
|
||||
},
|
||||
{ onSuccess: () => enqueueSnackbar(t('saved'), { variant: 'success' }) },
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 560 }}>
|
||||
<Box>
|
||||
<Typography variant="h5" component="h1">
|
||||
{t('title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Not bookable until verification (f5) — a neutral placeholder, not the real banner. */}
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{ p: 2, borderRadius: 2, 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 }}>
|
||||
{t('unverified_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('unverified_body')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<AppButton
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
to={`/${locale}${ROUTES.NURSE_VERIFICATION}`}
|
||||
sx={{ m: 0, alignSelf: 'flex-start' }}
|
||||
>
|
||||
{t('unverified_cta')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<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={{ m: 0, mt: 0.5, alignSelf: 'flex-start' }}
|
||||
>
|
||||
{uploadAvatar.isPending ? t('uploading') : t('upload')}
|
||||
</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 }}
|
||||
/>
|
||||
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('deferred_services')}
|
||||
</Typography>
|
||||
|
||||
<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