manual improvement 2 & add telegram bot
This commit is contained in:
@@ -1,20 +1,45 @@
|
||||
'use client';
|
||||
import { ChangeEvent, FunctionComponent, useEffect, useRef, useState } from 'react';
|
||||
import { ChangeEvent, FunctionComponent, useEffect, useRef } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useForm, FormProvider, useWatch } from 'react-hook-form';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import { Avatar, Box, Chip, MenuItem, Paper, Stack, TextField, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, AppLoading, TrustBadge } from '@/components';
|
||||
import { Avatar, Box, MenuItem, Stack, Typography } from '@mui/material';
|
||||
import {
|
||||
AccentCard,
|
||||
AppButton,
|
||||
AppIcon,
|
||||
AppLoading,
|
||||
FormSection,
|
||||
PageHeader,
|
||||
RhfChipSelect,
|
||||
RhfTextField,
|
||||
TrustBadge,
|
||||
} from '@/components';
|
||||
import { CONTENT_MAX_WIDTH } from '@/components/config';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { digitsOnly } from '@/utils';
|
||||
import { useNurseProfile, useUpsertNurseProfile, useUploadAvatar } from '@/services/profiles';
|
||||
import type { NurseProfile } from '@/services/profiles/types';
|
||||
import { useVerificationStatus } from '@/services/verification';
|
||||
import { ownBadgeState, SPECIALTY_PRESETS } from '@/services/verification/types';
|
||||
|
||||
const MAX_YEARS = 80;
|
||||
const MAX_YEARS_DIGITS = 2;
|
||||
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;
|
||||
|
||||
interface ProfileFormValues {
|
||||
avatarUrl: string | null;
|
||||
bio: string;
|
||||
years: string;
|
||||
educationLevel: string;
|
||||
educationLevelOther: string;
|
||||
educationField: string;
|
||||
educationFieldOther: string;
|
||||
specializations: string[];
|
||||
}
|
||||
|
||||
function parseSpecializations(json: string): string[] {
|
||||
try {
|
||||
const parsed = JSON.parse(json);
|
||||
@@ -24,13 +49,36 @@ function parseSpecializations(json: string): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
/** Nurse profile bootstrap (B7 header): avatar + bio + years + qualifications. */
|
||||
/**
|
||||
* Splits a stored free-text value into the (select code, "other" free-text) pair the form edits: a
|
||||
* value the preset list knows becomes the code, anything else becomes «سایر» + the raw text.
|
||||
*/
|
||||
function splitPreset(stored: string, presets: readonly string[]): { code: string; other: string } {
|
||||
if (presets.includes(stored)) return { code: stored, other: '' };
|
||||
return stored ? { code: OTHER_CODE, other: stored } : { code: '', other: '' };
|
||||
}
|
||||
|
||||
/** Nurse profile bootstrap (B7 header): avatar + bio + experience + qualifications. */
|
||||
export default function NurseProfilePage() {
|
||||
const { data: profile, isLoading } = useNurseProfile();
|
||||
if (isLoading) return <AppLoading />;
|
||||
return <NurseProfileForm initial={profile ?? null} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* The nurse's public-facing profile, rebuilt around three named questions instead of one undivided
|
||||
* column of inputs.
|
||||
*
|
||||
* What it replaced: a flat stack of avatar → bio → years → two selects → two conditional "other"
|
||||
* fields → a chip row → a save button, with no headings and no statement of what any of it was for.
|
||||
* The nurse could not tell which fields families actually see, which were required, or how much was
|
||||
* left — and every keystroke re-rendered the trust badge, the verification banner and the uploader
|
||||
* along with the field being typed into, because each input owned a `useState` at page level.
|
||||
*
|
||||
* Now: `FormSection` groups the fields into معرفی / تجربه و تحصیلات / تخصصها, each saying who the
|
||||
* answer is for; react-hook-form owns the values so a keystroke re-renders one field; and validation
|
||||
* lives on the field it governs rather than in a hand-rolled block at the top of the submit handler.
|
||||
*/
|
||||
const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({ initial }) => {
|
||||
const t = useTranslations('nurseProfile');
|
||||
const tv = useTranslations('verification');
|
||||
@@ -43,28 +91,29 @@ const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({
|
||||
const badgeState = ownBadgeState(verificationStatus);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [avatarUrl, setAvatarUrl] = useState<string | null>(initial?.avatarUrl ?? null);
|
||||
const [bio, setBio] = useState(initial?.bio ?? '');
|
||||
const [years, setYears] = useState(initial ? String(initial.yearsOfExperience) : '');
|
||||
const [yearsError, setYearsError] = useState(false);
|
||||
const level = splitPreset(initial?.educationLevel ?? '', EDUCATION_LEVELS);
|
||||
const field = splitPreset(initial?.educationField ?? '', EDUCATION_FIELDS);
|
||||
|
||||
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 ?? '[]'),
|
||||
);
|
||||
const form = useForm<ProfileFormValues>({
|
||||
mode: 'onTouched',
|
||||
defaultValues: {
|
||||
avatarUrl: initial?.avatarUrl ?? null,
|
||||
bio: initial?.bio ?? '',
|
||||
years: initial ? String(initial.yearsOfExperience) : '',
|
||||
educationLevel: level.code,
|
||||
educationLevelOther: level.other,
|
||||
educationField: field.code,
|
||||
educationFieldOther: field.other,
|
||||
specializations: parseSpecializations(initial?.specializationsJson ?? '[]'),
|
||||
},
|
||||
});
|
||||
const { control, handleSubmit, setValue, getValues } = form;
|
||||
|
||||
// Only these three drive conditional rendering, so they are the only fields worth subscribing the
|
||||
// page to — the rest re-render nothing but themselves.
|
||||
const avatarUrl = useWatch({ control, name: 'avatarUrl' });
|
||||
const educationLevel = useWatch({ control, name: 'educationLevel' });
|
||||
const educationField = useWatch({ control, name: 'educationField' });
|
||||
|
||||
// A staged-but-unsaved avatar must never be silently discarded — warn on reload/tab-close.
|
||||
const avatarDirty = avatarUrl !== (initial?.avatarUrl ?? null);
|
||||
@@ -79,224 +128,199 @@ const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({
|
||||
return () => window.removeEventListener('beforeunload', handler);
|
||||
}, [avatarDirty]);
|
||||
|
||||
const pickFile = () => fileInputRef.current?.click();
|
||||
|
||||
const onFileSelected = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = '';
|
||||
if (!file) return;
|
||||
uploadAvatar.mutate(file, {
|
||||
onSuccess: (result) => setAvatarUrl(result.url),
|
||||
onSuccess: (result) => setValue('avatarUrl', result.url, { shouldDirty: true }),
|
||||
onError: () => enqueueSnackbar(t('avatar_upload_error'), { variant: 'error' }),
|
||||
});
|
||||
};
|
||||
|
||||
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);
|
||||
const yearsInvalid = !Number.isInteger(yearsNum) || yearsNum < 0 || yearsNum > MAX_YEARS;
|
||||
setYearsError(yearsInvalid);
|
||||
if (yearsInvalid) return;
|
||||
|
||||
const resolvedLevel = educationLevel === OTHER_CODE ? educationLevelOther.trim() : educationLevel;
|
||||
const resolvedField = educationField === OTHER_CODE ? educationFieldOther.trim() : educationField;
|
||||
const save = (values: ProfileFormValues) => {
|
||||
const resolvedLevel =
|
||||
values.educationLevel === OTHER_CODE ? values.educationLevelOther.trim() : values.educationLevel;
|
||||
const resolvedField =
|
||||
values.educationField === OTHER_CODE ? values.educationFieldOther.trim() : values.educationField;
|
||||
|
||||
upsert.mutate(
|
||||
{
|
||||
bio: bio.trim(),
|
||||
yearsOfExperience: yearsNum,
|
||||
bio: values.bio.trim(),
|
||||
yearsOfExperience: values.years.trim() === '' ? 0 : Number(values.years),
|
||||
educationLevel: resolvedLevel,
|
||||
educationField: resolvedField,
|
||||
specializationsJson: JSON.stringify(specializations),
|
||||
avatarUrl,
|
||||
specializationsJson: JSON.stringify(values.specializations),
|
||||
avatarUrl: values.avatarUrl,
|
||||
},
|
||||
{
|
||||
onSuccess: () => enqueueSnackbar(t('saved'), { variant: 'success' }),
|
||||
onSuccess: () => {
|
||||
enqueueSnackbar(t('saved'), { variant: 'success' });
|
||||
// Re-baseline so the beforeunload guard and `isDirty` stop reporting saved work as unsaved.
|
||||
form.reset(getValues());
|
||||
},
|
||||
onError: () => enqueueSnackbar(t('save_error'), { variant: 'error' }),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 560 }}>
|
||||
<Box>
|
||||
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<Typography variant="h5" component="h1">
|
||||
{t('title')}
|
||||
</Typography>
|
||||
{/* The public trust signal on the nurse's own profile — the same badge f6 reuses in search. */}
|
||||
<TrustBadge state={badgeState} />
|
||||
</Stack>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<FormProvider {...form}>
|
||||
<Box
|
||||
component="form"
|
||||
noValidate
|
||||
onSubmit={handleSubmit(save)}
|
||||
sx={{ display: 'flex', flexDirection: 'column', gap: 2.5, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}
|
||||
>
|
||||
<PageHeader title={t('title')} subtitle={t('subtitle')} meta={<TrustBadge state={badgeState} />} />
|
||||
|
||||
{/* Blocked-until-verified banner — shown until the aggregate is approved (incl. the expired state). */}
|
||||
{badgeState !== 'verified' ? (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{ p: 2, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider', borderInlineStartWidth: 4, borderInlineStartColor: 'var(--bal-warning)' }}
|
||||
>
|
||||
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'flex-start' }}>
|
||||
<AppIcon icon="warning" size={24} color="var(--bal-warning)" />
|
||||
<Stack sx={{ gap: 1, flexGrow: 1 }}>
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{/* Blocked-until-verified nudge — shown until the aggregate is approved (incl. the expired state). */}
|
||||
{badgeState !== 'verified' ? (
|
||||
<AccentCard tone="warning" padding="sm">
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
|
||||
<AppIcon icon="warning" size={20} color="var(--bal-warning)" />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{t('unverified_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('unverified_body')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('unverified_body')}
|
||||
</Typography>
|
||||
<AppButton
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
variant="text"
|
||||
endIcon="forward"
|
||||
to={`/${locale}${ROUTES.NURSE_VERIFICATION}`}
|
||||
sx={{ alignSelf: 'flex-start' }}
|
||||
>
|
||||
{t('unverified_cta')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</AccentCard>
|
||||
) : null}
|
||||
|
||||
<FormSection title={t('section_intro_title')} description={t('section_intro_description')} icon="account">
|
||||
<Stack direction="row" sx={{ gap: 2, alignItems: 'center' }}>
|
||||
<Avatar src={avatarUrl ?? undefined} sx={{ width: 64, height: 64, bgcolor: 'var(--bal-primary-soft)' }}>
|
||||
{avatarUrl ? null : <AppIcon icon="account" size={32} color="var(--bal-primary)" />}
|
||||
</Avatar>
|
||||
<Stack sx={{ gap: 0.5, minWidth: 0 }}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('photo_hint')}
|
||||
</Typography>
|
||||
<AppButton
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
startIcon="camera"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploadAvatar.isPending}
|
||||
sx={{ alignSelf: 'flex-start' }}
|
||||
>
|
||||
{uploadAvatar.isPending ? t('uploading') : t('upload')}
|
||||
</AppButton>
|
||||
<input ref={fileInputRef} type="file" accept="image/*" hidden onChange={onFileSelected} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
<Stack direction="row" sx={{ gap: 2, alignItems: 'center' }}>
|
||||
<Avatar src={avatarUrl ?? undefined} sx={{ width: 72, height: 72, bgcolor: 'var(--bal-primary-soft)' }}>
|
||||
{avatarUrl ? null : <AppIcon icon="account" size={36} color="var(--bal-primary)" />}
|
||||
</Avatar>
|
||||
<Stack sx={{ gap: 0.5 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{t('photo')}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('photo_hint')}
|
||||
</Typography>
|
||||
<AppButton
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
startIcon="camera"
|
||||
onClick={pickFile}
|
||||
disabled={uploadAvatar.isPending}
|
||||
sx={{ mt: 0.5, alignSelf: 'flex-start' }}
|
||||
>
|
||||
{uploadAvatar.isPending ? t('uploading') : t('upload')}
|
||||
<RhfTextField<ProfileFormValues>
|
||||
name="bio"
|
||||
label={t('bio')}
|
||||
helperText={t('bio_hint')}
|
||||
multiline
|
||||
minRows={3}
|
||||
fullWidth
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
<FormSection
|
||||
title={t('section_experience_title')}
|
||||
description={t('section_experience_description')}
|
||||
icon="license"
|
||||
>
|
||||
<RhfTextField<ProfileFormValues>
|
||||
name="years"
|
||||
label={t('years')}
|
||||
transform={(raw) => digitsOnly(raw).slice(0, MAX_YEARS_DIGITS)}
|
||||
rules={{
|
||||
validate: (value) => {
|
||||
const trimmed = String(value ?? '').trim();
|
||||
if (trimmed === '') return true;
|
||||
const parsed = Number(trimmed);
|
||||
return (Number.isInteger(parsed) && parsed >= 0 && parsed <= MAX_YEARS) || t('years_invalid');
|
||||
},
|
||||
}}
|
||||
slotProps={{ htmlInput: { dir: 'ltr', inputMode: 'numeric', style: { textAlign: 'start' } } }}
|
||||
sx={{ maxWidth: 200 }}
|
||||
/>
|
||||
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} sx={{ gap: 2 }}>
|
||||
<RhfTextField<ProfileFormValues> name="educationLevel" select label={t('education_level_label')} fullWidth>
|
||||
{EDUCATION_LEVELS.map((code) => (
|
||||
<MenuItem key={code} value={code}>
|
||||
{t(`education_level_${code}`)}
|
||||
</MenuItem>
|
||||
))}
|
||||
<MenuItem value={OTHER_CODE}>{t('education_other')}</MenuItem>
|
||||
</RhfTextField>
|
||||
<RhfTextField<ProfileFormValues> name="educationField" select label={t('education_field_label')} fullWidth>
|
||||
{EDUCATION_FIELDS.map((code) => (
|
||||
<MenuItem key={code} value={code}>
|
||||
{t(`education_field_${code}`)}
|
||||
</MenuItem>
|
||||
))}
|
||||
<MenuItem value={OTHER_CODE}>{t('education_other')}</MenuItem>
|
||||
</RhfTextField>
|
||||
</Stack>
|
||||
|
||||
{educationLevel === OTHER_CODE ? (
|
||||
<RhfTextField<ProfileFormValues>
|
||||
name="educationLevelOther"
|
||||
label={t('education_level_other_label')}
|
||||
rules={{ required: t('education_other_required') }}
|
||||
fullWidth
|
||||
/>
|
||||
) : null}
|
||||
{educationField === OTHER_CODE ? (
|
||||
<RhfTextField<ProfileFormValues>
|
||||
name="educationFieldOther"
|
||||
label={t('education_field_other_label')}
|
||||
rules={{ required: t('education_other_required') }}
|
||||
fullWidth
|
||||
/>
|
||||
) : null}
|
||||
</FormSection>
|
||||
|
||||
<FormSection
|
||||
title={t('specializations_label')}
|
||||
description={t('section_specializations_description')}
|
||||
icon="clinical"
|
||||
optional
|
||||
optionalLabel={tc('optional')}
|
||||
>
|
||||
<RhfChipSelect<ProfileFormValues>
|
||||
name="specializations"
|
||||
options={SPECIALTY_PRESETS.map((code) => ({
|
||||
code,
|
||||
label: tv.has(`specialty_${code}`) ? tv(`specialty_${code}`) : code,
|
||||
}))}
|
||||
allowCustomValues
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<AppButton type="submit" color="primary" variant="contained" disabled={upsert.isPending}>
|
||||
{upsert.isPending ? tc('saving') : t('save')}
|
||||
</AppButton>
|
||||
<AppButton variant="text" color="primary" startIcon="account" to={`/${locale}${ROUTES.NURSE_PROFILE_PREVIEW}`}>
|
||||
{t('preview_cta')}
|
||||
</AppButton>
|
||||
<input ref={fileInputRef} type="file" accept="image/*" hidden onChange={onFileSelected} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<TextField
|
||||
label={t('bio')}
|
||||
value={bio}
|
||||
onChange={(e) => setBio(e.target.value)}
|
||||
helperText={t('bio_hint')}
|
||||
multiline
|
||||
minRows={3}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label={t('years')}
|
||||
value={years}
|
||||
onChange={(e) => {
|
||||
setYears(e.target.value.replace(/\D/g, '').slice(0, 2));
|
||||
if (yearsError) setYearsError(false);
|
||||
}}
|
||||
error={yearsError}
|
||||
helperText={yearsError ? t('years_invalid') : undefined}
|
||||
slotProps={{ htmlInput: { dir: 'ltr', inputMode: 'numeric', style: { textAlign: 'start' } } }}
|
||||
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 variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('deferred_services')}
|
||||
</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"
|
||||
onClick={handleSave}
|
||||
disabled={upsert.isPending}
|
||||
sx={{ alignSelf: 'flex-start' }}
|
||||
>
|
||||
{upsert.isPending ? tc('saving') : t('save')}
|
||||
</AppButton>
|
||||
</Box>
|
||||
</Box>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user