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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { ThemeProvider } from '../../theme';
|
||||
import BankStatusPanel from './BankStatusPanel';
|
||||
|
||||
describe('<BankStatusPanel/> component', () => {
|
||||
it('renders the pending state with its chip and title', () => {
|
||||
const { container } = render(
|
||||
<ThemeProvider>
|
||||
<BankStatusPanel status="pending" chipLabel="Checking" title="Verifying ownership" body="Please wait" />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
expect(container.querySelector('[data-status="pending"]')).toBeInTheDocument();
|
||||
expect(screen.getByText('Verifying ownership')).toBeInTheDocument();
|
||||
expect(screen.getByText('Checking')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the masked IBAN on the verified state', () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<BankStatusPanel
|
||||
status="verified"
|
||||
chipLabel="Verified"
|
||||
title="Account verified"
|
||||
body="Ready for payouts"
|
||||
ibanMasked="••••3456"
|
||||
ibanLabel="IBAN"
|
||||
/>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
expect(screen.getByText('••••3456')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers the re-enter action only on mismatch', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onReenter = jest.fn();
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<BankStatusPanel
|
||||
status="mismatch"
|
||||
chipLabel="Mismatch"
|
||||
title="Must be your own account"
|
||||
body="Names do not match"
|
||||
onReenter={onReenter}
|
||||
reenterLabel="Enter another account"
|
||||
/>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
await user.click(screen.getByText('Enter another account'));
|
||||
expect(onReenter).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { AppButton } from '@/components/common';
|
||||
import StatusChip from '@/components/StatusChip';
|
||||
import type { StatusKind } from '@/components/StatusChip';
|
||||
import type { BankAccountStatus } from '@/services/nurse/types';
|
||||
|
||||
const STATUS_KIND: Record<BankAccountStatus, StatusKind> = {
|
||||
pending: 'pending',
|
||||
verified: 'verified',
|
||||
mismatch: 'rejected',
|
||||
};
|
||||
|
||||
const ACCENT_TOKEN: Record<BankAccountStatus, string> = {
|
||||
pending: 'var(--bal-warning)',
|
||||
verified: 'var(--bal-success)',
|
||||
mismatch: 'var(--bal-error)',
|
||||
};
|
||||
|
||||
export interface BankStatusPanelProps {
|
||||
status: BankAccountStatus;
|
||||
/** Translated status chip / title / body for the active status. */
|
||||
chipLabel: string;
|
||||
title: string;
|
||||
body: string;
|
||||
/** Masked IBAN (last-4), shown when present. */
|
||||
ibanMasked?: string;
|
||||
ibanLabel?: string;
|
||||
bankName?: string;
|
||||
isPrimary?: boolean;
|
||||
primaryLabel?: string;
|
||||
/** Rendered only for the mismatch state (a friendly re-enter path). */
|
||||
onReenter?: () => void;
|
||||
reenterLabel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders one bank account in one of the three ownership-inquiry states — **pending**,
|
||||
* **verified**, **mismatch** — each visually distinct off the semantic tokens. The IBAN is
|
||||
* shown masked (last-4). Mismatch copy is passed in non-accusatory; the re-enter CTA is the
|
||||
* only action offered there. All strings are translated by the caller.
|
||||
* @component BankStatusPanel
|
||||
*/
|
||||
const BankStatusPanel: FunctionComponent<BankStatusPanelProps> = ({
|
||||
status,
|
||||
chipLabel,
|
||||
title,
|
||||
body,
|
||||
ibanMasked,
|
||||
ibanLabel,
|
||||
bankName,
|
||||
isPrimary = false,
|
||||
primaryLabel,
|
||||
onReenter,
|
||||
reenterLabel,
|
||||
}) => (
|
||||
<Paper
|
||||
elevation={0}
|
||||
data-status={status}
|
||||
sx={{
|
||||
p: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderInlineStartWidth: 4,
|
||||
borderInlineStartColor: ACCENT_TOKEN[status],
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
<Stack sx={{ gap: 1.25 }}>
|
||||
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||
<StatusChip status={STATUS_KIND[status]} label={chipLabel} />
|
||||
{isPrimary && primaryLabel ? (
|
||||
<StatusChip status="info" label={primaryLabel} />
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
<Stack sx={{ gap: 0.25 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{body}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
{ibanMasked ? (
|
||||
<Stack direction="row" sx={{ alignItems: 'baseline', gap: 1 }}>
|
||||
{ibanLabel ? (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{ibanLabel}
|
||||
</Typography>
|
||||
) : null}
|
||||
<Typography sx={{ fontWeight: 600, letterSpacing: 1 }} dir="ltr">
|
||||
{ibanMasked}
|
||||
</Typography>
|
||||
{bankName ? (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{bankName}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Stack>
|
||||
) : null}
|
||||
|
||||
{status === 'mismatch' && onReenter && reenterLabel ? (
|
||||
<AppButton color="primary" variant="outlined" startIcon="bank" onClick={onReenter} sx={{ m: 0, alignSelf: 'flex-start' }}>
|
||||
{reenterLabel}
|
||||
</AppButton>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
export default BankStatusPanel;
|
||||
@@ -0,0 +1,4 @@
|
||||
import BankStatusPanel from './BankStatusPanel';
|
||||
|
||||
export type { BankStatusPanelProps } from './BankStatusPanel';
|
||||
export { BankStatusPanel as default, BankStatusPanel };
|
||||
@@ -0,0 +1,41 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { ThemeProvider } from '../../theme';
|
||||
import ConditionChips from './ConditionChips';
|
||||
|
||||
const OPTIONS = [
|
||||
{ code: 'elderly', label: 'Elderly' },
|
||||
{ code: 'diabetes', label: 'Diabetes' },
|
||||
];
|
||||
|
||||
function renderChips(value: string[]) {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<ConditionChips options={OPTIONS} value={value} onChange={onChange} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
return { onChange };
|
||||
}
|
||||
|
||||
describe('<ConditionChips/> component', () => {
|
||||
it('renders every option', () => {
|
||||
renderChips([]);
|
||||
expect(screen.getByText('Elderly')).toBeInTheDocument();
|
||||
expect(screen.getByText('Diabetes')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds an unselected code on click', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onChange } = renderChips([]);
|
||||
await user.click(screen.getByText('Elderly'));
|
||||
expect(onChange).toHaveBeenCalledWith(['elderly']);
|
||||
});
|
||||
|
||||
it('removes an already-selected code on click', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onChange } = renderChips(['elderly']);
|
||||
await user.click(screen.getByText('Elderly'));
|
||||
expect(onChange).toHaveBeenCalledWith([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Chip from '@mui/material/Chip';
|
||||
|
||||
export interface ConditionOption {
|
||||
/** Stable code stored on the patient (e.g. `elderly`). */
|
||||
code: string;
|
||||
/** Translated display label. */
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface ConditionChipsProps {
|
||||
options: ConditionOption[];
|
||||
/** Selected codes. */
|
||||
value: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-select condition chips (A4). Toggles a stable code in/out of the selected set;
|
||||
* selection is optional. Labels are translated by the caller.
|
||||
* @component ConditionChips
|
||||
*/
|
||||
const ConditionChips: FunctionComponent<ConditionChipsProps> = ({ options, value, onChange, disabled = false }) => {
|
||||
const toggle = (code: string) => {
|
||||
onChange(value.includes(code) ? value.filter((item) => item !== code) : [...value, code]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{options.map((option) => {
|
||||
const selected = value.includes(option.code);
|
||||
return (
|
||||
<Chip
|
||||
key={option.code}
|
||||
label={option.label}
|
||||
data-code={option.code}
|
||||
aria-pressed={selected}
|
||||
clickable
|
||||
disabled={disabled}
|
||||
color={selected ? 'primary' : 'default'}
|
||||
variant={selected ? 'filled' : 'outlined'}
|
||||
onClick={() => toggle(option.code)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConditionChips;
|
||||
@@ -0,0 +1,4 @@
|
||||
import ConditionChips from './ConditionChips';
|
||||
|
||||
export type { ConditionChipsProps, ConditionOption } from './ConditionChips';
|
||||
export { ConditionChips as default, ConditionChips };
|
||||
@@ -0,0 +1,42 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { ThemeProvider } from '../../theme';
|
||||
import GenderToggle from './GenderToggle';
|
||||
|
||||
function renderToggle(value: 'male' | 'female' | null) {
|
||||
const onChange = jest.fn();
|
||||
const utils = render(
|
||||
<ThemeProvider>
|
||||
<GenderToggle value={value} onChange={onChange} maleLabel="Male" femaleLabel="Female" />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
return { ...utils, onChange };
|
||||
}
|
||||
|
||||
describe('<GenderToggle/> component', () => {
|
||||
it('renders both options', () => {
|
||||
renderToggle(null);
|
||||
expect(screen.getByText('Male')).toBeInTheDocument();
|
||||
expect(screen.getByText('Female')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('marks the selected value as pressed', () => {
|
||||
const { container } = renderToggle('female');
|
||||
expect(container.querySelector('[data-gender="female"]')).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(container.querySelector('[data-gender="male"]')).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
|
||||
it('calls onChange with the picked gender', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onChange } = renderToggle(null);
|
||||
await user.click(screen.getByText('Male'));
|
||||
expect(onChange).toHaveBeenCalledWith('male');
|
||||
});
|
||||
|
||||
it('does not fire onChange when the active value is clicked again (no deselect)', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onChange } = renderToggle('male');
|
||||
await user.click(screen.getByText('Male'));
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import ToggleButton from '@mui/material/ToggleButton';
|
||||
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
|
||||
import type { Gender } from '@/services/patients/types';
|
||||
|
||||
export interface GenderToggleProps {
|
||||
/** Current selection; `null` means nothing chosen yet (gender is never defaulted). */
|
||||
value: Gender | null;
|
||||
/** Fires only with a concrete gender — deselecting is ignored so the field stays required. */
|
||||
onChange: (value: Gender) => void;
|
||||
maleLabel: string;
|
||||
femaleLabel: string;
|
||||
/** Marks the group invalid (e.g. submitted without a choice). */
|
||||
error?: boolean;
|
||||
disabled?: boolean;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Required male/female toggle. Gender is **load-bearing** for same-gender caregiver matching
|
||||
* (search/booking), so it is never defaulted and cannot be deselected back to empty via the UI.
|
||||
* Labels are translated by the caller (labels are i18n keys off the code).
|
||||
* @component GenderToggle
|
||||
*/
|
||||
const GenderToggle: FunctionComponent<GenderToggleProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
maleLabel,
|
||||
femaleLabel,
|
||||
error = false,
|
||||
disabled = false,
|
||||
ariaLabel,
|
||||
}) => (
|
||||
<ToggleButtonGroup
|
||||
exclusive
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
onChange={(_event, next: Gender | null) => {
|
||||
if (next) onChange(next);
|
||||
}}
|
||||
sx={{
|
||||
'& .MuiToggleButton-root': {
|
||||
flex: 1,
|
||||
py: 1.25,
|
||||
fontWeight: 600,
|
||||
borderColor: error ? 'var(--bal-error)' : undefined,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ToggleButton value="male" data-gender="male">
|
||||
{maleLabel}
|
||||
</ToggleButton>
|
||||
<ToggleButton value="female" data-gender="female">
|
||||
{femaleLabel}
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
);
|
||||
|
||||
export default GenderToggle;
|
||||
@@ -0,0 +1,4 @@
|
||||
import GenderToggle from './GenderToggle';
|
||||
|
||||
export type { GenderToggleProps } from './GenderToggle';
|
||||
export { GenderToggle as default, GenderToggle };
|
||||
@@ -0,0 +1,60 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { ThemeProvider } from '../../theme';
|
||||
import PatientCard from './PatientCard';
|
||||
import type { Patient } from '@/services/patients/types';
|
||||
|
||||
const PATIENT: Patient = {
|
||||
id: 1,
|
||||
displayName: 'Zahra Mohammadi',
|
||||
firstName: 'Zahra',
|
||||
lastName: 'Mohammadi',
|
||||
birthDate: '1956-01-01',
|
||||
gender: 'female',
|
||||
bloodType: null,
|
||||
initialMedicalNotes: null,
|
||||
isActive: true,
|
||||
relation: 'parent',
|
||||
conditions: ['elderly'],
|
||||
};
|
||||
|
||||
function renderCard() {
|
||||
const onEdit = jest.fn();
|
||||
const onArchive = jest.fn();
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<PatientCard
|
||||
patient={PATIENT}
|
||||
relationLabel="Parent"
|
||||
genderLabel="Female"
|
||||
ageLabel="70 yrs"
|
||||
conditionLabels={['Elderly']}
|
||||
noConditionsLabel="No conditions"
|
||||
onEdit={onEdit}
|
||||
onArchive={onArchive}
|
||||
editLabel="Edit"
|
||||
archiveLabel="Archive"
|
||||
/>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
return { onEdit, onArchive };
|
||||
}
|
||||
|
||||
describe('<PatientCard/> component', () => {
|
||||
it('renders name, relation, meta and conditions', () => {
|
||||
renderCard();
|
||||
expect(screen.getByText('Zahra Mohammadi')).toBeInTheDocument();
|
||||
expect(screen.getByText('Parent')).toBeInTheDocument();
|
||||
expect(screen.getByText('70 yrs · Female')).toBeInTheDocument();
|
||||
expect(screen.getByText('Elderly')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onEdit and onArchive from the action buttons', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onEdit, onArchive } = renderCard();
|
||||
await user.click(screen.getByLabelText('Edit'));
|
||||
await user.click(screen.getByLabelText('Archive'));
|
||||
expect(onEdit).toHaveBeenCalledTimes(1);
|
||||
expect(onArchive).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { AppIconButton } from '@/components/common';
|
||||
import type { Patient } from '@/services/patients/types';
|
||||
|
||||
export interface PatientCardProps {
|
||||
patient: Patient;
|
||||
/** Translated relation label; omitted when the patient has no relation set. */
|
||||
relationLabel?: string;
|
||||
/** Translated gender label. */
|
||||
genderLabel: string;
|
||||
/** Translated age label (e.g. "70 yrs"); omitted when the birth date is unknown. */
|
||||
ageLabel?: string;
|
||||
/** Translated condition labels; empty renders the "no conditions" line. */
|
||||
conditionLabels: string[];
|
||||
noConditionsLabel: string;
|
||||
onEdit: () => void;
|
||||
onArchive: () => void;
|
||||
editLabel: string;
|
||||
archiveLabel: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Patient summary card for the E1 list — relation + name, age/gender, and condition chips,
|
||||
* with edit and archive actions. All display text is translated by the caller.
|
||||
* @component PatientCard
|
||||
*/
|
||||
const PatientCard: FunctionComponent<PatientCardProps> = ({
|
||||
patient,
|
||||
relationLabel,
|
||||
genderLabel,
|
||||
ageLabel,
|
||||
conditionLabels,
|
||||
noConditionsLabel,
|
||||
onEdit,
|
||||
onArchive,
|
||||
editLabel,
|
||||
archiveLabel,
|
||||
}) => {
|
||||
const meta = [ageLabel, genderLabel].filter(Boolean).join(' · ');
|
||||
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Stack direction="row" sx={{ alignItems: 'flex-start', gap: 1 }}>
|
||||
<Stack sx={{ flexGrow: 1, gap: 0.75, minWidth: 0 }}>
|
||||
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{patient.displayName}
|
||||
</Typography>
|
||||
{relationLabel ? (
|
||||
<Chip size="small" label={relationLabel} sx={{ bgcolor: 'var(--bal-primary-soft)', fontWeight: 600 }} />
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
{meta ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{meta}
|
||||
</Typography>
|
||||
) : null}
|
||||
|
||||
{conditionLabels.length > 0 ? (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mt: 0.25 }}>
|
||||
{conditionLabels.map((label) => (
|
||||
<Chip key={label} size="small" variant="outlined" label={label} />
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{noConditionsLabel}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" sx={{ flexShrink: 0 }}>
|
||||
<AppIconButton icon="edit" title={editLabel} aria-label={editLabel} size="small" onClick={onEdit} />
|
||||
<AppIconButton icon="archive" title={archiveLabel} aria-label={archiveLabel} size="small" onClick={onArchive} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default PatientCard;
|
||||
@@ -0,0 +1,4 @@
|
||||
import PatientCard from './PatientCard';
|
||||
|
||||
export type { PatientCardProps } from './PatientCard';
|
||||
export { PatientCard as default, PatientCard };
|
||||
@@ -0,0 +1,50 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { ThemeProvider } from '../../theme';
|
||||
|
||||
jest.mock('next-intl', () => ({ useTranslations: () => (key: string) => key }));
|
||||
|
||||
import PatientForm from './PatientForm';
|
||||
|
||||
function renderForm() {
|
||||
const onSubmit = jest.fn();
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<PatientForm submitLabel="Save" onSubmit={onSubmit} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
return { onSubmit };
|
||||
}
|
||||
|
||||
describe('<PatientForm/> component', () => {
|
||||
it('blocks submit and flags gender when it is missing', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSubmit } = renderForm();
|
||||
await user.type(screen.getByLabelText('name_label'), 'Ali Rezaei');
|
||||
await user.type(screen.getByLabelText('age_label'), '40');
|
||||
await user.click(screen.getByText('Save'));
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
expect(screen.getByText('gender_required')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('submits the mapped patient input once name, age and gender are set', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSubmit } = renderForm();
|
||||
await user.type(screen.getByLabelText('name_label'), 'Ali Rezaei');
|
||||
await user.type(screen.getByLabelText('age_label'), '40');
|
||||
await user.click(screen.getByText('gender_male'));
|
||||
await user.click(screen.getByText('Save'));
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
displayName: 'Ali Rezaei',
|
||||
firstName: 'Ali',
|
||||
lastName: 'Rezaei',
|
||||
gender: 'male',
|
||||
relation: null,
|
||||
conditions: [],
|
||||
birthDate: expect.stringMatching(/^\d{4}-01-01$/),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
'use client';
|
||||
import { FunctionComponent, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import FormLabel from '@mui/material/FormLabel';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { AppButton } from '@/components/common';
|
||||
import GenderToggle from '@/components/GenderToggle';
|
||||
import ConditionChips from '@/components/ConditionChips';
|
||||
import RelationSelect from '@/components/RelationSelect';
|
||||
import { digitsOnly } from '@/utils';
|
||||
import { CONDITION_CODES, RELATION_CODES } from '@/services/patients/constants';
|
||||
import { ageToBirthDate, birthDateToAge } from '@/services/patients/age';
|
||||
import type { ConditionCode, CreatePatientInput, Gender, Patient, Relation } from '@/services/patients/types';
|
||||
|
||||
const MAX_AGE = 120;
|
||||
|
||||
export interface PatientFormProps {
|
||||
/** Prefill for edit, or a relation carried from the onboarding relation step. */
|
||||
initial?: Partial<Pick<Patient, 'displayName' | 'birthDate' | 'gender' | 'conditions' | 'relation'>>;
|
||||
/** Show the relation picker (E1 add/edit). Onboarding hides it — A3 already chose the relation. */
|
||||
showRelation?: boolean;
|
||||
submitLabel: string;
|
||||
submitting?: boolean;
|
||||
onSubmit: (input: CreatePatientInput) => void;
|
||||
onCancel?: () => void;
|
||||
cancelLabel?: string;
|
||||
}
|
||||
|
||||
// A single full-name field (per the wireframe) maps to the contract's first/last/display.
|
||||
function splitName(fullName: string): Pick<CreatePatientInput, 'displayName' | 'firstName' | 'lastName'> {
|
||||
const displayName = fullName.trim();
|
||||
const parts = displayName.split(/\s+/);
|
||||
const firstName = parts[0] ?? '';
|
||||
const lastName = parts.slice(1).join(' ') || firstName;
|
||||
return { displayName, firstName, lastName };
|
||||
}
|
||||
|
||||
/**
|
||||
* The A4 patient form — full name, age, **required** gender, optional condition chips, and
|
||||
* (for E1) the relation. Reused for create and edit. Gender is required and never defaulted;
|
||||
* age maps to `birthDate`. Strings come from the `onboarding` namespace.
|
||||
* @component PatientForm
|
||||
*/
|
||||
const PatientForm: FunctionComponent<PatientFormProps> = ({
|
||||
initial,
|
||||
showRelation = false,
|
||||
submitLabel,
|
||||
submitting = false,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
cancelLabel,
|
||||
}) => {
|
||||
const t = useTranslations('onboarding');
|
||||
|
||||
const [fullName, setFullName] = useState(initial?.displayName ?? '');
|
||||
const [age, setAge] = useState(() => {
|
||||
const initialAge = birthDateToAge(initial?.birthDate);
|
||||
return initialAge == null ? '' : String(initialAge);
|
||||
});
|
||||
const [gender, setGender] = useState<Gender | null>(initial?.gender ?? null);
|
||||
const [conditions, setConditions] = useState<string[]>(initial?.conditions ?? []);
|
||||
const [relation, setRelation] = useState<Relation | null>(initial?.relation ?? null);
|
||||
|
||||
const [nameError, setNameError] = useState(false);
|
||||
const [ageError, setAgeError] = useState(false);
|
||||
const [genderError, setGenderError] = useState(false);
|
||||
|
||||
const conditionOptions = CONDITION_CODES.map((code) => ({ code, label: t(`condition_${code}`) }));
|
||||
const relationOptions = RELATION_CODES.map((code) => ({ code, label: t(`relation_${code}`) }));
|
||||
|
||||
const handleSubmit = () => {
|
||||
const name = fullName.trim();
|
||||
const ageNum = Number(digitsOnly(age));
|
||||
const nameInvalid = name.length === 0;
|
||||
const ageInvalid = age.trim().length === 0 || !Number.isInteger(ageNum) || ageNum < 0 || ageNum > MAX_AGE;
|
||||
const genderInvalid = gender == null;
|
||||
|
||||
setNameError(nameInvalid);
|
||||
setAgeError(ageInvalid);
|
||||
setGenderError(genderInvalid);
|
||||
if (nameInvalid || ageInvalid || genderInvalid) return;
|
||||
|
||||
onSubmit({
|
||||
...splitName(name),
|
||||
birthDate: ageToBirthDate(ageNum),
|
||||
gender: gender as Gender,
|
||||
bloodType: null,
|
||||
initialMedicalNotes: null,
|
||||
relation,
|
||||
conditions: conditions as ConditionCode[],
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 2.5 }}>
|
||||
<TextField
|
||||
label={t('name_label')}
|
||||
value={fullName}
|
||||
onChange={(event) => {
|
||||
setFullName(event.target.value);
|
||||
if (nameError) setNameError(false);
|
||||
}}
|
||||
error={nameError}
|
||||
helperText={nameError ? t('name_required') : undefined}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label={t('age_label')}
|
||||
value={age}
|
||||
onChange={(event) => {
|
||||
setAge(digitsOnly(event.target.value).slice(0, 3));
|
||||
if (ageError) setAgeError(false);
|
||||
}}
|
||||
error={ageError}
|
||||
helperText={ageError ? t('age_invalid') : undefined}
|
||||
slotProps={{ htmlInput: { dir: 'ltr', inputMode: 'numeric', style: { textAlign: 'start' } } }}
|
||||
sx={{ maxWidth: 160 }}
|
||||
/>
|
||||
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<FormLabel error={genderError}>{t('gender_label')}</FormLabel>
|
||||
<GenderToggle
|
||||
value={gender}
|
||||
onChange={(next) => {
|
||||
setGender(next);
|
||||
if (genderError) setGenderError(false);
|
||||
}}
|
||||
maleLabel={t('gender_male')}
|
||||
femaleLabel={t('gender_female')}
|
||||
error={genderError}
|
||||
ariaLabel={t('gender_label')}
|
||||
/>
|
||||
{genderError ? (
|
||||
<Typography variant="caption" sx={{ color: 'var(--bal-error)' }}>
|
||||
{t('gender_required')}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<FormLabel>{t('conditions_label')}</FormLabel>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('conditions_hint')}
|
||||
</Typography>
|
||||
<ConditionChips options={conditionOptions} value={conditions} onChange={setConditions} />
|
||||
</Stack>
|
||||
|
||||
{showRelation ? (
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<FormLabel>{t('relation_title')}</FormLabel>
|
||||
<RelationSelect
|
||||
options={relationOptions}
|
||||
value={relation}
|
||||
onChange={(code) => setRelation(code as Relation)}
|
||||
/>
|
||||
</Stack>
|
||||
) : null}
|
||||
|
||||
<Stack direction="row" sx={{ gap: 1, justifyContent: 'flex-end' }}>
|
||||
{onCancel ? (
|
||||
<AppButton variant="text" onClick={onCancel} disabled={submitting} sx={{ m: 0 }}>
|
||||
{cancelLabel}
|
||||
</AppButton>
|
||||
) : null}
|
||||
<AppButton
|
||||
color="primary"
|
||||
variant="contained"
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{submitLabel}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default PatientForm;
|
||||
@@ -0,0 +1,4 @@
|
||||
import PatientForm from './PatientForm';
|
||||
|
||||
export type { PatientFormProps } from './PatientForm';
|
||||
export { PatientForm as default, PatientForm };
|
||||
@@ -0,0 +1,40 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { ThemeProvider } from '../../theme';
|
||||
import RelationSelect from './RelationSelect';
|
||||
|
||||
const OPTIONS = [
|
||||
{ code: 'parent', label: 'Parent' },
|
||||
{ code: 'self', label: 'Myself' },
|
||||
];
|
||||
|
||||
function renderSelect(value: string | null) {
|
||||
const onChange = jest.fn();
|
||||
const utils = render(
|
||||
<ThemeProvider>
|
||||
<RelationSelect options={OPTIONS} value={value} onChange={onChange} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
return { ...utils, onChange };
|
||||
}
|
||||
|
||||
describe('<RelationSelect/> component', () => {
|
||||
it('renders every relation option', () => {
|
||||
renderSelect(null);
|
||||
expect(screen.getByText('Parent')).toBeInTheDocument();
|
||||
expect(screen.getByText('Myself')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('marks the selected option as checked', () => {
|
||||
const { container } = renderSelect('self');
|
||||
expect(container.querySelector('[data-code="self"]')).toHaveAttribute('aria-checked', 'true');
|
||||
expect(container.querySelector('[data-code="parent"]')).toHaveAttribute('aria-checked', 'false');
|
||||
});
|
||||
|
||||
it('calls onChange with the picked code', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onChange } = renderSelect(null);
|
||||
await user.click(screen.getByText('Parent'));
|
||||
expect(onChange).toHaveBeenCalledWith('parent');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import AppIcon from '@/components/common/AppIcon';
|
||||
|
||||
export interface RelationOption {
|
||||
/** Stable code (`parent`/`spouse`/`child`/`self`). */
|
||||
code: string;
|
||||
/** Translated label. */
|
||||
label: string;
|
||||
/** Optional AppIcon name for the card. */
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface RelationSelectProps {
|
||||
options: RelationOption[];
|
||||
value: string | null;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-select relation picker rendered as radio cards (A3 "who is care for?"). The relation
|
||||
* is a stable enum code carried into the patient; labels are translated by the caller.
|
||||
* @component RelationSelect
|
||||
*/
|
||||
const RelationSelect: FunctionComponent<RelationSelectProps> = ({ options, value, onChange }) => (
|
||||
<Stack role="radiogroup" sx={{ gap: 1.5 }}>
|
||||
{options.map((option) => {
|
||||
const selected = value === option.code;
|
||||
return (
|
||||
<Paper
|
||||
key={option.code}
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
tabIndex={0}
|
||||
data-code={option.code}
|
||||
elevation={0}
|
||||
onClick={() => onChange(option.code)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
onChange(option.code);
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
p: 2,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
border: '2px solid',
|
||||
borderColor: selected ? 'primary.main' : 'divider',
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
{option.icon ? <AppIcon icon={option.icon} size={26} color="var(--bal-primary)" /> : null}
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||
{option.label}
|
||||
</Typography>
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
export default RelationSelect;
|
||||
@@ -0,0 +1,4 @@
|
||||
import RelationSelect from './RelationSelect';
|
||||
|
||||
export type { RelationSelectProps, RelationOption } from './RelationSelect';
|
||||
export { RelationSelect as default, RelationSelect };
|
||||
@@ -32,6 +32,11 @@ import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import MedicalServicesIcon from '@mui/icons-material/MedicalServices';
|
||||
import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import EditIcon from '@mui/icons-material/EditOutlined';
|
||||
import ArchiveIcon from '@mui/icons-material/Inventory2Outlined';
|
||||
import BankIcon from '@mui/icons-material/AccountBalanceOutlined';
|
||||
import CameraIcon from '@mui/icons-material/PhotoCameraOutlined';
|
||||
import WarningIcon from '@mui/icons-material/WarningAmberOutlined';
|
||||
|
||||
/**
|
||||
* List of all available Icon names
|
||||
@@ -79,4 +84,9 @@ export const ICONS /* Note: Setting type disables property autocomplete :( was -
|
||||
visits: MedicalServicesIcon,
|
||||
admin: AdminPanelSettingsIcon,
|
||||
add: AddIcon,
|
||||
edit: EditIcon,
|
||||
archive: ArchiveIcon,
|
||||
bank: BankIcon,
|
||||
camera: CameraIcon,
|
||||
warning: WarningIcon,
|
||||
};
|
||||
|
||||
@@ -6,10 +6,35 @@ import OtpInput from './OtpInput';
|
||||
import PhoneNumberField from './PhoneNumberField';
|
||||
import StepperHeader from './StepperHeader';
|
||||
import StatusChip from './StatusChip';
|
||||
import GenderToggle from './GenderToggle';
|
||||
import ConditionChips from './ConditionChips';
|
||||
import RelationSelect from './RelationSelect';
|
||||
import PatientCard from './PatientCard';
|
||||
import PatientForm from './PatientForm';
|
||||
import BankStatusPanel from './BankStatusPanel';
|
||||
|
||||
export { UserInfo, PlaceholderScreen, OtpInput, PhoneNumberField, StepperHeader, StatusChip };
|
||||
export {
|
||||
UserInfo,
|
||||
PlaceholderScreen,
|
||||
OtpInput,
|
||||
PhoneNumberField,
|
||||
StepperHeader,
|
||||
StatusChip,
|
||||
GenderToggle,
|
||||
ConditionChips,
|
||||
RelationSelect,
|
||||
PatientCard,
|
||||
PatientForm,
|
||||
BankStatusPanel,
|
||||
};
|
||||
export type { PlaceholderScreenProps } from './PlaceholderScreen';
|
||||
export type { OtpInputProps } from './OtpInput';
|
||||
export type { PhoneNumberFieldProps } from './PhoneNumberField';
|
||||
export type { StepperHeaderProps } from './StepperHeader';
|
||||
export type { StatusChipProps, StatusKind } from './StatusChip';
|
||||
export type { GenderToggleProps } from './GenderToggle';
|
||||
export type { ConditionChipsProps, ConditionOption } from './ConditionChips';
|
||||
export type { RelationSelectProps, RelationOption } from './RelationSelect';
|
||||
export type { PatientCardProps } from './PatientCard';
|
||||
export type { PatientFormProps } from './PatientForm';
|
||||
export type { BankStatusPanelProps } from './BankStatusPanel';
|
||||
|
||||
@@ -5,6 +5,8 @@ export const ROUTES = {
|
||||
|
||||
// Customer (family) app — mobile-first, bottom-tab nav
|
||||
HOME: '/',
|
||||
// First-login "who is care for?" flow (A3→A4); re-enterable from the patient list.
|
||||
ONBOARDING: '/onboarding',
|
||||
BOOKINGS: '/bookings',
|
||||
PATIENTS: '/patients',
|
||||
WALLET: '/wallet',
|
||||
@@ -12,6 +14,8 @@ export const ROUTES = {
|
||||
|
||||
// Nurse app
|
||||
NURSE: '/nurse',
|
||||
NURSE_PROFILE: '/nurse/profile',
|
||||
NURSE_BANK: '/nurse/bank',
|
||||
NURSE_VERIFICATION: '/nurse/verification',
|
||||
NURSE_VISITS: '/nurse/visits',
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ const NurseLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
|
||||
const sidebarItems: Array<LinkToPage> = useMemo(
|
||||
() => [
|
||||
{ title: t('dashboard'), path: ROUTES.NURSE, icon: 'dashboard' },
|
||||
{ title: t('profile'), path: ROUTES.NURSE_PROFILE, icon: 'profile' },
|
||||
{ title: t('bank'), path: ROUTES.NURSE_BANK, icon: 'bank' },
|
||||
{ title: t('verification'), path: ROUTES.NURSE_VERIFICATION, icon: 'verification' },
|
||||
{ title: t('visits'), path: ROUTES.NURSE_VISITS, icon: 'visits' },
|
||||
],
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { clientFetch } from '@/lib/api/client';
|
||||
import { unwrap, type ApiEnvelope } from '@/lib/api/types';
|
||||
import { normalizeSheba, shebaBankName } from '../iban';
|
||||
import type { AddBankAccountInput, NurseBankAccountDto, NurseBankAccountsApi } from '../types';
|
||||
|
||||
const BASE = '/api/v1/nurse_bank_accounts';
|
||||
|
||||
/**
|
||||
* Real HTTP implementation of the NurseBankAccountsApi seam (b3 action-style routes). `add`
|
||||
* runs the ownership inquiry server-side and returns the account with `matchedNationalId`
|
||||
* already set, so the real path needs no polling. Selected once USE_NURSE_BANK_MOCK is false.
|
||||
*/
|
||||
export const nurseBankClientApi: NurseBankAccountsApi = {
|
||||
list: async () => unwrap(await clientFetch<ApiEnvelope<NurseBankAccountDto[]>>(`${BASE}/list`)),
|
||||
|
||||
add: async (input: AddBankAccountInput) => {
|
||||
const iban = normalizeSheba(input.iban);
|
||||
return unwrap(
|
||||
await clientFetch<ApiEnvelope<NurseBankAccountDto>>(`${BASE}/add`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ bankName: shebaBankName(iban), accountHolderName: input.accountHolderName, iban }),
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
setPrimary: async (id: number) => {
|
||||
await clientFetch<ApiEnvelope<void>>(`${BASE}/set_primary/${id}`, { method: 'POST' });
|
||||
},
|
||||
|
||||
verifyOwnership: async (id: number) =>
|
||||
unwrap(await clientFetch<ApiEnvelope<NurseBankAccountDto>>(`${BASE}/verify_ownership/${id}`, { method: 'POST' })),
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { USE_NURSE_BANK_MOCK } from '../constants';
|
||||
import type { NurseBankAccountsApi } from '../types';
|
||||
import { nurseBankClientApi } from './clientApi';
|
||||
import { nurseBankMockApi } from './mockApi';
|
||||
|
||||
/**
|
||||
* The selected NurseBankAccountsApi implementation — the single seam hooks import. Selection
|
||||
* is by config (USE_NURSE_BANK_MOCK), never by scattered `if (mock)` checks.
|
||||
*/
|
||||
export const nurseBankApi: NurseBankAccountsApi = USE_NURSE_BANK_MOCK ? nurseBankMockApi : nurseBankClientApi;
|
||||
@@ -0,0 +1,81 @@
|
||||
import { sleep } from '@/utils';
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { KNOWN_MISMATCH_IBAN } from '../constants';
|
||||
import { normalizeSheba, shebaBankName } from '../iban';
|
||||
import type { AddBankAccountInput, NurseBankAccountDto, NurseBankAccountsApi } from '../types';
|
||||
|
||||
const MOCK_LATENCY_MS = 400;
|
||||
|
||||
// The number of list reads the inquiry stays pending before resolving. 2 lets the pending
|
||||
// panel show on the invalidation-triggered read, then flip on the next poll — so the
|
||||
// pending→verified/mismatch transition is visible without a manual reload.
|
||||
const POLLS_BEFORE_RESOLVE = 2;
|
||||
|
||||
interface StoredAccount {
|
||||
dto: NurseBankAccountDto;
|
||||
iban: string; // normalized full value — mock-only; the real value is encrypted server-side
|
||||
pollsLeft: number;
|
||||
}
|
||||
|
||||
let store: StoredAccount[] = [];
|
||||
let nextId = 1;
|
||||
|
||||
function maskIban(normalized: string): string {
|
||||
return `••••${normalized.slice(-4)}`;
|
||||
}
|
||||
|
||||
// Deterministic fake استعلام شبا: every IBAN matches except the configured mismatch IBAN.
|
||||
function resolveIfDue(entry: StoredAccount): void {
|
||||
if (entry.dto.matchedNationalId !== null || entry.pollsLeft <= 0) return;
|
||||
entry.pollsLeft -= 1;
|
||||
if (entry.pollsLeft > 0) return;
|
||||
const matched = normalizeSheba(entry.iban) !== normalizeSheba(KNOWN_MISMATCH_IBAN);
|
||||
entry.dto = { ...entry.dto, matchedNationalId: matched, isVerified: matched };
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory mock behind the NurseBankAccountsApi seam. Drives the pending→verified/mismatch
|
||||
* transition and single-primary enforcement so all three UI states are demonstrable. Mirrors
|
||||
* the real shapes (masked IBAN, `matchedNationalId` gate) for a one-line swap.
|
||||
*/
|
||||
export const nurseBankMockApi: NurseBankAccountsApi = {
|
||||
list: async (): Promise<NurseBankAccountDto[]> => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
store.forEach(resolveIfDue);
|
||||
return store.map((entry) => entry.dto);
|
||||
},
|
||||
|
||||
add: async (input: AddBankAccountInput): Promise<NurseBankAccountDto> => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const normalized = normalizeSheba(input.iban);
|
||||
if (store.some((entry) => entry.iban === normalized)) {
|
||||
throw new ApiError(400, 'Duplicate IBAN', 'iban_duplicate');
|
||||
}
|
||||
const dto: NurseBankAccountDto = {
|
||||
id: nextId++,
|
||||
bankName: shebaBankName(normalized),
|
||||
ibanMasked: maskIban(normalized),
|
||||
isPrimary: store.length === 0,
|
||||
isVerified: false,
|
||||
matchedNationalId: null,
|
||||
};
|
||||
store = [...store, { dto, iban: normalized, pollsLeft: POLLS_BEFORE_RESOLVE }];
|
||||
return dto;
|
||||
},
|
||||
|
||||
setPrimary: async (id: number): Promise<void> => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
if (!store.some((entry) => entry.dto.id === id)) throw new ApiError(404, 'Account not found');
|
||||
store = store.map((entry) => ({ ...entry, dto: { ...entry.dto, isPrimary: entry.dto.id === id } }));
|
||||
},
|
||||
|
||||
verifyOwnership: async (id: number): Promise<NurseBankAccountDto> => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const entry = store.find((item) => item.dto.id === id);
|
||||
if (!entry) throw new ApiError(404, 'Account not found');
|
||||
const matched = normalizeSheba(entry.iban) !== normalizeSheba(KNOWN_MISMATCH_IBAN);
|
||||
entry.dto = { ...entry.dto, matchedNationalId: matched, isVerified: matched };
|
||||
entry.pollsLeft = 0;
|
||||
return entry.dto;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* When true, the nurse bank-account domain is served by the in-memory mock behind the
|
||||
* NurseBankAccountsApi seam. The b3 endpoints are live, but the استعلام شبا ownership
|
||||
* inquiry is itself backend-mocked (`IBankAccountOwnershipVerifier`), so this phase drives
|
||||
* the pending→verified/mismatch UI transition behind the client mock. Flip to false to use
|
||||
* the real endpoints — no hook/component changes (mocks-registry.md).
|
||||
*/
|
||||
export const USE_NURSE_BANK_MOCK = true;
|
||||
|
||||
/** Bank accounts change rarely; keep them warm across screen visits. */
|
||||
export const BANK_STALE_TIME = 30_000;
|
||||
|
||||
/** Poll interval (ms) used only while an account's ownership inquiry is pending. */
|
||||
export const BANK_POLL_INTERVAL_MS = 2_000;
|
||||
|
||||
/**
|
||||
* The IBAN that the (mock) ownership inquiry resolves to a mismatch, so the mismatch UI
|
||||
* state is demonstrable end-to-end. Mirrors the backend default `Seams:BankOwnership:MismatchIban`.
|
||||
*/
|
||||
export const KNOWN_MISMATCH_IBAN = 'IR000000000000000000000000';
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { nurseBankApi } from '../apis';
|
||||
import { bankKeys } from '../keys';
|
||||
import type { AddBankAccountInput } from '../types';
|
||||
|
||||
/**
|
||||
* Submits an IBAN + account-holder name; the server kicks off the ownership inquiry and
|
||||
* returns the account (pending in the mock, resolved on the real path). Invalidates the list
|
||||
* so the pending state — and its later transition — surfaces on the next read/poll. Domain
|
||||
* 400s (invalid/duplicate IBAN, no nurse profile) surface via `mutation.error`.
|
||||
*/
|
||||
export function useAddNurseBankAccount() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (input: AddBankAccountInput) => nurseBankApi.add(input),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: bankKeys.list() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useIsAuthenticated } from '@/hooks';
|
||||
import { nurseBankApi } from '../apis';
|
||||
import { bankKeys } from '../keys';
|
||||
import { BANK_POLL_INTERVAL_MS, BANK_STALE_TIME } from '../constants';
|
||||
import { deriveBankStatus, type NurseBankAccountDto } from '../types';
|
||||
|
||||
/**
|
||||
* The nurse's bank accounts (usually one primary). Polls **only while an account's ownership
|
||||
* inquiry is pending** so the pending→verified/mismatch transition appears without a manual
|
||||
* reload, then stops once every account has resolved.
|
||||
*/
|
||||
export function useNurseBankAccounts() {
|
||||
const isAuthenticated = useIsAuthenticated();
|
||||
return useQuery({
|
||||
queryKey: bankKeys.list(),
|
||||
queryFn: () => nurseBankApi.list(),
|
||||
enabled: isAuthenticated,
|
||||
staleTime: BANK_STALE_TIME,
|
||||
refetchInterval: (query) => {
|
||||
const accounts = (query.state.data ?? []) as NurseBankAccountDto[];
|
||||
return accounts.some((account) => deriveBankStatus(account) === 'pending') ? BANK_POLL_INTERVAL_MS : false;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { nurseBankApi } from '../apis';
|
||||
import { bankKeys } from '../keys';
|
||||
|
||||
/**
|
||||
* Makes an account the payout primary; single-primary enforcement is server-side (the prior
|
||||
* primary is cleared atomically). Invalidates the list so the cache reflects the switch.
|
||||
*/
|
||||
export function useSetPrimaryBankAccount() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => nurseBankApi.setPrimary(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: bankKeys.list() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { digitsOnly } from '@/utils';
|
||||
|
||||
/** An Iranian IBAN (شبا) is `IR` + 24 digits. */
|
||||
export const SHEBA_DIGIT_COUNT = 24;
|
||||
|
||||
/**
|
||||
* Normalizes user input to canonical `IR`+24-digit form: uppercases, strips spaces, drops a
|
||||
* leading `IR`, keeps ASCII digits (Persian/Arabic normalized), caps at 24. Partial input
|
||||
* yields fewer digits (so `isValidSheba` still fails).
|
||||
*/
|
||||
export function normalizeSheba(input: string): string {
|
||||
const raw = input.trim().toUpperCase().replace(/\s+/g, '');
|
||||
const withoutPrefix = raw.startsWith('IR') ? raw.slice(2) : raw;
|
||||
return `IR${digitsOnly(withoutPrefix).slice(0, SHEBA_DIGIT_COUNT)}`;
|
||||
}
|
||||
|
||||
/** True when the input is a well-formed Sheba (`IR` + exactly 24 digits) after normalization. */
|
||||
export function isValidSheba(input: string): boolean {
|
||||
return /^IR\d{24}$/.test(normalizeSheba(input));
|
||||
}
|
||||
|
||||
// Bank identifier = the 3 digits after the 2 check digits (BBAN prefix). Reference data used
|
||||
// to populate the add-body `bankName`; the returned DTO's bankName is authoritative for display.
|
||||
const BANK_NAMES: Record<string, string> = {
|
||||
'011': 'بانک صنعت و معدن',
|
||||
'012': 'بانک ملت',
|
||||
'013': 'بانک رفاه کارگران',
|
||||
'014': 'بانک مسکن',
|
||||
'015': 'بانک سپه',
|
||||
'016': 'بانک کشاورزی',
|
||||
'017': 'بانک ملی ایران',
|
||||
'018': 'بانک تجارت',
|
||||
'019': 'بانک صادرات ایران',
|
||||
'021': 'پست بانک ایران',
|
||||
'053': 'بانک کارآفرین',
|
||||
'054': 'بانک پارسیان',
|
||||
'055': 'بانک اقتصاد نوین',
|
||||
'057': 'بانک پاسارگاد',
|
||||
'062': 'بانک آینده',
|
||||
};
|
||||
|
||||
/** Best-effort bank name from the IBAN's 3-digit bank code; empty string when unknown. */
|
||||
export function shebaBankName(input: string): string {
|
||||
const normalized = normalizeSheba(input);
|
||||
if (!/^IR\d{24}$/.test(normalized)) return '';
|
||||
const bankCode = normalized.slice(4, 7);
|
||||
return BANK_NAMES[bankCode] ?? '';
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { useNurseBankAccounts } from './hooks/useNurseBankAccounts';
|
||||
export { useAddNurseBankAccount } from './hooks/useAddNurseBankAccount';
|
||||
export { useSetPrimaryBankAccount } from './hooks/useSetPrimaryBankAccount';
|
||||
@@ -0,0 +1,5 @@
|
||||
/** React Query key factory for the nurse bank-account domain. */
|
||||
export const bankKeys = {
|
||||
all: ['nurse-bank-accounts'] as const,
|
||||
list: () => [...bankKeys.all, 'list'] as const,
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Nurse payout bank-account sub-domain (kept separate from the profile because
|
||||
* verification/payouts read it independently). Shapes mirror the b3 contract
|
||||
* (`dev/contracts/domains/identity-profiles.md` → `NurseBankAccountDto`). The full IBAN is
|
||||
* never returned — the DTO carries `ibanMasked` (last-4 only).
|
||||
*/
|
||||
|
||||
/** `NurseBankAccountDto`. `matchedNationalId` is null until the ownership inquiry runs. */
|
||||
export interface NurseBankAccountDto {
|
||||
id: number;
|
||||
bankName: string;
|
||||
/** Last-4 only, e.g. `••••3456`. */
|
||||
ibanMasked: string;
|
||||
isPrimary: boolean;
|
||||
isVerified: boolean;
|
||||
matchedNationalId: boolean | null;
|
||||
}
|
||||
|
||||
/** The form input — bankName is derived from the IBAN in the API impl (see iban.ts). */
|
||||
export interface AddBankAccountInput {
|
||||
iban: string;
|
||||
accountHolderName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The three ownership-inquiry UI states. `matchedNationalId` is the gating field:
|
||||
* null → pending, false → mismatch, true → verified (the b13 first-payout gate).
|
||||
*/
|
||||
export type BankAccountStatus = 'pending' | 'verified' | 'mismatch';
|
||||
|
||||
export function deriveBankStatus(account: Pick<NurseBankAccountDto, 'matchedNationalId'>): BankAccountStatus {
|
||||
if (account.matchedNationalId == null) return 'pending';
|
||||
return account.matchedNationalId ? 'verified' : 'mismatch';
|
||||
}
|
||||
|
||||
/** The domain's API seam — a mock and the real client both implement this interface. */
|
||||
export interface NurseBankAccountsApi {
|
||||
list(): Promise<NurseBankAccountDto[]>;
|
||||
add(input: AddBankAccountInput): Promise<NurseBankAccountDto>;
|
||||
setPrimary(id: number): Promise<void>;
|
||||
verifyOwnership(id: number): Promise<NurseBankAccountDto>;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Age ↔ birth-date helpers. The A4 form collects a whole-year **age** (per the wireframe)
|
||||
* while the contract stores a `birthDate` (`YYYY-MM-DD`) — we map between them here. Birth
|
||||
* date is approximated as 1 January of the birth year; that round-trips back to the same age.
|
||||
*/
|
||||
|
||||
/** Approximate ISO birth date (`YYYY-MM-01-01`) for a whole-year age. */
|
||||
export function ageToBirthDate(age: number, now: Date = new Date()): string {
|
||||
const year = now.getUTCFullYear() - Math.max(0, Math.floor(age));
|
||||
return `${year}-01-01`;
|
||||
}
|
||||
|
||||
/** Whole-year age from an ISO birth date (floored); null for an empty/invalid date. */
|
||||
export function birthDateToAge(birthDate: string | null | undefined, now: Date = new Date()): number | null {
|
||||
if (!birthDate) return null;
|
||||
const date = new Date(birthDate);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
let age = now.getUTCFullYear() - date.getUTCFullYear();
|
||||
const monthDelta = now.getUTCMonth() - date.getUTCMonth();
|
||||
if (monthDelta < 0 || (monthDelta === 0 && now.getUTCDate() < date.getUTCDate())) age -= 1;
|
||||
return age < 0 ? null : age;
|
||||
}
|
||||
@@ -1,29 +1,70 @@
|
||||
import { clientFetch } from '@/lib/api/client';
|
||||
import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types';
|
||||
import type { CreatePatientDto, Patient, PatientsApi } from '../types';
|
||||
import type { CreatePatientInput, Patient, PatientDto, PatientsApi } from '../types';
|
||||
|
||||
const BASE = '/patients';
|
||||
const BASE = '/api/v1/patients';
|
||||
|
||||
// The wire `PatientDto` has no relation/conditions yet (REQ-005). Reads default them; writes
|
||||
// echo the caller's choice onto the returned row so the just-edited card reflects it (not
|
||||
// yet persisted server-side).
|
||||
function toPatient(dto: PatientDto, augment?: Pick<CreatePatientInput, 'relation' | 'conditions'>): Patient {
|
||||
return { ...dto, relation: augment?.relation ?? null, conditions: augment?.conditions ?? [] };
|
||||
}
|
||||
|
||||
// Only the wire fields cross the boundary — relation/conditions are client-augmented (REQ-005).
|
||||
function toBody(input: CreatePatientInput) {
|
||||
const { displayName, firstName, lastName, birthDate, gender } = input;
|
||||
return {
|
||||
displayName,
|
||||
firstName,
|
||||
lastName,
|
||||
birthDate,
|
||||
gender,
|
||||
bloodType: input.bloodType ?? null,
|
||||
initialMedicalNotes: input.initialMedicalNotes ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Real HTTP implementation of the PatientsApi seam. Wired to `clientFetch`, which
|
||||
* returns the raw server envelope — so each call reads the payload via `unwrap`.
|
||||
* Not selected until USE_PATIENTS_MOCK is false and the endpoints exist.
|
||||
* Real HTTP implementation of the PatientsApi seam (b3 action-style routes). `clientFetch`
|
||||
* returns the raw envelope, so each call reads its payload via `unwrap`. Selected once
|
||||
* USE_PATIENTS_MOCK is false and the relation/conditions fields land.
|
||||
*/
|
||||
export const patientsClientApi: PatientsApi = {
|
||||
list: async (params) => {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.page) query.set('page', String(params.page));
|
||||
if (params?.pageSize) query.set('page_size', String(params.pageSize));
|
||||
if (params?.pageSize) query.set('pageSize', String(params.pageSize));
|
||||
const qs = query.toString();
|
||||
const env = await clientFetch<ApiEnvelope<Paginated<Patient>>>(`${BASE}${qs ? `?${qs}` : ''}`);
|
||||
return unwrap(env);
|
||||
const page = unwrap(await clientFetch<ApiEnvelope<Paginated<PatientDto>>>(`${BASE}/list${qs ? `?${qs}` : ''}`));
|
||||
return { ...page, items: page.items.map((dto) => toPatient(dto)) };
|
||||
},
|
||||
|
||||
create: async (dto: CreatePatientDto) => {
|
||||
const env = await clientFetch<ApiEnvelope<Patient>>(BASE, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(dto),
|
||||
});
|
||||
return unwrap(env);
|
||||
get: async (id) => toPatient(unwrap(await clientFetch<ApiEnvelope<PatientDto>>(`${BASE}/get/${id}`))),
|
||||
|
||||
create: async (input) =>
|
||||
toPatient(
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<PatientDto>>(`${BASE}/create`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(toBody(input)),
|
||||
}),
|
||||
),
|
||||
input,
|
||||
),
|
||||
|
||||
update: async (id, input) =>
|
||||
toPatient(
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<PatientDto>>(`${BASE}/update/${id}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(toBody(input)),
|
||||
}),
|
||||
),
|
||||
input,
|
||||
),
|
||||
|
||||
archive: async (id) => {
|
||||
await clientFetch<ApiEnvelope<void>>(`${BASE}/archive/${id}`, { method: 'POST' });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,45 +1,72 @@
|
||||
import { sleep } from '@/utils';
|
||||
import type { Paginated } from '@/lib/api/types';
|
||||
import type { CreatePatientDto, Patient, PatientsApi } from '../types';
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import type { PageParams, Paginated } from '@/lib/api/types';
|
||||
import type { CreatePatientInput, Patient, PatientsApi } from '../types';
|
||||
|
||||
const MOCK_LATENCY_MS = 400;
|
||||
const MOCK_LATENCY_MS = 350;
|
||||
|
||||
// In-memory store. Seed timestamps are static strings (not Date.now) so repeated
|
||||
// renders are stable; `create` stamps a real ISO time on the client.
|
||||
let store: Patient[] = [
|
||||
{ id: 2, fullName: 'زهرا محمدی', gender: 'female', createdAtUtc: '2026-05-12T08:30:00Z' },
|
||||
{ id: 1, fullName: 'علی رضایی', gender: 'male', createdAtUtc: '2026-04-03T11:15:00Z' },
|
||||
];
|
||||
let nextId = 3;
|
||||
// In-memory store, seeded **empty** so a single session can demo both the onboarding flow
|
||||
// (A3→A4 creates the first patient) and the E1 empty state. Archive is soft (isActive=false)
|
||||
// and never removes the row — the list simply hides inactive patients.
|
||||
let store: Patient[] = [];
|
||||
let nextId = 1;
|
||||
|
||||
function build(id: number, input: CreatePatientInput, isActive: boolean): Patient {
|
||||
return {
|
||||
id,
|
||||
displayName: input.displayName,
|
||||
firstName: input.firstName,
|
||||
lastName: input.lastName,
|
||||
birthDate: input.birthDate,
|
||||
gender: input.gender,
|
||||
bloodType: input.bloodType ?? null,
|
||||
initialMedicalNotes: input.initialMedicalNotes ?? null,
|
||||
isActive,
|
||||
relation: input.relation,
|
||||
conditions: input.conditions,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory mock behind the PatientsApi seam — the template f1+ follow until the real
|
||||
* `/patients` endpoints are merged. Mirrors the real shapes so swapping is a one-line
|
||||
* change in constants.ts.
|
||||
* In-memory mock behind the PatientsApi seam — the b3 endpoints are live but the wire shape
|
||||
* lacks relation/conditions (REQ-005), so this drives the UI until those land. Mirrors the
|
||||
* real shapes so swapping is a one-line change in constants.ts.
|
||||
*/
|
||||
export const patientsMockApi: PatientsApi = {
|
||||
list: async (params): Promise<Paginated<Patient>> => {
|
||||
list: async (params?: PageParams): Promise<Paginated<Patient>> => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const active = store.filter((patient) => patient.isActive);
|
||||
const page = params?.page ?? 1;
|
||||
const pageSize = params?.pageSize ?? 20;
|
||||
const pageSize = params?.pageSize ?? 50;
|
||||
const start = (page - 1) * pageSize;
|
||||
return {
|
||||
items: store.slice(start, start + pageSize),
|
||||
total: store.length,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
return { items: active.slice(start, start + pageSize), total: active.length, page, pageSize };
|
||||
},
|
||||
|
||||
create: async (dto: CreatePatientDto): Promise<Patient> => {
|
||||
get: async (id: number): Promise<Patient> => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const patient: Patient = {
|
||||
id: nextId++,
|
||||
fullName: dto.fullName,
|
||||
gender: dto.gender,
|
||||
createdAtUtc: new Date().toISOString(),
|
||||
};
|
||||
const found = store.find((patient) => patient.id === id && patient.isActive);
|
||||
if (!found) throw new ApiError(404, 'Patient not found');
|
||||
return found;
|
||||
},
|
||||
|
||||
create: async (input: CreatePatientInput): Promise<Patient> => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const patient = build(nextId++, input, true);
|
||||
store = [patient, ...store];
|
||||
return patient;
|
||||
},
|
||||
|
||||
update: async (id: number, input: CreatePatientInput): Promise<Patient> => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const existing = store.find((patient) => patient.id === id);
|
||||
if (!existing) throw new ApiError(404, 'Patient not found');
|
||||
const updated = build(id, input, existing.isActive);
|
||||
store = store.map((patient) => (patient.id === id ? updated : patient));
|
||||
return updated;
|
||||
},
|
||||
|
||||
archive: async (id: number): Promise<void> => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
store = store.map((patient) => (patient.id === id ? { ...patient, isActive: false } : patient));
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,8 +1,26 @@
|
||||
/**
|
||||
* When true, the domain is served by the in-memory mock (apis/mockApi.ts) behind the
|
||||
* PatientsApi seam. Flip to false once the real `/patients` endpoints land — no hook or
|
||||
* component changes are needed (see dev/shared-working-context/reports/mocks-registry.md).
|
||||
* PatientsApi seam. The b3 `patients/*` endpoints are live, but the wire `PatientDto`
|
||||
* has no `relation`/`conditions` yet (filed as REQ-005), so this phase demos behind the
|
||||
* mock. Flip to false once those fields land — no hook/component changes are needed
|
||||
* (see dev/shared-working-context/reports/mocks-registry.md).
|
||||
*/
|
||||
export const USE_PATIENTS_MOCK = true;
|
||||
|
||||
export const PATIENTS_STALE_TIME = 60_000;
|
||||
|
||||
/** api-conventions default page size; `pageSize` ≤ 100. */
|
||||
export const PATIENTS_PAGE_SIZE = 50;
|
||||
|
||||
/**
|
||||
* Relation of the care recipient to the signed-in customer (payer ≠ patient). A stable
|
||||
* enum code, i18n-labelled — never a hardcoded Persian string in logic. Client-augmented:
|
||||
* not on the wire `PatientDto` yet (REQ-005); carried on create and stored by the mock.
|
||||
*/
|
||||
export const RELATION_CODES = ['parent', 'spouse', 'child', 'self'] as const;
|
||||
|
||||
/**
|
||||
* Common patient conditions surfaced as multi-select chips (A4). Client-augmented (REQ-005);
|
||||
* carried on create and stored by the mock. Codes are stable; labels are i18n keys.
|
||||
*/
|
||||
export const CONDITION_CODES = ['elderly', 'post_surgery', 'diabetes', 'mobility', 'dementia'] as const;
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { patientsApi } from '../apis';
|
||||
import { patientKeys } from '../keys';
|
||||
import type { CreatePatientDto } from '../types';
|
||||
|
||||
/**
|
||||
* Creates a patient and invalidates every patients list so the cache reflects the new
|
||||
* row without a manual refetch. (setQueryData would also work when the API returns the
|
||||
* full new list item and pagination is trivial — invalidation is the safe default.)
|
||||
*/
|
||||
export function useAddPatient() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (dto: CreatePatientDto) => patientsApi.create(dto),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: patientKeys.lists() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import type { Paginated } from '@/lib/api/types';
|
||||
import { patientsApi } from '../apis';
|
||||
import { patientKeys } from '../keys';
|
||||
import type { Patient } from '../types';
|
||||
|
||||
/**
|
||||
* Soft-archives a patient (`isActive=false`, never a hard delete — historical bookings must
|
||||
* survive). Optimistically removes the card from every cached list, then reconciles on
|
||||
* settle; on error the previous cache is restored.
|
||||
*/
|
||||
export function useArchivePatient() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => patientsApi.archive(id),
|
||||
onMutate: async (id) => {
|
||||
await queryClient.cancelQueries({ queryKey: patientKeys.lists() });
|
||||
const previous = queryClient.getQueriesData<Paginated<Patient>>({ queryKey: patientKeys.lists() });
|
||||
previous.forEach(([key, data]) => {
|
||||
if (!data) return;
|
||||
queryClient.setQueryData<Paginated<Patient>>(key, {
|
||||
...data,
|
||||
items: data.items.filter((patient) => patient.id !== id),
|
||||
total: Math.max(0, data.total - 1),
|
||||
});
|
||||
});
|
||||
return { previous };
|
||||
},
|
||||
onError: (_error, _id, context) => {
|
||||
context?.previous?.forEach(([key, data]) => queryClient.setQueryData(key, data));
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: patientKeys.lists() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import type { Paginated } from '@/lib/api/types';
|
||||
import { patientsApi } from '../apis';
|
||||
import { patientKeys } from '../keys';
|
||||
import type { CreatePatientInput, Patient } from '../types';
|
||||
|
||||
/**
|
||||
* Creates a patient. Splices the new row into every cached list immediately (so the E1 list
|
||||
* and the Home onboarding-gate reflect it without waiting for a refetch — no transient
|
||||
* "0 patients" window that would bounce the user back to onboarding), then invalidates to
|
||||
* reconcile. Domain 400s (missing/invalid gender, future birth date) surface via
|
||||
* `mutation.error`; the fetch layer owns 401/403/5xx toasts.
|
||||
*/
|
||||
export function useCreatePatient() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (input: CreatePatientInput) => patientsApi.create(input),
|
||||
onSuccess: (patient) => {
|
||||
queryClient.setQueriesData<Paginated<Patient>>({ queryKey: patientKeys.lists() }, (old) =>
|
||||
old ? { ...old, items: [patient, ...old.items], total: old.total + 1 } : old,
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: patientKeys.lists() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { patientsApi } from '../apis';
|
||||
import { patientKeys } from '../keys';
|
||||
import type { UpdatePatientInput } from '../types';
|
||||
|
||||
/**
|
||||
* Updates a patient (the A4 form reused for edit) and invalidates the lists so the card
|
||||
* reflects the change. A cross-tenant id returns 404 server-side (tenancy is enforced there).
|
||||
*/
|
||||
export function useUpdatePatient() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, input }: { id: number; input: UpdatePatientInput }) => patientsApi.update(id, input),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: patientKeys.lists() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,2 +1,4 @@
|
||||
export { usePatients } from './hooks/usePatients';
|
||||
export { useAddPatient } from './hooks/useAddPatient';
|
||||
export { useCreatePatient } from './hooks/useCreatePatient';
|
||||
export { useUpdatePatient } from './hooks/useUpdatePatient';
|
||||
export { useArchivePatient } from './hooks/useArchivePatient';
|
||||
|
||||
@@ -1,33 +1,67 @@
|
||||
import type { PageParams, Paginated } from '@/lib/api/types';
|
||||
import { CONDITION_CODES, RELATION_CODES } from './constants';
|
||||
|
||||
/**
|
||||
* Patients domain — the reference `services/{domain}` implementation every later
|
||||
* frontend phase copies. Enums cross the wire as stable string codes (money-and-types.md);
|
||||
* mirror them as string-literal unions and never hardcode a display label off the code.
|
||||
* Patients domain — the care-recipient (patient) sub-domain, customer-scoped and
|
||||
* tenancy-enforced server-side. Shapes mirror the b3 contract
|
||||
* (`dev/contracts/domains/identity-profiles.md` → `PatientDto`) exactly; enums cross the
|
||||
* wire as stable string codes (money-and-types.md) mirrored here as unions.
|
||||
*
|
||||
* Deriving these types from the contract: read the domain's shapes from the published
|
||||
* `dev/contracts/domains/<domain>.md` + `dev/contracts/openapi/swagger.v1.json`, mirror
|
||||
* the wire exactly (field names + casing), and map enums to unions here. Until the real
|
||||
* `/patients` endpoints exist, the shapes below are the agreed target the mock honours.
|
||||
* `relation` and `conditions` are **client-augmented**: they are not on the wire
|
||||
* `PatientDto` yet (filed as REQ-005 in requests/for-backend.md). The mock persists them;
|
||||
* the real client carries them through create/update so the just-edited card reflects the
|
||||
* choice, but they are not round-tripped by the server until the backend adds the columns.
|
||||
*/
|
||||
|
||||
export type Gender = 'male' | 'female';
|
||||
export type Relation = (typeof RELATION_CODES)[number];
|
||||
export type ConditionCode = (typeof CONDITION_CODES)[number];
|
||||
|
||||
export interface Patient {
|
||||
/** The b3 wire shape returned by every `patients/*` endpoint. */
|
||||
export interface PatientDto {
|
||||
id: number;
|
||||
fullName: string;
|
||||
displayName: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
/** ISO date `YYYY-MM-DD`. */
|
||||
birthDate: string;
|
||||
gender: Gender;
|
||||
/** UTC ISO-8601; display via formatShamsiDate. */
|
||||
createdAtUtc: string;
|
||||
bloodType: string | null;
|
||||
/** Decrypted, owner-only free-text notes (E2 record viewer, deferred). */
|
||||
initialMedicalNotes: string | null;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface CreatePatientDto {
|
||||
fullName: string;
|
||||
gender: Gender;
|
||||
/** App-level patient = wire shape + the client-augmented relation/conditions. */
|
||||
export interface Patient extends PatientDto {
|
||||
relation: Relation | null;
|
||||
conditions: ConditionCode[];
|
||||
}
|
||||
|
||||
/** The domain's API seam. A mock and the real client both implement this interface. */
|
||||
/**
|
||||
* Create/update input. `firstName`/`lastName`/`displayName` derive from the A4 single
|
||||
* full-name field (split on the first space); `birthDate` derives from the age field.
|
||||
* `bloodType`/`initialMedicalNotes` are deferred to the E2 record viewer.
|
||||
*/
|
||||
export interface CreatePatientInput {
|
||||
displayName: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
birthDate: string;
|
||||
gender: Gender;
|
||||
bloodType?: string | null;
|
||||
initialMedicalNotes?: string | null;
|
||||
relation: Relation | null;
|
||||
conditions: ConditionCode[];
|
||||
}
|
||||
|
||||
export type UpdatePatientInput = CreatePatientInput;
|
||||
|
||||
/** The domain's API seam — a mock and the real client both implement this interface. */
|
||||
export interface PatientsApi {
|
||||
list(params?: PageParams): Promise<Paginated<Patient>>;
|
||||
create(dto: CreatePatientDto): Promise<Patient>;
|
||||
get(id: number): Promise<Patient>;
|
||||
create(input: CreatePatientInput): Promise<Patient>;
|
||||
update(id: number, input: UpdatePatientInput): Promise<Patient>;
|
||||
archive(id: number): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { clientFetch } from '@/lib/api/client';
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { unwrap, type ApiEnvelope } from '@/lib/api/types';
|
||||
import type {
|
||||
AvatarUploadResult,
|
||||
CustomerProfile,
|
||||
CustomerProfileDto,
|
||||
NurseProfile,
|
||||
NurseProfileDto,
|
||||
ProfilesApi,
|
||||
UpsertCustomerProfileInput,
|
||||
UpsertNurseProfileInput,
|
||||
} from '../types';
|
||||
|
||||
const BASE = '/api/v1';
|
||||
|
||||
// A caller with no profile yet gets a 404 from the GET — map that to `null` (an empty form),
|
||||
// not an error. Any other status propagates.
|
||||
async function orNull<T>(promise: Promise<T>): Promise<T | null> {
|
||||
try {
|
||||
return await promise;
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 404) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// The wire DTOs carry no avatar/name yet (REQ-006/007); reads default the augmented fields.
|
||||
function toNurseProfile(dto: NurseProfileDto): NurseProfile {
|
||||
return { ...dto, avatarUrl: null };
|
||||
}
|
||||
function toCustomerProfile(dto: CustomerProfileDto): CustomerProfile {
|
||||
return { ...dto, firstName: null, lastName: null, preferredLanguage: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Real HTTP implementation of the ProfilesApi seam (b3 action-style routes). Selected once
|
||||
* USE_PROFILES_MOCK is false and the avatar/name gaps land. `uploadAvatar` has no route yet
|
||||
* (REQ-006) and the JSON-only fetch layer can't send multipart — it stays mock-only.
|
||||
*/
|
||||
export const profilesClientApi: ProfilesApi = {
|
||||
getCustomerProfile: async () =>
|
||||
orNull(
|
||||
clientFetch<ApiEnvelope<CustomerProfileDto>>(`${BASE}/customer_profiles/me`).then((env) =>
|
||||
toCustomerProfile(unwrap(env)),
|
||||
),
|
||||
),
|
||||
|
||||
upsertCustomerProfile: async (input: UpsertCustomerProfileInput) =>
|
||||
toCustomerProfile(
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<CustomerProfileDto>>(`${BASE}/customer_profiles/upsert`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
defaultEmergencyContactName: input.defaultEmergencyContactName,
|
||||
defaultEmergencyContactPhone: input.defaultEmergencyContactPhone,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
getNurseProfile: async () =>
|
||||
orNull(
|
||||
clientFetch<ApiEnvelope<NurseProfileDto>>(`${BASE}/nurse_profiles/me`).then((env) => toNurseProfile(unwrap(env))),
|
||||
),
|
||||
|
||||
upsertNurseProfile: async (input: UpsertNurseProfileInput) =>
|
||||
toNurseProfile(
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<NurseProfileDto>>(`${BASE}/nurse_profiles/upsert`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
bio: input.bio,
|
||||
yearsOfExperience: input.yearsOfExperience,
|
||||
educationLevel: input.educationLevel,
|
||||
educationField: input.educationField,
|
||||
specializationsJson: input.specializationsJson,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
uploadAvatar: async (): Promise<AvatarUploadResult> => {
|
||||
throw new ApiError(501, 'Avatar upload has no backend route yet (REQ-006); served by the mock.');
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { USE_PROFILES_MOCK } from '../constants';
|
||||
import type { ProfilesApi } from '../types';
|
||||
import { profilesClientApi } from './clientApi';
|
||||
import { profilesMockApi } from './mockApi';
|
||||
|
||||
/**
|
||||
* The selected ProfilesApi implementation — the single seam hooks import. Selection is by
|
||||
* config (USE_PROFILES_MOCK), never by scattered `if (mock)` checks.
|
||||
*/
|
||||
export const profilesApi: ProfilesApi = USE_PROFILES_MOCK ? profilesMockApi : profilesClientApi;
|
||||
@@ -0,0 +1,73 @@
|
||||
import { sleep } from '@/utils';
|
||||
import type {
|
||||
AvatarUploadResult,
|
||||
CustomerProfile,
|
||||
NurseProfile,
|
||||
ProfilesApi,
|
||||
UpsertCustomerProfileInput,
|
||||
UpsertNurseProfileInput,
|
||||
} from '../types';
|
||||
|
||||
const MOCK_LATENCY_MS = 350;
|
||||
|
||||
// Both profiles start absent (a fresh user has none — the real GET would 404). Bootstrapping
|
||||
// via upsert creates them; `isVerified` and the aggregates stay server-owned defaults.
|
||||
let customerProfile: CustomerProfile | null = null;
|
||||
let nurseProfile: NurseProfile | null = null;
|
||||
|
||||
/**
|
||||
* In-memory mock behind the ProfilesApi seam. Mirrors the b3 shapes and keeps the guarded
|
||||
* read-only fields (`isVerified=false`, zero aggregates) exactly as the server would, so a
|
||||
* bootstrapped nurse is never presented as verified/bookable.
|
||||
*/
|
||||
export const profilesMockApi: ProfilesApi = {
|
||||
getCustomerProfile: async () => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
return customerProfile;
|
||||
},
|
||||
|
||||
upsertCustomerProfile: async (input: UpsertCustomerProfileInput) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
customerProfile = {
|
||||
id: customerProfile?.id ?? 1,
|
||||
defaultEmergencyContactName: input.defaultEmergencyContactName,
|
||||
defaultEmergencyContactPhone: input.defaultEmergencyContactPhone,
|
||||
firstName: input.firstName ?? customerProfile?.firstName ?? null,
|
||||
lastName: input.lastName ?? customerProfile?.lastName ?? null,
|
||||
preferredLanguage: input.preferredLanguage ?? customerProfile?.preferredLanguage ?? null,
|
||||
};
|
||||
return customerProfile;
|
||||
},
|
||||
|
||||
getNurseProfile: async () => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
return nurseProfile;
|
||||
},
|
||||
|
||||
upsertNurseProfile: async (input: UpsertNurseProfileInput) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
nurseProfile = {
|
||||
id: nurseProfile?.id ?? 1,
|
||||
bio: input.bio,
|
||||
yearsOfExperience: input.yearsOfExperience,
|
||||
educationLevel: input.educationLevel,
|
||||
educationField: input.educationField,
|
||||
specializationsJson: input.specializationsJson,
|
||||
// Server-owned, guarded — a bootstrapped profile is never verified or bookable.
|
||||
isVerified: false,
|
||||
isAcceptingBookings: nurseProfile?.isAcceptingBookings ?? false,
|
||||
averageRating: 0,
|
||||
totalReviews: 0,
|
||||
totalCompletedBookings: 0,
|
||||
avatarUrl: input.avatarUrl ?? nurseProfile?.avatarUrl ?? null,
|
||||
};
|
||||
return nurseProfile;
|
||||
},
|
||||
|
||||
uploadAvatar: async (file: File): Promise<AvatarUploadResult> => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
// Object URL reflects the actual picked image for the demo; the real impl returns an
|
||||
// object-storage URL (REQ-006).
|
||||
return { url: URL.createObjectURL(file) };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* When true, the profiles domain is served by the in-memory mock behind the ProfilesApi
|
||||
* seam. The b3 `customer_profiles/*` and `nurse_profiles/*` endpoints are live, but the
|
||||
* avatar/object-storage route and the customer name/preferred-language fields are gaps
|
||||
* (REQ-006 / REQ-007), so this phase demos behind the mock. Flip to false once those land —
|
||||
* no hook/component changes (see dev/shared-working-context/reports/mocks-registry.md).
|
||||
*/
|
||||
export const USE_PROFILES_MOCK = true;
|
||||
|
||||
/** Profiles are stable within a session; revisiting a screen shouldn't refetch. */
|
||||
export const PROFILE_STALE_TIME = 60_000;
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useIsAuthenticated } from '@/hooks';
|
||||
import { profilesApi } from '../apis';
|
||||
import { profileKeys } from '../keys';
|
||||
import { PROFILE_STALE_TIME } from '../constants';
|
||||
|
||||
/** The signed-in customer's payer profile (emergency contact + name). `null` until created. */
|
||||
export function useCustomerProfile() {
|
||||
const isAuthenticated = useIsAuthenticated();
|
||||
return useQuery({
|
||||
queryKey: profileKeys.customer(),
|
||||
queryFn: () => profilesApi.getCustomerProfile(),
|
||||
enabled: isAuthenticated,
|
||||
staleTime: PROFILE_STALE_TIME,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useIsAuthenticated } from '@/hooks';
|
||||
import { profilesApi } from '../apis';
|
||||
import { profileKeys } from '../keys';
|
||||
import { PROFILE_STALE_TIME } from '../constants';
|
||||
|
||||
/** The signed-in nurse's own seller profile. `null` until bootstrapped (B7). */
|
||||
export function useNurseProfile() {
|
||||
const isAuthenticated = useIsAuthenticated();
|
||||
return useQuery({
|
||||
queryKey: profileKeys.nurse(),
|
||||
queryFn: () => profilesApi.getNurseProfile(),
|
||||
enabled: isAuthenticated,
|
||||
staleTime: PROFILE_STALE_TIME,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { profilesApi } from '../apis';
|
||||
|
||||
/**
|
||||
* Uploads an avatar image and returns its URL. The caller folds the returned URL into the
|
||||
* next profile upsert. Backed by the mock until the object-storage route lands (REQ-006).
|
||||
*/
|
||||
export function useUploadAvatar() {
|
||||
return useMutation({
|
||||
mutationFn: (file: File) => profilesApi.uploadAvatar(file),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { authKeys } from '@/services/auth/keys';
|
||||
import { profilesApi } from '../apis';
|
||||
import { profileKeys } from '../keys';
|
||||
import type { UpsertCustomerProfileInput } from '../types';
|
||||
|
||||
/**
|
||||
* Creates/updates the customer profile. Writes the fresh profile straight into cache and
|
||||
* invalidates `/me` so a profile-completion change reflects in the Home nudge (b3 also
|
||||
* auto-provisions a thin customer profile, flipping `hasCustomerProfile`).
|
||||
*/
|
||||
export function useUpsertCustomerProfile() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (input: UpsertCustomerProfileInput) => profilesApi.upsertCustomerProfile(input),
|
||||
onSuccess: (profile) => {
|
||||
queryClient.setQueryData(profileKeys.customer(), profile);
|
||||
queryClient.invalidateQueries({ queryKey: authKeys.me() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { authKeys } from '@/services/auth/keys';
|
||||
import { profilesApi } from '../apis';
|
||||
import { profileKeys } from '../keys';
|
||||
import type { UpsertNurseProfileInput } from '../types';
|
||||
|
||||
/**
|
||||
* Bootstraps (first entry) or edits the nurse profile via the single b3 upsert. `isVerified`
|
||||
* is never sent — it stays server-owned/false. Writes the fresh profile to cache and
|
||||
* invalidates `/me` so `hasNurseProfile` reflects the bootstrap.
|
||||
*/
|
||||
export function useUpsertNurseProfile() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (input: UpsertNurseProfileInput) => profilesApi.upsertNurseProfile(input),
|
||||
onSuccess: (profile) => {
|
||||
queryClient.setQueryData(profileKeys.nurse(), profile);
|
||||
queryClient.invalidateQueries({ queryKey: authKeys.me() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { useCustomerProfile } from './hooks/useCustomerProfile';
|
||||
export { useUpsertCustomerProfile } from './hooks/useUpsertCustomerProfile';
|
||||
export { useNurseProfile } from './hooks/useNurseProfile';
|
||||
export { useUpsertNurseProfile } from './hooks/useUpsertNurseProfile';
|
||||
export { useUploadAvatar } from './hooks/useUploadAvatar';
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* React Query key factory for the profiles domain. The customer and nurse profiles are
|
||||
* distinct owner-scoped resources, each its own key.
|
||||
*/
|
||||
export const profileKeys = {
|
||||
all: ['profiles'] as const,
|
||||
customer: () => [...profileKeys.all, 'customer'] as const,
|
||||
nurse: () => [...profileKeys.all, 'nurse'] as const,
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Profiles domain — the nurse *seller* profile and the customer *payer* profile. Shapes
|
||||
* mirror the b3 contract (`dev/contracts/domains/identity-profiles.md` → `NurseProfileDto`,
|
||||
* `CustomerProfileDto`) exactly. The bank account is a separate domain (`services/nurse`).
|
||||
*
|
||||
* Client-augmented fields (not on the wire yet — filed in requests/for-backend.md):
|
||||
* - nurse `avatarUrl` — no avatar/object-storage route in b3 (REQ-006).
|
||||
* - customer `firstName`/`lastName`/`preferredLanguage` — the wire `CustomerProfileDto`
|
||||
* carries only the emergency contact; name lives on `/me` with no update endpoint (REQ-007).
|
||||
* The mock persists these; the real client sends only the wire fields.
|
||||
*/
|
||||
|
||||
/** `NurseProfileDto` — `isVerified` + the three aggregates are server-owned and read-only. */
|
||||
export interface NurseProfileDto {
|
||||
id: number;
|
||||
bio: string;
|
||||
yearsOfExperience: number;
|
||||
educationLevel: string;
|
||||
educationField: string;
|
||||
/** Raw JSON array string of specialization codes (the builder is deferred to f4). */
|
||||
specializationsJson: string;
|
||||
isVerified: boolean;
|
||||
isAcceptingBookings: boolean;
|
||||
averageRating: number;
|
||||
totalReviews: number;
|
||||
totalCompletedBookings: number;
|
||||
}
|
||||
|
||||
export interface NurseProfile extends NurseProfileDto {
|
||||
avatarUrl: string | null;
|
||||
}
|
||||
|
||||
/** `nurse_profiles/upsert` body — never carries `isVerified` or the aggregates. */
|
||||
export interface UpsertNurseProfileInput {
|
||||
bio: string;
|
||||
yearsOfExperience: number;
|
||||
educationLevel: string;
|
||||
educationField: string;
|
||||
specializationsJson: string;
|
||||
avatarUrl?: string | null;
|
||||
}
|
||||
|
||||
/** `CustomerProfileDto` — emergency contact returned in full to the owning customer. */
|
||||
export interface CustomerProfileDto {
|
||||
id: number;
|
||||
defaultEmergencyContactName: string;
|
||||
defaultEmergencyContactPhone: string;
|
||||
}
|
||||
|
||||
export interface CustomerProfile extends CustomerProfileDto {
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
preferredLanguage: string | null;
|
||||
}
|
||||
|
||||
/** `customer_profiles/upsert` body (emergency contact) + client-augmented name/language. */
|
||||
export interface UpsertCustomerProfileInput {
|
||||
defaultEmergencyContactName: string;
|
||||
defaultEmergencyContactPhone: string;
|
||||
firstName?: string | null;
|
||||
lastName?: string | null;
|
||||
preferredLanguage?: string | null;
|
||||
}
|
||||
|
||||
export interface AvatarUploadResult {
|
||||
url: string;
|
||||
}
|
||||
|
||||
/** The domain's API seam. `get*` resolve to `null` when the caller has no profile yet (404). */
|
||||
export interface ProfilesApi {
|
||||
getCustomerProfile(): Promise<CustomerProfile | null>;
|
||||
upsertCustomerProfile(input: UpsertCustomerProfileInput): Promise<CustomerProfile>;
|
||||
getNurseProfile(): Promise<NurseProfile | null>;
|
||||
upsertNurseProfile(input: UpsertNurseProfileInput): Promise<NurseProfile>;
|
||||
uploadAvatar(file: File): Promise<AvatarUploadResult>;
|
||||
}
|
||||
@@ -25,6 +25,8 @@
|
||||
--bal-primary-light: #2f6b5e;
|
||||
--bal-primary-dark: #123029;
|
||||
--bal-primary-contrast: #f3efe9;
|
||||
/* Soft primary tint — selected chips, subtle info panels */
|
||||
--bal-primary-soft: rgba(29, 74, 64, 0.10);
|
||||
|
||||
/* Secondary — terracotta */
|
||||
--bal-secondary: #d98c6a;
|
||||
@@ -61,6 +63,8 @@
|
||||
--bal-primary-light: #8fd2c1;
|
||||
--bal-primary-dark: #3f8a78;
|
||||
--bal-primary-contrast: #06120f;
|
||||
/* Soft primary tint — selected chips, subtle info panels */
|
||||
--bal-primary-soft: rgba(111, 192, 172, 0.16);
|
||||
|
||||
/* Secondary — warm terracotta-light */
|
||||
--bal-secondary: #e6a98a;
|
||||
|
||||
Reference in New Issue
Block a user