frontend phase 15
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user