228 lines
8.6 KiB
TypeScript
228 lines
8.6 KiB
TypeScript
'use client';
|
|
import { Suspense, useEffect, useRef } from 'react';
|
|
import { useLocale, useTranslations } from 'next-intl';
|
|
import { useRouter } from 'next/navigation';
|
|
import { Box, Chip, Skeleton, Stack, Tab, Tabs, TextField } from '@mui/material';
|
|
import { AppButton, AppIcon, AppLoading, 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 { formatNumber, formatRelativeTime, formatShamsiDate } from '@/utils';
|
|
import { useAdminListState } from '@/hooks';
|
|
import { useVerificationQueue } from '@/services/verification';
|
|
import { ADMIN_QUEUE_PAGE_SIZE } from '@/services/verification/constants';
|
|
import type {
|
|
AdminVerificationQueueFilters,
|
|
AdminVerificationQueueItem,
|
|
VerificationAggregateStatus,
|
|
} from '@/services/verification/types';
|
|
import { EMPTY_QUEUE_FILTERS, parseQueueFilters, queueCaseHref, serializeQueueFilters } from './queueFilters';
|
|
|
|
/** SLA thresholds for the waiting-time column's color (display-only client signal — never a server rule). */
|
|
const WAITING_TIME_WARNING_HOURS = 48;
|
|
const WAITING_TIME_ALARM_HOURS = 96;
|
|
const MS_PER_HOUR = 60 * 60 * 1000;
|
|
|
|
/** 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',
|
|
};
|
|
|
|
export default function AdminVerificationQueuePage() {
|
|
return (
|
|
<Suspense fallback={<AppLoading />}>
|
|
<AdminVerificationQueueScreen />
|
|
</Suspense>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Verification review queue (b6 `AdminVerificationsController`) — the trust desk's worklist, one row per
|
|
* nurse folded from the per-step endpoint. Status tabs (all / pending / in_review, badge-counted when the
|
|
* server serves `counts` — REQ-062) replace the old lone select; a name/phone search follows the same
|
|
* draft-vs-applied Apply/Clear pattern as `admin/tickets`/`admin/audit`. Filters + page are URL-synced via
|
|
* `useAdminListState`, so a queue row carries them forward into the case URL (`queueCaseHref`) — the case
|
|
* page re-derives the same query key to reuse this cache for next/prev case navigation.
|
|
*/
|
|
function AdminVerificationQueueScreen() {
|
|
const t = useTranslations('admin');
|
|
const locale = useLocale();
|
|
const router = useRouter();
|
|
|
|
const state = useAdminListState<AdminVerificationQueueFilters>({
|
|
parse: parseQueueFilters,
|
|
serialize: serializeQueueFilters,
|
|
empty: EMPTY_QUEUE_FILTERS,
|
|
});
|
|
|
|
// Tabs commit immediately (they're discrete, not free text) — `state.apply()` closes over the CURRENT
|
|
// render's `draft`, so calling it synchronously right after `setDraft` would still see the stale value.
|
|
// Deferring the commit to the render that follows the draft update reads the fresh `draft` correctly.
|
|
const applyPendingRef = useRef(false);
|
|
useEffect(() => {
|
|
if (applyPendingRef.current) {
|
|
applyPendingRef.current = false;
|
|
state.apply();
|
|
}
|
|
});
|
|
|
|
const selectStatus = (next: '' | 'pending' | 'in_review') => {
|
|
state.setDraft((d) => ({ ...d, status: next || undefined }));
|
|
applyPendingRef.current = true;
|
|
};
|
|
|
|
const queue = useVerificationQueue(state.applied, state.page);
|
|
const items = queue.data?.items ?? [];
|
|
const total = queue.data?.total ?? 0;
|
|
const counts = queue.data?.counts;
|
|
const pageCount = Math.max(1, Math.ceil(total / ADMIN_QUEUE_PAGE_SIZE));
|
|
|
|
const tabLabel = (base: string, count: number | undefined): string =>
|
|
count === undefined ? base : `${base} (${formatNumber(count, locale)})`;
|
|
|
|
const footerText =
|
|
total > 0
|
|
? t('showing_range', {
|
|
from: (state.page - 1) * ADMIN_QUEUE_PAGE_SIZE + 1,
|
|
to: Math.min(state.page * ADMIN_QUEUE_PAGE_SIZE, total),
|
|
total,
|
|
})
|
|
: undefined;
|
|
|
|
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: 500 }}
|
|
/>
|
|
) : 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) : '—'),
|
|
},
|
|
{
|
|
key: 'waiting',
|
|
header: t('ver_col_waiting'),
|
|
render: (item) => {
|
|
if (!item.submittedAt) return '—';
|
|
const hours = (Date.now() - new Date(item.submittedAt).getTime()) / MS_PER_HOUR;
|
|
const color =
|
|
hours >= WAITING_TIME_ALARM_HOURS
|
|
? 'var(--bal-error)'
|
|
: hours >= WAITING_TIME_WARNING_HOURS
|
|
? 'var(--bal-warning)'
|
|
: undefined;
|
|
return (
|
|
<Box component="span" sx={color ? { color } : undefined}>
|
|
{formatRelativeTime(item.submittedAt, locale, formatShamsiDate)}
|
|
</Box>
|
|
);
|
|
},
|
|
},
|
|
];
|
|
|
|
return (
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
|
<AdminPageHeader title={t('ver_title')} subtitle={t('ver_subtitle')} />
|
|
|
|
<Tabs value={state.draft.status ?? ''} onChange={(_event, value: '' | 'pending' | 'in_review') => selectStatus(value)}>
|
|
<Tab value="" data-tab="all" label={t('filter_all')} />
|
|
<Tab value="pending" data-tab="pending" label={tabLabel(t('agg_pending'), counts?.pending)} />
|
|
<Tab value="in_review" data-tab="in_review" label={tabLabel(t('agg_in_review'), counts?.in_review)} />
|
|
</Tabs>
|
|
|
|
<Stack
|
|
direction="row"
|
|
sx={{ gap: 1.5, flexWrap: 'wrap', alignItems: 'center', p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
|
|
>
|
|
<TextField
|
|
size="small"
|
|
label={t('ver_search_label')}
|
|
placeholder={t('ver_search_ph')}
|
|
value={state.draft.search ?? ''}
|
|
onChange={(e) => state.setDraft((d) => ({ ...d, search: e.target.value || undefined }))}
|
|
sx={{ minWidth: 240 }}
|
|
/>
|
|
<AppButton variant="contained" color="primary" onClick={state.apply}>
|
|
{t('apply')}
|
|
</AppButton>
|
|
<AppButton variant="text" color="inherit" onClick={state.clear}>
|
|
{t('clear')}
|
|
</AppButton>
|
|
</Stack>
|
|
|
|
{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')}
|
|
footer={footerText}
|
|
onRowClick={(item) =>
|
|
router.push(
|
|
queueCaseHref(locale, adminVerificationCasePath(item.nurseVerificationId), state.applied, state.page),
|
|
)
|
|
}
|
|
/>
|
|
)}
|
|
|
|
<AdminPager
|
|
page={state.page}
|
|
pageCount={pageCount}
|
|
onPrev={() => state.goToPage(Math.max(1, state.page - 1))}
|
|
onNext={() => state.goToPage(Math.min(pageCount, state.page + 1))}
|
|
prevLabel={t('prev_page')}
|
|
nextLabel={t('next_page')}
|
|
indicator={t('page_indicator', { page: state.page, total: pageCount })}
|
|
/>
|
|
</Box>
|
|
);
|
|
}
|