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 };