ui phase 8
This commit is contained in:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user