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