frontend phase 5 & backend phase 12
This commit is contained in:
@@ -3,10 +3,12 @@ import { ChangeEvent, FunctionComponent, 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 { AppButton, AppIcon, AppLoading } from '@/components';
|
||||
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';
|
||||
|
||||
const MAX_YEARS = 80;
|
||||
|
||||
@@ -24,6 +26,8 @@ const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const upsert = useUpsertNurseProfile();
|
||||
const uploadAvatar = useUploadAvatar();
|
||||
const { data: verificationStatus } = useVerificationStatus();
|
||||
const badgeState = ownBadgeState(verificationStatus);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [avatarUrl, setAvatarUrl] = useState<string | null>(initial?.avatarUrl ?? null);
|
||||
@@ -63,41 +67,47 @@ const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 560 }}>
|
||||
<Box>
|
||||
<Typography variant="h5" component="h1">
|
||||
{t('title')}
|
||||
</Typography>
|
||||
<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>
|
||||
|
||||
{/* Not bookable until verification (f5) — a neutral placeholder, not the real banner. */}
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{ p: 2, borderRadius: 2, 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 }}>
|
||||
{t('unverified_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('unverified_body')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<AppButton
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
to={`/${locale}${ROUTES.NURSE_VERIFICATION}`}
|
||||
sx={{ m: 0, alignSelf: 'flex-start' }}
|
||||
>
|
||||
{t('unverified_cta')}
|
||||
</AppButton>
|
||||
{/* Blocked-until-verified banner — shown until the aggregate is approved (incl. the expired state). */}
|
||||
{badgeState !== 'verified' ? (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{ p: 2, borderRadius: 2, 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 }}>
|
||||
{t('unverified_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('unverified_body')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<AppButton
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
to={`/${locale}${ROUTES.NURSE_VERIFICATION}`}
|
||||
sx={{ m: 0, alignSelf: 'flex-start' }}
|
||||
>
|
||||
{t('unverified_cta')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
<Stack direction="row" sx={{ gap: 2, alignItems: 'center' }}>
|
||||
<Avatar src={avatarUrl ?? undefined} sx={{ width: 72, height: 72, bgcolor: 'var(--bal-primary-soft)' }}>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { AppButton, AppIcon, VariantCard } from '@/components';
|
||||
import { useMyVariants, useSetVariantActive } from '@/services/catalog';
|
||||
import type { NurseServiceVariant } from '@/services/catalog/types';
|
||||
import PublishGate from './PublishGate';
|
||||
|
||||
interface MyServicesListProps {
|
||||
onAdd: () => void;
|
||||
@@ -86,6 +87,8 @@ const MyServicesList: FunctionComponent<MyServicesListProps> = ({ onAdd, onEdit
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
<PublishGate />
|
||||
|
||||
{isLoading ? (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
{[0, 1].map((key) => (
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import { Paper, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon } from '@/components';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { useVerificationStatus } from '@/services/verification';
|
||||
import { isApproved } from '@/services/verification/types';
|
||||
|
||||
/**
|
||||
* The go-live gate for the nurse's services (the f4 publish stub, wired to verification here). A nurse
|
||||
* is **not bookable and cannot publish until verified**: when the aggregate isn't `approved` the publish
|
||||
* CTA is **disabled** with a blocked-until-verified explanation that links to the B3 checklist; once
|
||||
* approved it enables. Mirrors the server's guarded `is_verified` flip — the UI never implies a nurse is
|
||||
* live before verification completes. Reads the shared `VerificationStatus` query (cached across the app).
|
||||
*/
|
||||
const PublishGate: FunctionComponent = () => {
|
||||
const t = useTranslations('verification');
|
||||
const locale = useLocale();
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const { data: status, isLoading } = useVerificationStatus();
|
||||
|
||||
if (isLoading) return null;
|
||||
const approved = isApproved(status);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderInlineStartWidth: 4,
|
||||
borderInlineStartColor: approved ? 'var(--bal-success)' : 'var(--bal-warning)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'flex-start' }}>
|
||||
<AppIcon
|
||||
icon={approved ? 'verified' : 'warning'}
|
||||
size={24}
|
||||
color={approved ? 'var(--bal-success)' : 'var(--bal-warning)'}
|
||||
/>
|
||||
<Stack sx={{ gap: 0.5, flexGrow: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{approved ? t('publish_ready_title') : t('publish_blocked_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{approved ? t('publish_ready_body') : t('publish_blocked_body')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
|
||||
<AppButton
|
||||
color="primary"
|
||||
variant="contained"
|
||||
startIcon="publish"
|
||||
disabled={!approved}
|
||||
onClick={() => enqueueSnackbar(t('publish_done'), { variant: 'success' })}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{t('publish_cta')}
|
||||
</AppButton>
|
||||
{!approved ? (
|
||||
<AppButton
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
to={`/${locale}${ROUTES.NURSE_VERIFICATION}`}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{t('publish_complete_verification')}
|
||||
</AppButton>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default PublishGate;
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Box, LinearProgress, Paper, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, StatusChip } from '@/components';
|
||||
import type { VerificationStatus, VerificationStep } from '@/services/verification/types';
|
||||
import {
|
||||
displaySteps,
|
||||
progressCounts,
|
||||
routeForStep,
|
||||
stepDescriptionKey,
|
||||
stepLabelKey,
|
||||
stepStatusChip,
|
||||
} from './verificationSteps';
|
||||
|
||||
interface VerificationChecklistProps {
|
||||
status: VerificationStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* The B3 checklist body: the "X از Y" progress meter + the data-driven, ordered step rows (reusing the
|
||||
* shared `StatusChip`). Rows are rendered from `displaySteps(status)` — a new step type appears without a
|
||||
* code change. Failed/expired rows surface their reason and a re-submit path; the first actionable
|
||||
* automated/manual step gets an inline "go" link to its screen.
|
||||
*/
|
||||
const VerificationChecklist: FunctionComponent<VerificationChecklistProps> = ({ status }) => {
|
||||
const { passed, total } = progressCounts(status);
|
||||
const steps = displaySteps(status);
|
||||
const firstActionableId = steps.find(
|
||||
(step) => step.status !== 'passed' && step.status !== 'in_review' && routeForStep(step.code) !== null,
|
||||
)?.id;
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<ProgressMeter passed={passed} total={total} />
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
{steps.map((step) => (
|
||||
<StepRow key={step.code} step={step} highlighted={step.id === firstActionableId} />
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const ProgressMeter: FunctionComponent<{ passed: number; total: number }> = ({ passed, total }) => {
|
||||
const t = useTranslations('verification');
|
||||
const locale = useLocale();
|
||||
const percent = total === 0 ? 0 : (passed / total) * 100;
|
||||
const format = (value: number) => new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US').format(value);
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'baseline', mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{t('progress_title')}
|
||||
</Typography>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: 'var(--bal-primary)' }}>
|
||||
{t('progress_count', { passed: format(passed), total: format(total) })}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<LinearProgress variant="determinate" value={percent} sx={{ height: 8, borderRadius: 1 }} />
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
const StepRow: FunctionComponent<{ step: VerificationStep; highlighted: boolean }> = ({ step, highlighted }) => {
|
||||
const t = useTranslations('verification');
|
||||
const locale = useLocale();
|
||||
const chip = stepStatusChip(step.status);
|
||||
const labelKey = stepLabelKey(step.code);
|
||||
const descKey = stepDescriptionKey(step.code);
|
||||
const label = t.has(labelKey) ? t(labelKey) : step.displayName;
|
||||
const description = t.has(descKey) ? t(descKey) : '';
|
||||
const route = routeForStep(step.code);
|
||||
const showReason = step.status === 'failed' || step.status === 'expired';
|
||||
const reason = step.failureReason
|
||||
? t.has(`reason_${step.failureReason}`)
|
||||
? t(`reason_${step.failureReason}`)
|
||||
: step.failureReason
|
||||
: null;
|
||||
// Only the genuinely automated checks may advertise "استعلام خودکار" — the honesty constraint.
|
||||
const showAutoNote = step.isAutomated && step.status === 'not_started';
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: highlighted ? 'var(--bal-primary)' : 'divider',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
|
||||
<AppIcon icon={step.isAutomated ? 'verified' : 'document'} size={22} color="var(--bal-text-secondary)" />
|
||||
<Stack sx={{ gap: 0.25, flexGrow: 1, minWidth: 0 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
{description ? (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{description}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Stack>
|
||||
<StatusChip status={chip.kind} label={t(chip.labelKey)} sx={{ flexShrink: 0 }} />
|
||||
</Stack>
|
||||
|
||||
{showReason && reason ? (
|
||||
<Typography variant="body2" sx={{ color: 'var(--bal-error)' }}>
|
||||
{reason}
|
||||
</Typography>
|
||||
) : null}
|
||||
|
||||
{showAutoNote ? (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('auto_query_note')}
|
||||
</Typography>
|
||||
) : null}
|
||||
|
||||
{route && (highlighted || step.status === 'failed' || step.status === 'expired') ? (
|
||||
<AppButton
|
||||
variant="text"
|
||||
color="primary"
|
||||
endIcon="edit"
|
||||
to={`/${locale}${route}`}
|
||||
sx={{ m: 0, alignSelf: 'flex-start' }}
|
||||
>
|
||||
{step.status === 'failed' || step.status === 'expired' ? t('row_fix') : t('row_go')}
|
||||
</AppButton>
|
||||
) : null}
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default VerificationChecklist;
|
||||
@@ -0,0 +1,266 @@
|
||||
'use client';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import { Box, Chip, Paper, Stack, TextField, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, AppLoading, DocumentUpload, StepperHeader } from '@/components';
|
||||
import type { UploadedDocInfo } from '@/components';
|
||||
import { ROUTES } from '@/constants';
|
||||
import {
|
||||
useSubmitCredentials,
|
||||
useUploadVerificationDocument,
|
||||
useVerificationStatus,
|
||||
} from '@/services/verification';
|
||||
import { SPECIALTY_PRESETS } from '@/services/verification/types';
|
||||
import type { VerificationStep } from '@/services/verification/types';
|
||||
import { stepDescriptionKey, stepLabelKey } from '../verificationSteps';
|
||||
|
||||
const MANUAL_CREDENTIAL_CODES = ['moh_competency_license', 'ino_membership', 'criminal_record'];
|
||||
|
||||
/**
|
||||
* B5 — professional credentials. Renders a `DocumentUpload` for **each manual credential step in the
|
||||
* status** (data-driven — a new manual step renders without a code change); each upload moves its step to
|
||||
* `in_review` (manual admin review — copy never claims an automated authority check). Collects the INO
|
||||
* number + specialty chips + optional registry fields, persisted on submit. Lands on B6 (under review).
|
||||
*/
|
||||
export default function CredentialsSubmitPage() {
|
||||
const t = useTranslations('verification');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
|
||||
const { data: status, isLoading } = useVerificationStatus();
|
||||
const uploadDocument = useUploadVerificationDocument();
|
||||
const submitCredentials = useSubmitCredentials();
|
||||
|
||||
const [inoNumber, setInoNumber] = useState('');
|
||||
const [inoError, setInoError] = useState(false);
|
||||
const [specialties, setSpecialties] = useState<string[]>([]);
|
||||
const [customSpecialty, setCustomSpecialty] = useState('');
|
||||
const [issuingAuthority, setIssuingAuthority] = useState('');
|
||||
const [issuedAt, setIssuedAt] = useState('');
|
||||
const [expiresAt, setExpiresAt] = useState('');
|
||||
const [uploadedSteps, setUploadedSteps] = useState<Record<number, boolean>>({});
|
||||
|
||||
const manualSteps = useMemo(
|
||||
() => (status?.steps ?? []).filter((step) => MANUAL_CREDENTIAL_CODES.includes(step.code)),
|
||||
[status],
|
||||
);
|
||||
|
||||
const toggleSpecialty = (value: string) =>
|
||||
setSpecialties((prev) => (prev.includes(value) ? prev.filter((item) => item !== value) : [...prev, value]));
|
||||
|
||||
const addCustomSpecialty = () => {
|
||||
const value = customSpecialty.trim();
|
||||
if (value && !specialties.includes(value)) setSpecialties((prev) => [...prev, value]);
|
||||
setCustomSpecialty('');
|
||||
};
|
||||
|
||||
const uploadToStep = (step: VerificationStep) => async (file: File, onProgress: (percent: number) => void) => {
|
||||
const doc = await uploadDocument.mutateAsync({ stepId: step.id, file, onProgress });
|
||||
return { name: doc.originalFileName ?? file.name, sizeBytes: doc.fileSizeBytes } satisfies UploadedDocInfo;
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
const inoValid = inoNumber.trim().length > 0;
|
||||
setInoError(!inoValid);
|
||||
if (!inoValid) return;
|
||||
|
||||
submitCredentials.mutate(
|
||||
{
|
||||
inoNumber: inoNumber.trim(),
|
||||
specialties,
|
||||
issuingAuthority: issuingAuthority.trim() || undefined,
|
||||
issuedAt: issuedAt || undefined,
|
||||
expiresAt: expiresAt || undefined,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
enqueueSnackbar(t('credentials_submitted'), { variant: 'success' });
|
||||
router.push(`/${locale}${ROUTES.NURSE_VERIFICATION_REVIEW}`);
|
||||
},
|
||||
onError: () => enqueueSnackbar(t('credentials_error'), { variant: 'error' }),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoading) return <AppLoading />;
|
||||
|
||||
if (!status || manualSteps.length === 0) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, maxWidth: 560 }}>
|
||||
<Typography variant="h5" component="h1">
|
||||
{t('credentials_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('credentials_needs_start')}
|
||||
</Typography>
|
||||
<AppButton color="primary" variant="contained" to={`/${locale}${ROUTES.NURSE_VERIFICATION}`} sx={{ m: 0, alignSelf: 'flex-start' }}>
|
||||
{t('back_to_checklist')}
|
||||
</AppButton>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const anyUploaded = Object.values(uploadedSteps).some(Boolean);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 560 }}>
|
||||
<Box>
|
||||
<Typography variant="h5" component="h1">
|
||||
{t('credentials_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('credentials_subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ overflowX: 'auto' }}>
|
||||
<StepperHeader steps={[t('journey_identity'), t('journey_credentials'), t('journey_review')]} activeStep={1} />
|
||||
</Box>
|
||||
|
||||
<TextField
|
||||
label={t('ino_number_label')}
|
||||
value={inoNumber}
|
||||
onChange={(event) => {
|
||||
setInoNumber(event.target.value);
|
||||
if (inoError) setInoError(false);
|
||||
}}
|
||||
error={inoError}
|
||||
helperText={inoError ? t('ino_number_required') : t('ino_number_hint')}
|
||||
slotProps={{ htmlInput: { dir: 'ltr', style: { textAlign: 'start' } } }}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
{/* One uploader per manual credential step — data-driven from the status. */}
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
{manualSteps.map((step) => (
|
||||
<DocumentUpload
|
||||
key={step.code}
|
||||
label={t.has(stepLabelKey(step.code)) ? t(stepLabelKey(step.code)) : step.displayName}
|
||||
hint={t.has(stepDescriptionKey(step.code)) ? t(stepDescriptionKey(step.code)) : undefined}
|
||||
onUpload={uploadToStep(step)}
|
||||
onUploaded={() => setUploadedSteps((prev) => ({ ...prev, [step.id]: true }))}
|
||||
rejected={step.status === 'failed'}
|
||||
rejectionReason={step.failureReason ?? undefined}
|
||||
existingDoc={step.status === 'in_review' ? { name: t('doc_uploaded') } : null}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
{/* Supplementary education certificate — a local attachment (no dedicated step). */}
|
||||
<DocumentUpload label={t('education_label')} hint={t('education_hint')} onUpload={async (file) => ({ name: file.name })} />
|
||||
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{t('specialties_label')}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('specialties_hint')}
|
||||
</Typography>
|
||||
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
|
||||
{SPECIALTY_PRESETS.map((preset) => {
|
||||
const selected = specialties.includes(preset);
|
||||
return (
|
||||
<Chip
|
||||
key={preset}
|
||||
label={t.has(`specialty_${preset}`) ? t(`specialty_${preset}`) : preset}
|
||||
onClick={() => toggleSpecialty(preset)}
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
backgroundColor: selected ? 'var(--bal-primary)' : 'var(--bal-primary-soft)',
|
||||
color: selected ? 'var(--bal-primary-contrast)' : 'var(--bal-primary)',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{specialties
|
||||
.filter((value) => !SPECIALTY_PRESETS.includes(value))
|
||||
.map((value) => (
|
||||
<Chip
|
||||
key={value}
|
||||
label={value}
|
||||
onDelete={() => toggleSpecialty(value)}
|
||||
sx={{ fontWeight: 600, backgroundColor: 'var(--bal-primary)', color: 'var(--bal-primary-contrast)' }}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'flex-start' }}>
|
||||
<TextField
|
||||
size="small"
|
||||
placeholder={t('specialty_add_placeholder')}
|
||||
value={customSpecialty}
|
||||
onChange={(event) => setCustomSpecialty(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
addCustomSpecialty();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<AppButton variant="outlined" color="primary" startIcon="add" onClick={addCustomSpecialty} sx={{ m: 0 }}>
|
||||
{t('specialty_add')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{/* Optional registry details the admin cross-checks — issue/expiry are stored UTC (Shamsi shown elsewhere). */}
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{t('registry_details_label')}
|
||||
</Typography>
|
||||
<TextField
|
||||
label={t('issuing_authority_label')}
|
||||
value={issuingAuthority}
|
||||
onChange={(event) => setIssuingAuthority(event.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
<Stack direction="row" sx={{ gap: 1.5, flexWrap: 'wrap' }}>
|
||||
<TextField
|
||||
label={t('issued_at_label')}
|
||||
type="date"
|
||||
value={issuedAt}
|
||||
onChange={(event) => setIssuedAt(event.target.value)}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
sx={{ flex: 1, minWidth: 160 }}
|
||||
/>
|
||||
<TextField
|
||||
label={t('expires_at_label')}
|
||||
type="date"
|
||||
value={expiresAt}
|
||||
onChange={(event) => setExpiresAt(event.target.value)}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
sx={{ flex: 1, minWidth: 160 }}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{ p: 1.5, borderRadius: 2, bgcolor: 'var(--bal-primary-soft)', display: 'flex', gap: 1, alignItems: 'center' }}
|
||||
>
|
||||
<AppIcon icon="info" size={18} color="var(--bal-primary)" />
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('manual_review_note')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
|
||||
<Stack direction="row" sx={{ gap: 1 }}>
|
||||
<AppButton
|
||||
color="primary"
|
||||
variant="contained"
|
||||
startIcon="license"
|
||||
onClick={handleSubmit}
|
||||
disabled={submitCredentials.isPending || !anyUploaded}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{submitCredentials.isPending ? t('credentials_submitting') : t('credentials_submit')}
|
||||
</AppButton>
|
||||
<AppButton variant="text" color="primary" to={`/${locale}${ROUTES.NURSE_VERIFICATION}`} sx={{ m: 0 }}>
|
||||
{t('back_to_checklist')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import { Box, Paper, Stack, TextField, Typography } from '@mui/material';
|
||||
import { AppAlert, AppButton, AppIcon, DocumentUpload, StepperHeader } from '@/components';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { toEnglishDigits } from '@/utils';
|
||||
import { useSubmitIdentity } from '@/services/verification';
|
||||
import { isValidNationalId } from '@/services/verification/validation';
|
||||
import { ACCEPTED_IMAGE_TYPES, NATIONAL_ID_LENGTH } from '@/services/verification/constants';
|
||||
import type { SubmitIdentityResult } from '@/services/verification/hooks/useSubmitIdentity';
|
||||
|
||||
type SubmitError = { key: 'national_id_mismatch' | 'shared_sim' | 'shahkar_mismatch' } | null;
|
||||
|
||||
/**
|
||||
* B4 — identity submission. Collects the national id (10-digit + checksum), a national-ID card image,
|
||||
* and a liveness selfie, then runs the automated civil-registry KYC + the chained Shahkar match. The
|
||||
* card/selfie are **local captures** feeding the automated check (identity stores no document server-side),
|
||||
* so `DocumentUpload` runs in local mode here. The auto-query note is honest — this check is performed.
|
||||
* The shared-SIM Shahkar failure surfaces as a clear, non-accusatory message; a national-ID mismatch on
|
||||
* its own step.
|
||||
*/
|
||||
export default function IdentitySubmitPage() {
|
||||
const t = useTranslations('verification');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const submitIdentity = useSubmitIdentity();
|
||||
|
||||
const [nationalId, setNationalId] = useState('');
|
||||
const [idError, setIdError] = useState(false);
|
||||
const [cardCaptured, setCardCaptured] = useState(false);
|
||||
const [selfieCaptured, setSelfieCaptured] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<SubmitError>(null);
|
||||
|
||||
// Local capture: the card/selfie feed the automated KYC (no stored document) — resolve immediately.
|
||||
const captureLocally = async (file: File) => ({ name: file.name });
|
||||
|
||||
const canSubmit = isValidNationalId(nationalId) && selfieCaptured && !submitIdentity.isPending;
|
||||
|
||||
const handleSubmit = () => {
|
||||
const idValid = isValidNationalId(nationalId);
|
||||
setIdError(!idValid);
|
||||
setSubmitError(null);
|
||||
if (!idValid || !selfieCaptured) return;
|
||||
|
||||
submitIdentity.mutate(
|
||||
{ nationalId, livenessCaptured: selfieCaptured },
|
||||
{
|
||||
onSuccess: (result: SubmitIdentityResult) => {
|
||||
if (result.identity.stepStatus === 'failed') {
|
||||
setSubmitError({ key: 'national_id_mismatch' });
|
||||
return;
|
||||
}
|
||||
if (result.shahkar?.stepStatus === 'failed') {
|
||||
setSubmitError({ key: result.shahkar.failureReason === 'shared_sim' ? 'shared_sim' : 'shahkar_mismatch' });
|
||||
return;
|
||||
}
|
||||
enqueueSnackbar(t('identity_submitted'), { variant: 'success' });
|
||||
router.push(`/${locale}${ROUTES.NURSE_VERIFICATION}`);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 560 }}>
|
||||
<Box>
|
||||
<Typography variant="h5" component="h1">
|
||||
{t('identity_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('identity_subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ overflowX: 'auto' }}>
|
||||
<StepperHeader
|
||||
steps={[t('journey_identity'), t('journey_credentials'), t('journey_review')]}
|
||||
activeStep={0}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<TextField
|
||||
label={t('national_id_label')}
|
||||
value={nationalId}
|
||||
onChange={(event) => {
|
||||
setNationalId(toEnglishDigits(event.target.value).replace(/\D/g, '').slice(0, NATIONAL_ID_LENGTH));
|
||||
if (idError) setIdError(false);
|
||||
}}
|
||||
error={idError}
|
||||
helperText={idError ? t('national_id_invalid') : t('national_id_hint')}
|
||||
slotProps={{ htmlInput: { dir: 'ltr', inputMode: 'numeric', style: { textAlign: 'start', letterSpacing: 2 } } }}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<DocumentUpload
|
||||
label={t('card_label')}
|
||||
hint={t('card_hint')}
|
||||
accept={ACCEPTED_IMAGE_TYPES}
|
||||
capture="environment"
|
||||
onUpload={captureLocally}
|
||||
onUploaded={() => setCardCaptured(true)}
|
||||
/>
|
||||
|
||||
<DocumentUpload
|
||||
label={t('selfie_label')}
|
||||
hint={t('selfie_hint')}
|
||||
accept={ACCEPTED_IMAGE_TYPES}
|
||||
capture="user"
|
||||
onUpload={captureLocally}
|
||||
onUploaded={() => setSelfieCaptured(true)}
|
||||
/>
|
||||
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{ p: 1.5, borderRadius: 2, bgcolor: 'var(--bal-primary-soft)', display: 'flex', gap: 1, alignItems: 'center' }}
|
||||
>
|
||||
<AppIcon icon="info" size={18} color="var(--bal-primary)" />
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('auto_registry_note')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
|
||||
{submitError ? (
|
||||
<AppAlert severity={submitError.key === 'shared_sim' ? 'warning' : 'error'} variant="outlined">
|
||||
{t(`error_${submitError.key}`)}
|
||||
</AppAlert>
|
||||
) : null}
|
||||
|
||||
{!cardCaptured ? (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('card_recommended')}
|
||||
</Typography>
|
||||
) : null}
|
||||
|
||||
<Stack direction="row" sx={{ gap: 1 }}>
|
||||
<AppButton
|
||||
color="primary"
|
||||
variant="contained"
|
||||
startIcon="identity"
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{submitIdentity.isPending ? t('identity_submitting') : t('identity_submit')}
|
||||
</AppButton>
|
||||
<AppButton variant="text" color="primary" to={`/${locale}${ROUTES.NURSE_VERIFICATION}`} sx={{ m: 0 }}>
|
||||
{t('back_to_checklist')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,237 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { PlaceholderScreen } from '@/components';
|
||||
'use client';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import { AppAlert, AppButton, AppIcon } from '@/components';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { useStartVerification, useVerificationStatus } from '@/services/verification';
|
||||
import { verificationKeys } from '@/services/verification/keys';
|
||||
import { USE_VERIFICATION_MOCK } from '@/services/verification/constants';
|
||||
import { __mockApproveAll, __mockRejectStep } from '@/services/verification/apis/mockApi';
|
||||
import VerificationChecklist from './VerificationChecklist';
|
||||
import { nextActionRoute, nextActionableIndex } from './verificationSteps';
|
||||
|
||||
export default async function NurseVerificationPage() {
|
||||
const t = await getTranslations('nav');
|
||||
const tShell = await getTranslations('shell');
|
||||
return <PlaceholderScreen icon="verification" title={t('verification')} description={tShell('placeholder_body')} />;
|
||||
/**
|
||||
* B3 — the verification status hub. The canonical view of the single cached `VerificationStatus` query
|
||||
* (B6 is a focused second view of the same data, never a separate fetch). Renders the loading skeleton,
|
||||
* error, the `not_started` start-CTA, the in-progress checklist + a single "continue" CTA that routes to
|
||||
* the next actionable step, and the terminal `approved` state (link to publish). The mock-only admin
|
||||
* simulation lets a human observe the verified flip while the b6 admin queue is deferred (f15).
|
||||
*/
|
||||
export default function NurseVerificationPage() {
|
||||
const t = useTranslations('verification');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: status, isLoading, isError, refetch } = useVerificationStatus();
|
||||
const startVerification = useStartVerification();
|
||||
|
||||
const go = (route: string) => router.push(`/${locale}${route}`);
|
||||
|
||||
const handleContinue = () => {
|
||||
if (!status || status.status === 'not_started') {
|
||||
startVerification.mutate(undefined, {
|
||||
onSuccess: () => go(ROUTES.NURSE_VERIFICATION_IDENTITY),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const route = nextActionRoute(status);
|
||||
if (route) go(route);
|
||||
};
|
||||
|
||||
const refreshStatus = () => queryClient.invalidateQueries({ queryKey: verificationKeys.status() });
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 620 }}>
|
||||
<Box>
|
||||
<Typography variant="h5" component="h1">
|
||||
{t('title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{isLoading ? (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<Skeleton variant="rounded" height={72} sx={{ borderRadius: 2 }} />
|
||||
{[0, 1, 2, 3].map((key) => (
|
||||
<Skeleton key={key} variant="rounded" height={64} sx={{ borderRadius: 2 }} />
|
||||
))}
|
||||
</Stack>
|
||||
) : isError ? (
|
||||
<AppAlert
|
||||
severity="error"
|
||||
action={
|
||||
<AppButton variant="text" color="inherit" onClick={() => refetch()} sx={{ m: 0 }}>
|
||||
{t('retry')}
|
||||
</AppButton>
|
||||
}
|
||||
>
|
||||
{t('load_error')}
|
||||
</AppAlert>
|
||||
) : !status || status.status === 'not_started' ? (
|
||||
<NotStarted onStart={handleContinue} pending={startVerification.isPending} />
|
||||
) : status.status === 'approved' ? (
|
||||
<Approved onPublish={() => go(ROUTES.NURSE_SERVICES)} />
|
||||
) : (
|
||||
<>
|
||||
<VerificationChecklist status={status} />
|
||||
<BlockingSummary hasBlocking={status.blockingSteps.length > 0} />
|
||||
<ContinueCta status={status} onContinue={handleContinue} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Dev-only: stand in for the deferred (f15) admin review queue so a human can watch the flip. */}
|
||||
{USE_VERIFICATION_MOCK && status && status.status !== 'not_started' ? (
|
||||
<MockAdminControls
|
||||
onApprove={() => {
|
||||
__mockApproveAll();
|
||||
refreshStatus();
|
||||
}}
|
||||
onReject={() => {
|
||||
__mockRejectStep('moh_competency_license', 'blurry_scan');
|
||||
refreshStatus();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function NotStarted({ onStart, pending }: { onStart: () => void; pending: boolean }) {
|
||||
const t = useTranslations('verification');
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 4,
|
||||
textAlign: 'center',
|
||||
border: '1px dashed',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
}}
|
||||
>
|
||||
<AppIcon icon="verification" size={44} color="var(--bal-primary)" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('start_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', maxWidth: 440 }}>
|
||||
{t('start_body')}
|
||||
</Typography>
|
||||
<AppButton
|
||||
color="primary"
|
||||
variant="contained"
|
||||
startIcon="verification"
|
||||
onClick={onStart}
|
||||
disabled={pending}
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
{pending ? t('starting') : t('start_cta')}
|
||||
</AppButton>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function Approved({ onPublish }: { onPublish: () => void }) {
|
||||
const t = useTranslations('verification');
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 3,
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderInlineStartWidth: 4,
|
||||
borderInlineStartColor: 'var(--bal-success)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1.5,
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
|
||||
<AppIcon icon="verified" size={28} color="var(--bal-success)" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('approved_title')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('approved_body')}
|
||||
</Typography>
|
||||
<AppButton
|
||||
color="primary"
|
||||
variant="contained"
|
||||
startIcon="publish"
|
||||
onClick={onPublish}
|
||||
sx={{ m: 0, alignSelf: 'flex-start' }}
|
||||
>
|
||||
{t('approved_cta')}
|
||||
</AppButton>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function BlockingSummary({ hasBlocking }: { hasBlocking: boolean }) {
|
||||
const t = useTranslations('verification');
|
||||
if (!hasBlocking) return null;
|
||||
return (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('blocking_summary')}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
function ContinueCta({
|
||||
status,
|
||||
onContinue,
|
||||
}: {
|
||||
status: Parameters<typeof nextActionRoute>[0];
|
||||
onContinue: () => void;
|
||||
}) {
|
||||
const t = useTranslations('verification');
|
||||
const index = nextActionableIndex(status);
|
||||
const route = nextActionRoute(status);
|
||||
if (route == null) return null;
|
||||
return (
|
||||
<AppButton color="primary" variant="contained" onClick={onContinue} sx={{ m: 0, alignSelf: 'flex-start' }}>
|
||||
{index != null ? t('continue_step', { n: index }) : t('continue')}
|
||||
</AppButton>
|
||||
);
|
||||
}
|
||||
|
||||
function MockAdminControls({ onApprove, onReject }: { onApprove: () => void; onReject: () => void }) {
|
||||
const t = useTranslations('verification');
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
border: '1px dashed',
|
||||
borderColor: 'divider',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 700 }}>
|
||||
{t('mock_admin_title')}
|
||||
</Typography>
|
||||
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
|
||||
<AppButton variant="outlined" color="primary" onClick={onApprove} sx={{ m: 0 }}>
|
||||
{t('mock_admin_approve')}
|
||||
</AppButton>
|
||||
<AppButton variant="outlined" color="error" onClick={onReject} sx={{ m: 0 }}>
|
||||
{t('mock_admin_reject')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
'use client';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Box, Paper, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, AppLoading, StatusChip, StepperHeader } from '@/components';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { useVerificationStatus } from '@/services/verification';
|
||||
import { displaySteps, stepLabelKey, stepStatusChip } from '../verificationSteps';
|
||||
|
||||
/**
|
||||
* B6 — under review. A focused view of the **same cached `VerificationStatus`** B3 reads (one query, two
|
||||
* views — never a second fetch). Shows the waiting message + the 24–48h expectation + a condensed
|
||||
* mini-checklist (reusing the shared `StatusChip`) of what is passed vs in-review vs pending. "مشاهده
|
||||
* وضعیت" returns to the canonical B3 hub.
|
||||
*/
|
||||
export default function UnderReviewPage() {
|
||||
const t = useTranslations('verification');
|
||||
const locale = useLocale();
|
||||
const { data: status, isLoading } = useVerificationStatus();
|
||||
|
||||
if (isLoading) return <AppLoading />;
|
||||
|
||||
const steps = displaySteps(status);
|
||||
const isApproved = status?.status === 'approved';
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 560 }}>
|
||||
<Box sx={{ overflowX: 'auto' }}>
|
||||
<StepperHeader steps={[t('journey_identity'), t('journey_credentials'), t('journey_review')]} activeStep={2} />
|
||||
</Box>
|
||||
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 3,
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderInlineStartWidth: 4,
|
||||
borderInlineStartColor: isApproved ? 'var(--bal-success)' : 'var(--bal-warning)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
|
||||
<AppIcon icon={isApproved ? 'verified' : 'pending'} size={28} color={isApproved ? 'var(--bal-success)' : 'var(--bal-warning)'} />
|
||||
<Typography variant="h6" component="h1">
|
||||
{isApproved ? t('review_approved_title') : t('review_title')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{isApproved ? t('review_approved_body') : t('review_body')}
|
||||
</Typography>
|
||||
{!isApproved ? (
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: 'var(--bal-warning)' }}>
|
||||
{t('review_eta')}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Paper>
|
||||
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{t('review_summary_title')}
|
||||
</Typography>
|
||||
{steps.map((step) => {
|
||||
const chip = stepStatusChip(step.status);
|
||||
const label = t.has(stepLabelKey(step.code)) ? t(stepLabelKey(step.code)) : step.displayName;
|
||||
return (
|
||||
<Stack
|
||||
key={step.code}
|
||||
direction="row"
|
||||
sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between', py: 0.5 }}
|
||||
>
|
||||
<Typography variant="body2">{label}</Typography>
|
||||
<StatusChip status={chip.kind} label={t(chip.labelKey)} sx={{ flexShrink: 0 }} />
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
<AppButton
|
||||
color="primary"
|
||||
variant="contained"
|
||||
startIcon="verification"
|
||||
to={`/${locale}${ROUTES.NURSE_VERIFICATION}`}
|
||||
sx={{ m: 0, alignSelf: 'flex-start' }}
|
||||
>
|
||||
{t('review_view_status')}
|
||||
</AppButton>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { StatusKind } from '@/components';
|
||||
import { ROUTES } from '@/constants';
|
||||
import type { VerificationStatus, VerificationStep, VerificationStepStatus } from '@/services/verification/types';
|
||||
|
||||
/**
|
||||
* Screen helpers shared across the verification subtree (B3/B4/B5/B6). Keeps the rendering **data-driven**:
|
||||
* the screens iterate `steps[]` and map each `code`/`status` to a label + chip via these helpers — a new
|
||||
* step type in the response renders without a code change. Labels are i18n keys off the `code`/`status`,
|
||||
* never derived from the enum string (honesty + localisation rule).
|
||||
*/
|
||||
|
||||
/**
|
||||
* A display-only "mobile verified" step prepended to the checklist. It is **not** a server step — it is
|
||||
* satisfied at login (f1 phone-OTP), so it always renders `passed` and never blocks. Id 0 keeps it out
|
||||
* of the server-id space.
|
||||
*/
|
||||
export const MOBILE_STEP: VerificationStep = {
|
||||
id: 0,
|
||||
code: 'mobile_verified',
|
||||
displayName: 'mobile_verified',
|
||||
status: 'passed',
|
||||
isAutomated: true,
|
||||
expiresAt: null,
|
||||
failureReason: null,
|
||||
};
|
||||
|
||||
/** The full ordered checklist as shown: the synthetic mobile step, then the server's seeded steps. */
|
||||
export function displaySteps(status: VerificationStatus | undefined): VerificationStep[] {
|
||||
return status ? [MOBILE_STEP, ...status.steps] : [MOBILE_STEP];
|
||||
}
|
||||
|
||||
/** "X از Y" meter counts — X = passed steps, Y = total (all seeded steps are required). */
|
||||
export function progressCounts(status: VerificationStatus | undefined): { passed: number; total: number } {
|
||||
const steps = displaySteps(status);
|
||||
return { passed: steps.filter((step) => step.status === 'passed').length, total: steps.length };
|
||||
}
|
||||
|
||||
/** The i18n label key for a step, keyed off its stable `code`. */
|
||||
export function stepLabelKey(code: string): string {
|
||||
return `step_${code}`;
|
||||
}
|
||||
|
||||
/** The i18n one-line description key for a step (what it verifies / why it is manual vs automatic). */
|
||||
export function stepDescriptionKey(code: string): string {
|
||||
return `step_${code}_desc`;
|
||||
}
|
||||
|
||||
interface StepChip {
|
||||
kind: StatusKind;
|
||||
labelKey: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The chip kind + label key for a per-step status. Encodes the wireframe legend: green = passed,
|
||||
* amber = pending/in-review/expired, grey = not-started/next, red = failed. Expired is amber but keeps
|
||||
* its own label so copy stays honest ("expired", not "pending").
|
||||
*/
|
||||
export function stepStatusChip(status: VerificationStepStatus): StepChip {
|
||||
switch (status) {
|
||||
case 'passed':
|
||||
return { kind: 'verified', labelKey: 'status_passed' };
|
||||
case 'in_review':
|
||||
return { kind: 'pending', labelKey: 'status_in_review' };
|
||||
case 'pending':
|
||||
return { kind: 'pending', labelKey: 'status_pending' };
|
||||
case 'failed':
|
||||
return { kind: 'rejected', labelKey: 'status_failed' };
|
||||
case 'expired':
|
||||
return { kind: 'pending', labelKey: 'status_expired' };
|
||||
case 'not_started':
|
||||
default:
|
||||
return { kind: 'neutral', labelKey: 'status_next' };
|
||||
}
|
||||
}
|
||||
|
||||
/** Which submission screen owns a given step code (drives the row CTA + the "continue" router). */
|
||||
export function routeForStep(code: string): string | null {
|
||||
switch (code) {
|
||||
case 'identity_kyc':
|
||||
case 'shahkar_match':
|
||||
return ROUTES.NURSE_VERIFICATION_IDENTITY;
|
||||
case 'moh_competency_license':
|
||||
case 'ino_membership':
|
||||
case 'criminal_record':
|
||||
return ROUTES.NURSE_VERIFICATION_CREDENTIALS;
|
||||
case 'bank_account_verification':
|
||||
return ROUTES.NURSE_BANK;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** A step the nurse can act on now — not passed, not waiting on an admin (`in_review`). */
|
||||
function isActionable(step: VerificationStep): boolean {
|
||||
return step.status !== 'passed' && step.status !== 'in_review' && routeForStep(step.code) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The route the B3 "continue" CTA targets: the first actionable step's screen (in checklist order); if
|
||||
* everything left is waiting on admin (`in_review`), the under-review screen; `null` when approved (the
|
||||
* publish CTA takes over).
|
||||
*/
|
||||
export function nextActionRoute(status: VerificationStatus | undefined): string | null {
|
||||
if (!status || status.status === 'approved') return null;
|
||||
const next = status.steps.find(isActionable);
|
||||
if (next) return routeForStep(next.code);
|
||||
if (status.steps.some((step) => step.status === 'in_review')) return ROUTES.NURSE_VERIFICATION_REVIEW;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The 1-based index of the next actionable step (for the "continue to step N" CTA label). */
|
||||
export function nextActionableIndex(status: VerificationStatus | undefined): number | null {
|
||||
if (!status) return null;
|
||||
const steps = displaySteps(status);
|
||||
const index = steps.findIndex(isActionable);
|
||||
return index === -1 ? null : index + 1;
|
||||
}
|
||||
Reference in New Issue
Block a user