ui phase 8

This commit is contained in:
hamid
2026-07-19 13:57:11 +03:30
parent edc38543fd
commit 1ef4feb911
41 changed files with 2113 additions and 667 deletions
@@ -1,18 +1,30 @@
'use client';
import { ChangeEvent, FunctionComponent, useRef, useState } from 'react';
import { ChangeEvent, FunctionComponent, useEffect, useRef, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import { Avatar, Box, Paper, Stack, TextField, Typography } from '@mui/material';
import { Avatar, Box, Chip, MenuItem, Paper, Stack, TextField, Typography } from '@mui/material';
import { AppButton, AppIcon, AppLoading, TrustBadge } from '@/components';
import { ROUTES } from '@/constants';
import { useNurseProfile, useUpsertNurseProfile, useUploadAvatar } from '@/services/profiles';
import type { NurseProfile } from '@/services/profiles/types';
import { useVerificationStatus } from '@/services/verification';
import { ownBadgeState } from '@/services/verification/types';
import { ownBadgeState, SPECIALTY_PRESETS } from '@/services/verification/types';
const MAX_YEARS = 80;
const OTHER_CODE = '__other';
const EDUCATION_LEVELS = ['diploma', 'associate', 'bachelor', 'master', 'doctorate'] as const;
const EDUCATION_FIELDS = ['nursing', 'midwifery', 'anesthesia', 'operating_room', 'public_health'] as const;
/** Nurse profile bootstrap (B7 header): avatar + bio + years. Services/availability are deferred (f4). */
function parseSpecializations(json: string): string[] {
try {
const parsed = JSON.parse(json);
return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === 'string') : [];
} catch {
return [];
}
}
/** Nurse profile bootstrap (B7 header): avatar + bio + years + qualifications. */
export default function NurseProfilePage() {
const { data: profile, isLoading } = useNurseProfile();
if (isLoading) return <AppLoading />;
@@ -21,6 +33,7 @@ export default function NurseProfilePage() {
const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({ initial }) => {
const t = useTranslations('nurseProfile');
const tv = useTranslations('verification');
const tc = useTranslations('common');
const locale = useLocale();
const { enqueueSnackbar } = useSnackbar();
@@ -35,6 +48,37 @@ const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({
const [years, setYears] = useState(initial ? String(initial.yearsOfExperience) : '');
const [yearsError, setYearsError] = useState(false);
const initialLevel = initial?.educationLevel ?? '';
const initialField = initial?.educationField ?? '';
const [educationLevel, setEducationLevel] = useState(
(EDUCATION_LEVELS as readonly string[]).includes(initialLevel) ? initialLevel : initialLevel ? OTHER_CODE : '',
);
const [educationLevelOther, setEducationLevelOther] = useState(
(EDUCATION_LEVELS as readonly string[]).includes(initialLevel) ? '' : initialLevel,
);
const [educationField, setEducationField] = useState(
(EDUCATION_FIELDS as readonly string[]).includes(initialField) ? initialField : initialField ? OTHER_CODE : '',
);
const [educationFieldOther, setEducationFieldOther] = useState(
(EDUCATION_FIELDS as readonly string[]).includes(initialField) ? '' : initialField,
);
const [specializations, setSpecializations] = useState<string[]>(
parseSpecializations(initial?.specializationsJson ?? '[]'),
);
// A staged-but-unsaved avatar must never be silently discarded — warn on reload/tab-close.
const avatarDirty = avatarUrl !== (initial?.avatarUrl ?? null);
useEffect(() => {
if (!avatarDirty) return;
const handler = (event: BeforeUnloadEvent) => {
event.preventDefault();
// Chrome (and most engines) only show the native confirm dialog when returnValue is set.
event.returnValue = '';
};
window.addEventListener('beforeunload', handler);
return () => window.removeEventListener('beforeunload', handler);
}, [avatarDirty]);
const pickFile = () => fileInputRef.current?.click();
const onFileSelected = (event: ChangeEvent<HTMLInputElement>) => {
@@ -47,6 +91,9 @@ const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({
});
};
const toggleSpecialty = (value: string) =>
setSpecializations((prev) => (prev.includes(value) ? prev.filter((item) => item !== value) : [...prev, value]));
const handleSave = () => {
const trimmed = years.trim();
const yearsNum = trimmed === '' ? 0 : Number(trimmed);
@@ -54,13 +101,16 @@ const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({
setYearsError(yearsInvalid);
if (yearsInvalid) return;
const resolvedLevel = educationLevel === OTHER_CODE ? educationLevelOther.trim() : educationLevel;
const resolvedField = educationField === OTHER_CODE ? educationFieldOther.trim() : educationField;
upsert.mutate(
{
bio: bio.trim(),
yearsOfExperience: yearsNum,
educationLevel: initial?.educationLevel ?? '',
educationField: initial?.educationField ?? '',
specializationsJson: initial?.specializationsJson ?? '[]',
educationLevel: resolvedLevel,
educationField: resolvedField,
specializationsJson: JSON.stringify(specializations),
avatarUrl,
},
{
@@ -163,10 +213,81 @@ const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({
sx={{ maxWidth: 200 }}
/>
<Stack direction={{ xs: 'column', sm: 'row' }} sx={{ gap: 2 }}>
<TextField select label={t('education_level_label')} value={educationLevel} onChange={(e) => setEducationLevel(e.target.value)} fullWidth>
{EDUCATION_LEVELS.map((code) => (
<MenuItem key={code} value={code}>
{t(`education_level_${code}`)}
</MenuItem>
))}
<MenuItem value={OTHER_CODE}>{t('education_other')}</MenuItem>
</TextField>
<TextField select label={t('education_field_label')} value={educationField} onChange={(e) => setEducationField(e.target.value)} fullWidth>
{EDUCATION_FIELDS.map((code) => (
<MenuItem key={code} value={code}>
{t(`education_field_${code}`)}
</MenuItem>
))}
<MenuItem value={OTHER_CODE}>{t('education_other')}</MenuItem>
</TextField>
</Stack>
{educationLevel === OTHER_CODE ? (
<TextField
label={t('education_level_other_label')}
value={educationLevelOther}
onChange={(e) => setEducationLevelOther(e.target.value)}
fullWidth
/>
) : null}
{educationField === OTHER_CODE ? (
<TextField
label={t('education_field_other_label')}
value={educationFieldOther}
onChange={(e) => setEducationFieldOther(e.target.value)}
fullWidth
/>
) : null}
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('specializations_label')}
</Typography>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
{SPECIALTY_PRESETS.map((code) => {
const selected = specializations.includes(code);
return (
<Chip
key={code}
label={tv.has(`specialty_${code}`) ? tv(`specialty_${code}`) : code}
onClick={() => toggleSpecialty(code)}
variant={selected ? 'filled' : 'outlined'}
sx={{
fontWeight: 500,
backgroundColor: selected ? 'var(--bal-primary)' : 'transparent',
color: selected ? 'var(--bal-primary-contrast)' : 'var(--bal-primary)',
borderColor: 'var(--bal-primary)',
}}
/>
);
})}
</Stack>
</Stack>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('deferred_services')}
</Typography>
<AppButton
variant="text"
color="primary"
startIcon="account"
to={`/${locale}${ROUTES.NURSE_PROFILE_PREVIEW}`}
sx={{ alignSelf: 'flex-start' }}
>
{t('preview_cta')}
</AppButton>
<AppButton
color="primary"
variant="contained"