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