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,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,
};
+26 -1
View File
@@ -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';