ui phase 11

This commit is contained in:
hamid
2026-07-19 19:19:44 +03:30
parent b4b8c9ea79
commit 87fa4cd497
74 changed files with 3115 additions and 506 deletions
@@ -1,9 +1,9 @@
'use client';
import { useState } from 'react';
import { Suspense, useEffect, useRef } 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 { Box, Chip, Skeleton, Stack, Tab, Tabs, TextField } from '@mui/material';
import { AppButton, AppIcon, AppLoading, StatusChip } from '@/components';
import type { StatusKind } from '@/components';
import {
AdminDataTable,
@@ -14,13 +14,21 @@ import {
} from '@/components/admin';
import type { AdminTableColumn } from '@/components/admin';
import { adminVerificationCasePath } from '@/constants';
import { formatShamsiDate } from '@/utils';
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 { AdminVerificationQueueItem, VerificationAggregateStatus } from '@/services/verification/types';
import type {
AdminVerificationQueueFilters,
AdminVerificationQueueItem,
VerificationAggregateStatus,
} from '@/services/verification/types';
import { EMPTY_QUEUE_FILTERS, parseQueueFilters, queueCaseHref, serializeQueueFilters } from './queueFilters';
/** The queue status filter — a subset of the aggregate statuses the desk works (default all). */
type QueueStatusFilter = '' | 'pending' | 'in_review';
/** 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> = {
@@ -32,24 +40,66 @@ const AGG_STATUS_KIND: Record<VerificationAggregateStatus, StatusKind> = {
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. 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.
* 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.
*/
export default function AdminVerificationQueuePage() {
function AdminVerificationQueueScreen() {
const t = useTranslations('admin');
const locale = useLocale();
const router = useRouter();
const [status, setStatus] = useState<QueueStatusFilter>('');
const [page, setPage] = useState(1);
const state = useAdminListState<AdminVerificationQueueFilters>({
parse: parseQueueFilters,
serialize: serializeQueueFilters,
empty: EMPTY_QUEUE_FILTERS,
});
const queue = useVerificationQueue({ status: status || undefined }, page);
// 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 pageCount = Math.max(1, Math.ceil((queue.data?.total ?? 0) / ADMIN_QUEUE_PAGE_SIZE));
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>[] = [
{
@@ -91,31 +141,56 @@ export default function AdminVerificationQueuePage() {
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')}
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>
}
/>
<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>
@@ -129,18 +204,23 @@ export default function AdminVerificationQueuePage() {
rows={items}
getRowKey={(item) => item.nurseVerificationId}
ariaLabel={t('ver_title')}
onRowClick={(item) => router.push(`/${locale}${adminVerificationCasePath(item.nurseVerificationId)}`)}
footer={footerText}
onRowClick={(item) =>
router.push(
queueCaseHref(locale, adminVerificationCasePath(item.nurseVerificationId), state.applied, state.page),
)
}
/>
)}
<AdminPager
page={page}
page={state.page}
pageCount={pageCount}
onPrev={() => setPage((p) => Math.max(1, p - 1))}
onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
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 })}
indicator={t('page_indicator', { page: state.page, total: pageCount })}
/>
</Box>
);