frontend phase 15

This commit is contained in:
hamid
2026-07-10 20:28:06 +03:30
parent bc51cf59b4
commit 70cf00ce4a
151 changed files with 10711 additions and 44 deletions
@@ -0,0 +1,452 @@
'use client';
import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import {
Box,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Skeleton,
Stack,
TextField,
Typography,
} from '@mui/material';
import { AppButton, StatusChip } from '@/components';
import type { StatusKind } from '@/components';
import {
AdminEmptyState,
AdminErrorState,
AdminPageHeader,
ConfirmDialog,
DocumentViewer,
} from '@/components/admin';
import { ROUTES } from '@/constants';
import { formatShamsiDate } from '@/utils';
import { useAdminCapabilities } from '@/hooks';
import {
useApproveVerification,
useDecideStep,
useRejectVerification,
useVerificationCase,
} from '@/services/verification';
import type { AdminVerificationStepDetail, VerificationStepStatus } from '@/services/verification/types';
/** The three credential-bearing step types — a Pass here opens the structured credential form. */
const CREDENTIAL_STEP_CODES: readonly string[] = ['moh_competency_license', 'ino_membership', 'criminal_record'];
/** Per-step status → chip kind. `expired`/`failed` read red; `in_review`/`pending` amber; `passed` green. */
const STEP_STATUS_KIND: Record<VerificationStepStatus, StatusKind> = {
not_started: 'neutral',
pending: 'pending',
in_review: 'pending',
passed: 'verified',
failed: 'rejected',
expired: 'rejected',
};
/**
* Verification case (b6 `AdminVerificationsController`) — the trust desk works one nurse: the identity on
* file for cross-check, every step with its status + documents (each `DocumentViewer` re-signs its own
* short-lived URL on demand), and the manual-step decisions. A credential-bearing step records the
* (encrypted) credential via a structured form; recorded credentials are listed by **type** only — the
* number never crosses the wire. The whole verification is approvable only when every required step has
* passed; a decision re-aggregates server-side (flipping `is_verified`) and removes the case from the queue.
*/
export default function AdminVerificationCasePage() {
const t = useTranslations('admin');
const locale = useLocale();
const router = useRouter();
const params = useParams<{ nurseId: string }>();
const nurseVerificationId = Number(params?.nurseId);
const caps = useAdminCapabilities();
const { enqueueSnackbar } = useSnackbar();
const { data, isLoading, isError, refetch } = useVerificationCase(
Number.isFinite(nurseVerificationId) ? nurseVerificationId : null,
);
const approve = useApproveVerification();
const reject = useRejectVerification();
const [approveOpen, setApproveOpen] = useState(false);
const [rejectOpen, setRejectOpen] = useState(false);
const backToQueue = () => router.push(`/${locale}${ROUTES.ADMIN_VERIFICATION}`);
const allPassed = !!data && data.steps.length > 0 && data.steps.every((step) => step.status === 'passed');
const onApprove = () => {
approve.mutate(nurseVerificationId, {
onSuccess: () => {
enqueueSnackbar(t('ver_decided'), { variant: 'success' });
setApproveOpen(false);
backToQueue();
},
});
};
const onReject = (reason?: string) => {
reject.mutate(
{ nurseVerificationId, reason: reason ?? '' },
{
onSuccess: () => {
enqueueSnackbar(t('ver_decided'), { variant: 'success' });
setRejectOpen(false);
backToQueue();
},
},
);
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Stack sx={{ gap: 1 }}>
<AppButton variant="text" color="primary" onClick={backToQueue} sx={{ m: 0, alignSelf: 'flex-start' }}>
{t('back')}
</AppButton>
<AdminPageHeader title={t('ver_case_title')} />
</Stack>
{isLoading ? (
<Stack sx={{ gap: 2 }}>
<Skeleton variant="rounded" height={72} />
<Skeleton variant="rounded" height={220} />
<Skeleton variant="rounded" height={160} />
</Stack>
) : isError ? (
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => refetch()} />
) : !data ? (
<AdminEmptyState icon="verified" title={t('ver_empty')} />
) : (
<>
<Box sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, p: 2 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('ver_identity_name')}
</Typography>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{data.identityName}
</Typography>
</Box>
<Stack sx={{ gap: 1.5 }}>
<Typography variant="h6">{t('ver_steps_title')}</Typography>
{data.steps.map((step) => (
<StepCard
key={step.id}
step={step}
nurseVerificationId={nurseVerificationId}
canVerify={caps.canVerify}
/>
))}
</Stack>
{data.credentials.length > 0 ? (
<Stack sx={{ gap: 1.5 }}>
<Typography variant="h6">{t('ver_credentials_title')}</Typography>
{data.credentials.map((cred) => (
<Box key={cred.id} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, p: 1.75 }}>
<Stack
direction="row"
sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}
>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t(`step_${cred.credentialType}`)}
</Typography>
{cred.expiresAt ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('ver_expires_at')}: {formatShamsiDate(cred.expiresAt, locale)}
</Typography>
) : null}
</Stack>
<Typography variant="body2">{cred.holderNameSnapshot}</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{cred.issuingAuthority}
</Typography>
</Box>
))}
</Stack>
) : null}
<Stack sx={{ gap: 0.5 }}>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
<AppButton
variant="contained"
color="primary"
onClick={() => setApproveOpen(true)}
disabled={!allPassed || !caps.canVerify}
sx={{ m: 0 }}
>
{t('ver_approve')}
</AppButton>
<AppButton
variant="outlined"
color="error"
onClick={() => setRejectOpen(true)}
disabled={!caps.canVerify}
sx={{ m: 0 }}
>
{t('ver_reject_all')}
</AppButton>
</Stack>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('ver_approve_hint')}
</Typography>
</Stack>
</>
)}
<ConfirmDialog
open={approveOpen}
title={t('ver_approve')}
body={t('ver_approve_confirm')}
confirmLabel={t('confirm')}
cancelLabel={t('cancel')}
onConfirm={onApprove}
onClose={() => setApproveOpen(false)}
loading={approve.isPending}
/>
<ConfirmDialog
open={rejectOpen}
title={t('ver_reject_all')}
body={t('ver_reject_confirm')}
confirmLabel={t('ver_reject_all')}
cancelLabel={t('cancel')}
onConfirm={onReject}
onClose={() => setRejectOpen(false)}
loading={reject.isPending}
requireReason
reasonLabel={t('reason_label')}
reasonPlaceholder={t('ver_reject_reason_ph')}
confirmColor="error"
/>
</Box>
);
}
/** One step of the case: label + status chip (+ automated badge), its documents, and — for a decidable
* manual step — Pass / Reject. A credential-bearing Pass opens the structured credential form. */
function StepCard({
step,
nurseVerificationId,
canVerify,
}: {
step: AdminVerificationStepDetail;
nurseVerificationId: number;
canVerify: boolean;
}) {
const t = useTranslations('admin');
const { enqueueSnackbar } = useSnackbar();
const decide = useDecideStep();
const [rejectOpen, setRejectOpen] = useState(false);
const [credentialOpen, setCredentialOpen] = useState(false);
const isManual = !step.isAutomated;
const isCredentialStep = CREDENTIAL_STEP_CODES.includes(step.code);
const isDecidable = isManual && (step.status === 'in_review' || step.status === 'pending');
const onPass = () => {
decide.mutate(
{ stepId: step.id, nurseVerificationId, input: { approve: true } },
{ onSuccess: () => enqueueSnackbar(t('ver_decided'), { variant: 'success' }) },
);
};
const onReject = (reason?: string) => {
decide.mutate(
{ stepId: step.id, nurseVerificationId, input: { approve: false, rejectionReason: reason ?? '' } },
{
onSuccess: () => {
enqueueSnackbar(t('ver_decided'), { variant: 'success' });
setRejectOpen(false);
},
},
);
};
return (
<Box sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, p: 2 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, flexGrow: 1 }}>
{t(`step_${step.code}`)}
</Typography>
<StatusChip status={STEP_STATUS_KIND[step.status]} label={t(`step_${step.status}`)} />
{step.isAutomated ? (
<Chip size="small" label={t('ver_automated_badge')} sx={{ bgcolor: 'action.hover', fontWeight: 600 }} />
) : null}
</Stack>
{step.documents.length > 0 ? (
<Stack sx={{ gap: 1.5, mt: 1.5 }}>
{step.documents.map((doc) => (
<DocumentViewer key={doc.id} document={doc} />
))}
</Stack>
) : isManual ? (
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 1 }}>
{t('ver_no_documents')}
</Typography>
) : null}
{step.failureReason ? (
<Typography variant="caption" sx={{ color: 'var(--bal-error)', mt: 1, display: 'block' }}>
{step.failureReason}
</Typography>
) : null}
{isDecidable && canVerify ? (
<Stack direction="row" sx={{ gap: 1, mt: 1.5, flexWrap: 'wrap' }}>
<AppButton
variant="contained"
color="primary"
onClick={() => (isCredentialStep ? setCredentialOpen(true) : onPass())}
disabled={decide.isPending}
sx={{ m: 0 }}
>
{t('ver_pass')}
</AppButton>
<AppButton
variant="outlined"
color="error"
onClick={() => setRejectOpen(true)}
disabled={decide.isPending}
sx={{ m: 0 }}
>
{t('ver_reject')}
</AppButton>
</Stack>
) : null}
<ConfirmDialog
open={rejectOpen}
title={t('ver_reject_step')}
confirmLabel={t('ver_reject')}
cancelLabel={t('cancel')}
onConfirm={onReject}
onClose={() => setRejectOpen(false)}
loading={decide.isPending}
requireReason
reasonLabel={t('reason_label')}
reasonPlaceholder={t('ver_reject_reason_ph')}
confirmColor="error"
/>
{isCredentialStep && credentialOpen ? (
<CredentialDialog step={step} nurseVerificationId={nurseVerificationId} onClose={() => setCredentialOpen(false)} />
) : null}
</Box>
);
}
/** The structured credential form recorded on approving a credential-bearing step. `criminal_record`
* requires an expiry date; `credentialNumber` is accepted as input and never echoed back. */
function CredentialDialog({
step,
nurseVerificationId,
onClose,
}: {
step: AdminVerificationStepDetail;
nurseVerificationId: number;
onClose: () => void;
}) {
const t = useTranslations('admin');
const { enqueueSnackbar } = useSnackbar();
const decide = useDecideStep();
const [credentialNumber, setCredentialNumber] = useState('');
const [holderName, setHolderName] = useState('');
const [issuingAuthority, setIssuingAuthority] = useState('');
const [issuedAt, setIssuedAt] = useState('');
const [expiresAt, setExpiresAt] = useState('');
const expiryRequired = step.code === 'criminal_record';
const expiryMissing = expiryRequired && expiresAt.trim().length === 0;
const onSubmit = () => {
if (expiryMissing) return;
decide.mutate(
{
stepId: step.id,
nurseVerificationId,
input: {
approve: true,
credentialNumber: credentialNumber.trim() || undefined,
holderName: holderName.trim() || undefined,
issuingAuthority: issuingAuthority.trim() || undefined,
issuedAt: issuedAt || undefined,
expiresAt: expiresAt || undefined,
},
},
{
onSuccess: () => {
enqueueSnackbar(t('ver_decided'), { variant: 'success' });
onClose();
},
},
);
};
return (
<Dialog open onClose={decide.isPending ? undefined : onClose} fullWidth maxWidth="sm">
<DialogTitle sx={{ fontWeight: 800 }}>{t('ver_credential_title')}</DialogTitle>
<DialogContent>
<Stack sx={{ gap: 2, mt: 1 }}>
<TextField
fullWidth
autoFocus
label={t('ver_credential_number')}
value={credentialNumber}
onChange={(e) => setCredentialNumber(e.target.value)}
/>
<TextField
fullWidth
label={t('ver_holder_name')}
helperText={t('ver_holder_hint')}
value={holderName}
onChange={(e) => setHolderName(e.target.value)}
/>
<TextField
fullWidth
label={t('ver_issuing_authority')}
value={issuingAuthority}
onChange={(e) => setIssuingAuthority(e.target.value)}
/>
<TextField
fullWidth
type="date"
label={t('ver_issued_at')}
value={issuedAt}
onChange={(e) => setIssuedAt(e.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
/>
<TextField
fullWidth
type="date"
label={t('ver_expires_at')}
value={expiresAt}
onChange={(e) => setExpiresAt(e.target.value)}
required={expiryRequired}
error={expiryMissing}
helperText={expiryMissing ? t('ver_expiry_required') : undefined}
slotProps={{ inputLabel: { shrink: true } }}
/>
</Stack>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<AppButton variant="text" color="inherit" onClick={onClose} disabled={decide.isPending} sx={{ m: 0 }}>
{t('cancel')}
</AppButton>
<AppButton
variant="contained"
color="primary"
onClick={onSubmit}
disabled={decide.isPending || expiryMissing}
sx={{ m: 0 }}
>
{decide.isPending ? t('saving') : t('save')}
</AppButton>
</DialogActions>
</Dialog>
);
}
@@ -0,0 +1,147 @@
'use client';
import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { Box, Chip, MenuItem, Skeleton, Stack, TextField } from '@mui/material';
import { AppIcon, StatusChip } from '@/components';
import type { StatusKind } from '@/components';
import {
AdminDataTable,
AdminEmptyState,
AdminErrorState,
AdminPageHeader,
AdminPager,
} from '@/components/admin';
import type { AdminTableColumn } from '@/components/admin';
import { adminVerificationCasePath } from '@/constants';
import { formatShamsiDate } from '@/utils';
import { useVerificationQueue } from '@/services/verification';
import { ADMIN_QUEUE_PAGE_SIZE } from '@/services/verification/constants';
import type { AdminVerificationQueueItem, VerificationAggregateStatus } from '@/services/verification/types';
/** The queue status filter — a subset of the aggregate statuses the desk works (default all). */
type QueueStatusFilter = '' | 'pending' | 'in_review';
/** Aggregate status → chip kind. `in_review` reads as informational; a rejected/suspended case shows red. */
const AGG_STATUS_KIND: Record<VerificationAggregateStatus, StatusKind> = {
not_started: 'neutral',
pending: 'pending',
in_review: 'info',
approved: 'verified',
rejected: 'rejected',
suspended: 'rejected',
};
/**
* Verification review queue (b6 `AdminVerificationsController`) — the trust desk's worklist, one row per
* nurse folded from the per-step endpoint. Filter by status (all / pending / in_review); each row surfaces
* the step progress, the next pending step, when it was submitted, and a warning when a credential is
* expiring. A row opens its case. The filter + page are the query key, so switching them reuses cached
* pages; a decision on a case invalidates the queue so the desk re-renders without a manual refresh.
*/
export default function AdminVerificationQueuePage() {
const t = useTranslations('admin');
const locale = useLocale();
const router = useRouter();
const [status, setStatus] = useState<QueueStatusFilter>('');
const [page, setPage] = useState(1);
const queue = useVerificationQueue({ status: status || undefined }, page);
const items = queue.data?.items ?? [];
const pageCount = Math.max(1, Math.ceil((queue.data?.total ?? 0) / ADMIN_QUEUE_PAGE_SIZE));
const columns: AdminTableColumn<AdminVerificationQueueItem>[] = [
{
key: 'nurse',
header: t('ver_col_nurse'),
render: (item) => (
<Stack sx={{ gap: 0.5, alignItems: 'flex-start' }}>
<Box sx={{ fontWeight: 700 }}>{item.nurseName}</Box>
{item.hasExpiringCredential ? (
<Chip
size="small"
icon={<AppIcon icon="warning" size={14} color="var(--bal-warning-contrast)" />}
label={t('ver_expiring_warning')}
sx={{ bgcolor: 'var(--bal-warning)', color: 'var(--bal-warning-contrast)', fontWeight: 600 }}
/>
) : null}
</Stack>
),
},
{
key: 'status',
header: t('ver_col_status'),
render: (item) => <StatusChip status={AGG_STATUS_KIND[item.status]} label={t(`agg_${item.status}`)} />,
},
{
key: 'step',
header: t('ver_col_step'),
render: (item) => (
<Stack sx={{ gap: 0.25 }}>
<Box>{t('ver_progress', { done: item.stepsPassed, total: item.stepsTotal })}</Box>
<Box sx={{ color: 'text.secondary', fontSize: 13 }}>
{t('ver_next_step', { step: item.nextPendingStepCode ? t(`step_${item.nextPendingStepCode}`) : '—' })}
</Box>
</Stack>
),
},
{
key: 'submitted',
header: t('ver_col_submitted'),
render: (item) => (item.submittedAt ? formatShamsiDate(item.submittedAt, locale) : '—'),
},
];
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader
title={t('ver_title')}
subtitle={t('ver_subtitle')}
actions={
<TextField
select
size="small"
label={t('ver_col_status')}
value={status}
onChange={(e) => {
setStatus(e.target.value as QueueStatusFilter);
setPage(1);
}}
sx={{ minWidth: 160 }}
>
<MenuItem value="">{t('filter_all')}</MenuItem>
<MenuItem value="pending">{t('agg_pending')}</MenuItem>
<MenuItem value="in_review">{t('agg_in_review')}</MenuItem>
</TextField>
}
/>
{queue.isLoading ? (
<Stack sx={{ gap: 1 }}>{[0, 1, 2, 3].map((k) => <Skeleton key={k} variant="rounded" height={56} />)}</Stack>
) : queue.isError ? (
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => queue.refetch()} />
) : items.length === 0 ? (
<AdminEmptyState icon="verified" title={t('ver_empty')} />
) : (
<AdminDataTable
columns={columns}
rows={items}
getRowKey={(item) => item.nurseVerificationId}
ariaLabel={t('ver_title')}
onRowClick={(item) => router.push(`/${locale}${adminVerificationCasePath(item.nurseVerificationId)}`)}
/>
)}
<AdminPager
page={page}
pageCount={pageCount}
onPrev={() => setPage((p) => Math.max(1, p - 1))}
onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
prevLabel={t('prev_page')}
nextLabel={t('next_page')}
indicator={t('page_indicator', { page })}
/>
</Box>
);
}