'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 = { not_started: 'neutral', pending: 'pending', in_review: 'info', approved: 'verified', rejected: 'rejected', suspended: 'rejected', }; export default function AdminVerificationQueuePage() { return ( }> ); } /** * 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({ 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[] = [ { key: 'nurse', header: t('ver_col_nurse'), render: (item) => ( {item.nurseName} {item.hasExpiringCredential ? ( } label={t('ver_expiring_warning')} sx={{ bgcolor: 'var(--bal-warning)', color: 'var(--bal-warning-contrast)', fontWeight: 500 }} /> ) : null} ), }, { key: 'status', header: t('ver_col_status'), render: (item) => , }, { key: 'step', header: t('ver_col_step'), render: (item) => ( {t('ver_progress', { done: item.stepsPassed, total: item.stepsTotal })} {t('ver_next_step', { step: item.nextPendingStepCode ? t(`step_${item.nextPendingStepCode}`) : '—' })} ), }, { 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 ( {formatRelativeTime(item.submittedAt, locale, formatShamsiDate)} ); }, }, ]; return ( selectStatus(value)}> state.setDraft((d) => ({ ...d, search: e.target.value || undefined }))} sx={{ minWidth: 240 }} /> {t('apply')} {t('clear')} {queue.isLoading ? ( {[0, 1, 2, 3].map((k) => )} ) : queue.isError ? ( queue.refetch()} /> ) : items.length === 0 ? ( ) : ( item.nurseVerificationId} ariaLabel={t('ver_title')} footer={footerText} onRowClick={(item) => router.push( queueCaseHref(locale, adminVerificationCasePath(item.nurseVerificationId), state.applied, state.page), ) } /> )} 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 })} /> ); }