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:
hamid
2026-07-02 22:04:38 +03:30
parent 82561c4cc6
commit 4b4243c451
70 changed files with 3111 additions and 190 deletions
@@ -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>
);
};