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
@@ -1,8 +1,128 @@
import { getTranslations } from 'next-intl/server';
import { PlaceholderScreen } from '@/components';
'use client';
import { FunctionComponent, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import { Box, Divider, MenuItem, Stack, TextField, Typography } from '@mui/material';
import { AppButton, AppLoading, PhoneNumberField } from '@/components';
import { isIranianMobile } from '@/components/PhoneNumberField';
import { digitsOnly } from '@/utils';
import { useCustomerProfile, useUpsertCustomerProfile } from '@/services/profiles';
import type { CustomerProfile } from '@/services/profiles/types';
export default async function ProfilePage() {
const t = await getTranslations('nav');
const tShell = await getTranslations('shell');
return <PlaceholderScreen icon="profile" title={t('profile')} description={tShell('placeholder_body')} />;
/** Customer profile — name, preferred language, and the emergency contact. No national-ID KYC. */
export default function CustomerProfilePage() {
const { data: profile, isLoading } = useCustomerProfile();
if (isLoading) return <AppLoading />;
return <CustomerProfileForm initial={profile ?? null} />;
}
const CustomerProfileForm: FunctionComponent<{ initial: CustomerProfile | null }> = ({ initial }) => {
const t = useTranslations('profile');
const tc = useTranslations('common');
const { enqueueSnackbar } = useSnackbar();
const upsert = useUpsertCustomerProfile();
const [firstName, setFirstName] = useState(initial?.firstName ?? '');
const [lastName, setLastName] = useState(initial?.lastName ?? '');
const [language, setLanguage] = useState(initial?.preferredLanguage ?? 'fa');
const [emergencyName, setEmergencyName] = useState(initial?.defaultEmergencyContactName ?? '');
const [emergencyPhone, setEmergencyPhone] = useState(digitsOnly(initial?.defaultEmergencyContactPhone ?? ''));
const [nameError, setNameError] = useState(false);
const [phoneError, setPhoneError] = useState(false);
const isComplete = Boolean(initial?.defaultEmergencyContactName && initial?.defaultEmergencyContactPhone);
const handleSave = () => {
const nameInvalid = emergencyName.trim().length === 0;
const phoneInvalid = !isIranianMobile(emergencyPhone);
setNameError(nameInvalid);
setPhoneError(phoneInvalid);
if (nameInvalid || phoneInvalid) return;
upsert.mutate(
{
defaultEmergencyContactName: emergencyName.trim(),
defaultEmergencyContactPhone: emergencyPhone,
firstName: firstName.trim() || null,
lastName: lastName.trim() || null,
preferredLanguage: language,
},
{ onSuccess: () => enqueueSnackbar(t('saved'), { variant: 'success' }) },
);
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 520 }}>
<Box>
<Typography variant="h5" component="h1">
{t('title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('subtitle')}
</Typography>
<Typography variant="body2" sx={{ mt: 0.5, color: isComplete ? 'var(--bal-success)' : 'text.secondary' }}>
{isComplete ? t('completion_done') : t('completion_todo')}
</Typography>
</Box>
<Stack direction={{ xs: 'column', sm: 'row' }} sx={{ gap: 2 }}>
<TextField label={t('first_name')} value={firstName} onChange={(e) => setFirstName(e.target.value)} fullWidth />
<TextField label={t('last_name')} value={lastName} onChange={(e) => setLastName(e.target.value)} fullWidth />
</Stack>
<TextField
select
label={t('language')}
value={language}
onChange={(e) => setLanguage(e.target.value)}
sx={{ maxWidth: 220 }}
>
<MenuItem value="fa">{t('language_fa')}</MenuItem>
<MenuItem value="en">{t('language_en')}</MenuItem>
</TextField>
<Divider />
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('emergency_section')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('emergency_hint')}
</Typography>
</Box>
<TextField
label={t('emergency_name')}
value={emergencyName}
onChange={(e) => {
setEmergencyName(e.target.value);
if (nameError) setNameError(false);
}}
error={nameError}
fullWidth
/>
<PhoneNumberField
label={t('emergency_phone')}
value={emergencyPhone}
onChange={(value) => {
setEmergencyPhone(value);
if (phoneError) setPhoneError(false);
}}
error={phoneError}
helperText={phoneError ? t('emergency_phone_invalid') : undefined}
fullWidth
/>
<AppButton
color="primary"
variant="contained"
onClick={handleSave}
disabled={upsert.isPending}
sx={{ m: 0, alignSelf: 'flex-start' }}
>
{upsert.isPending ? tc('saving') : t('save')}
</AppButton>
</Box>
);
};