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,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 };
|
||||
Reference in New Issue
Block a user