frontend phase 2: onboarding & profiles — customer/patient, nurse profile & bank

Turns a logged-in user into a usable account, consuming the b3 identity-profiles
contract behind the services/{domain} seam.

Services (mock default true; real HTTP clients wired for a one-line flip):
- services/patients: rewritten to b3 PatientDto + client-augmented relation/conditions;
  full CRUD, optimistic soft-archive, cache-splice on create, age<->birthDate helper.
- services/profiles: customer + nurse profile get/upsert + avatar (404->null mapping).
- services/nurse: payout bank accounts + IBAN(Sheba) util + pending-only polling.

Screens: A3->A4 onboarding wizard, E1 patients list/CRUD, A5 home (first-login gate +
nudge), customer profile (no national-ID), nurse profile bootstrap (unverified
placeholder), nurse bank settings (pending/verified/mismatch + make-primary).

Shared composites (each tested): GenderToggle, ConditionChips, RelationSelect,
PatientForm, PatientCard, BankStatusPanel; reuses f0 StepperHeader/StatusChip/PhoneField.
Adds onboarding/home/profile/nurseProfile/bank i18n namespaces (both locales, in sync),
the --bal-primary-soft token, and nurse sidebar Profile + Bank entries.

Contract gaps filed: REQ-005 (patient relation/conditions), REQ-006 (avatar route),
REQ-007 (customer name/language). Gate: check + 112 tests + build all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamid
2026-07-02 22:04:38 +03:30
parent 82561c4cc6
commit 4b4243c451
70 changed files with 3111 additions and 190 deletions
@@ -0,0 +1,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 };