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,158 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import { Box, Paper, Stack, TextField, Typography } from '@mui/material';
import { AppButton, AppIcon, AppLoading, BankStatusPanel } from '@/components';
import { useNurseBankAccounts, useAddNurseBankAccount, useSetPrimaryBankAccount } from '@/services/nurse';
import { isValidSheba } from '@/services/nurse/iban';
import { deriveBankStatus } from '@/services/nurse/types';
/**
* Nurse payout bank settings — submit an IBAN (شبا) + account-holder name, then watch the
* ownership inquiry resolve through its three states (pending → verified / mismatch). The list
* polls only while pending; a verified account shows the masked IBAN; mismatch offers re-enter.
*/
export default function NurseBankPage() {
const t = useTranslations('bank');
const { enqueueSnackbar } = useSnackbar();
const { data, isLoading } = useNurseBankAccounts();
const addAccount = useAddNurseBankAccount();
const setPrimary = useSetPrimaryBankAccount();
const [iban, setIban] = useState('');
const [holder, setHolder] = useState('');
const [ibanError, setIbanError] = useState(false);
const [holderError, setHolderError] = useState(false);
const [showForm, setShowForm] = useState(false);
const accounts = data ?? [];
const showFormNow = !isLoading && (accounts.length === 0 || showForm);
const submit = () => {
const ibanInvalid = !isValidSheba(iban);
const holderInvalid = holder.trim().length === 0;
setIbanError(ibanInvalid);
setHolderError(holderInvalid);
if (ibanInvalid || holderInvalid) return;
addAccount.mutate(
{ iban, accountHolderName: holder.trim() },
{
onSuccess: () => {
setIban('');
setHolder('');
setShowForm(false);
enqueueSnackbar(t('added'), { variant: 'success' });
},
onError: () => enqueueSnackbar(t('add_error'), { variant: 'error' }),
},
);
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 560 }}>
<Box>
<Typography variant="h5" component="h1">
{t('title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('subtitle')}
</Typography>
</Box>
{isLoading ? <AppLoading /> : null}
{accounts.map((account) => {
const status = deriveBankStatus(account);
return (
<Stack key={account.id} sx={{ gap: 1 }}>
<BankStatusPanel
status={status}
chipLabel={t(`status_${status}_chip`)}
title={t(`status_${status}_title`)}
body={t(`status_${status}_body`)}
ibanMasked={status === 'verified' ? account.ibanMasked : undefined}
ibanLabel={t('iban_masked_label')}
bankName={account.bankName || undefined}
isPrimary={account.isPrimary}
primaryLabel={t('primary')}
onReenter={status === 'mismatch' ? () => setShowForm(true) : undefined}
reenterLabel={t('reenter')}
/>
{/* Promote a verified non-primary account so payouts (gated on matchedNationalId) target it. */}
{status === 'verified' && !account.isPrimary ? (
<AppButton
variant="text"
color="primary"
disabled={setPrimary.isPending}
onClick={() =>
setPrimary.mutate(account.id, {
onSuccess: () => enqueueSnackbar(t('primary_set'), { variant: 'success' }),
})
}
sx={{ m: 0, alignSelf: 'flex-start' }}
>
{t('make_primary')}
</AppButton>
) : null}
</Stack>
);
})}
{!isLoading && accounts.length === 0 ? (
<Paper
elevation={0}
sx={{ p: 3, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}
>
<AppIcon icon="bank" size={36} color="var(--bal-primary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('empty_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('empty_body')}
</Typography>
</Paper>
) : null}
{showFormNow ? (
<Stack sx={{ gap: 2 }}>
<TextField
label={t('iban_label')}
value={iban}
onChange={(e) => {
setIban(e.target.value.toUpperCase());
if (ibanError) setIbanError(false);
}}
error={ibanError}
helperText={ibanError ? t('iban_invalid') : t('iban_hint')}
slotProps={{ htmlInput: { dir: 'ltr', style: { textAlign: 'start', letterSpacing: 1 } } }}
fullWidth
/>
<TextField
label={t('holder_label')}
value={holder}
onChange={(e) => {
setHolder(e.target.value);
if (holderError) setHolderError(false);
}}
error={holderError}
helperText={holderError ? t('holder_required') : t('holder_hint')}
fullWidth
/>
<AppButton
color="primary"
variant="contained"
startIcon="bank"
onClick={submit}
disabled={addAccount.isPending}
sx={{ m: 0, alignSelf: 'flex-start' }}
>
{addAccount.isPending ? t('submitting') : t('submit')}
</AppButton>
</Stack>
) : null}
</Box>
);
}