4b4243c451
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>
183 lines
6.3 KiB
TypeScript
183 lines
6.3 KiB
TypeScript
'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;
|