diff --git a/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage.tsx b/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage.tsx index 818af2802..21bde24f6 100644 --- a/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage.tsx +++ b/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage.tsx @@ -9,7 +9,6 @@ import { Container, Group, MultiSelect, - Pagination, Paper, SegmentedControl, Select, @@ -17,7 +16,6 @@ import { Stack, Kbd, Modal, - Table, Tabs, Text, TextInput, @@ -51,7 +49,7 @@ import { type LicenseStatus, type QueueFilter, } from '@ema-platform/api'; -import { EmptyState, ErrorState } from '@ema-platform/ui'; +import { AdvancedTable, EmptyState, ErrorState, type AdvancedColumn } from '@ema-platform/ui'; import { computeSla } from '../sla'; import { DEFAULT_VIEW, @@ -183,7 +181,6 @@ export function LicenseQueuePage() { const items = active.data?.items ?? []; const total = active.data?.total ?? 0; - const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE)); const updateUrl = useCallback( (next: Partial, nextView: SavedViewId, nextPage: number) => { @@ -287,6 +284,121 @@ export function LicenseQueuePage() { debouncedSearch, ); + const sortableHeader = (label: string, field: NonNullable) => ( + toggleSort(field)}> + {label} + {urlFilter.sortBy === field && sortIcon} + + ); + + const columns: AdvancedColumn[] = useMemo( + () => [ + { + header: ( + 0 && !allSelected} + onChange={() => setSelected(allSelected ? [] : items.map((a) => a.id))} + /> + ), + size: 40, + cell: ({ row }) => ( + + setSelected((prev) => + e.currentTarget.checked + ? [...prev, row.original.id] + : prev.filter((id) => id !== row.original.id), + ) + } + /> + ), + }, + { + header: sortableHeader(t('queue.number', 'App #'), 'applicationNumber'), + cell: ({ row }) => ( + + {row.original.applicationNumber} + + ), + }, + { + header: sortableHeader(t('queue.company', 'Company'), 'companyName'), + cell: ({ row }) => {row.original.companyName ?? '—'}, + }, + { + header: t('queue.tin', 'TIN'), + cell: ({ row }) => ( + + {row.original.tinNumber ?? '—'} + + ), + }, + { + header: t('queue.typeCol', 'Type'), + cell: ({ row }) => {row.original.licenseType?.name?.en ?? '—'}, + }, + { + header: sortableHeader(t('queue.statusCol', 'Status'), 'status'), + cell: ({ row }) => ( + + {STATUS_LABELS[row.original.status]} + + ), + }, + { + header: sortableHeader(t('queue.submitted', 'Submitted'), 'submittedAt'), + cell: ({ row }) => ( + + {row.original.submittedAt + ? new Date(row.original.submittedAt).toLocaleDateString(i18n.language) + : '—'} + + ), + }, + { + header: t('queue.sla', 'Age / SLA'), + cell: ({ row }) => { + const sla = computeSla(row.original); + return ( + // Colour is never the only signal — the label says the same thing. + + + {sla.label} + + + ); + }, + }, + { + header: '', + align: 'right', + size: 140, + cell: ({ row }) => + row.original.assignedOfficerId === null && row.original.status === 'SUBMITTED' ? ( + + ) : ( + + ), + }, + ], + [t, i18n.language, urlFilter.sortBy, sortIcon, selected, allSelected, items, claiming], + ); + return ( @@ -446,77 +558,7 @@ export function LicenseQueuePage() { /> ) : ( <> - - - - - - 0 && !allSelected} - onChange={() => - setSelected(allSelected ? [] : items.map((a) => a.id)) - } - /> - - - - {t('queue.tin', 'TIN')} - {t('queue.typeCol', 'Type')} - - - {t('queue.sla', 'Age / SLA')} - - - - - {items.map((app, index) => ( - - setSelected((prev) => - checked ? [...prev, app.id] : prev.filter((id) => id !== app.id), - ) - } - onClaim={() => handleClaim(app.id)} - onOpen={() => navigate(`/licence-review/${app.id}`)} - /> - ))} - -
-
- - + {t('queue.showing', { from: (page - 1) * PAGE_SIZE + 1, @@ -525,16 +567,30 @@ export function LicenseQueuePage() { defaultValue: 'Showing {{from}}–{{to}} of {{total}}', })} - { - setPage(next); - updateUrl({}, view, next); - }} - total={pageCount} - size="sm" - /> + { + const next = pageIndex + 1; + setPage(next); + updateUrl({}, view, next); + }} + pageSize={PAGE_SIZE} + refresh={() => active.refetch()} + isLoading={active.isFetching} + verticalSpacing={density === 'compact' ? 4 : 'sm'} + rowStyle={(_row, index) => + // Keyboard cursor. A left border rather than a background keeps + // it distinguishable from row selection and from hover. + index === cursor + ? { boxShadow: 'inset 3px 0 0 var(--mantine-color-blue-6)' } + : undefined + } + /> )} @@ -601,122 +657,4 @@ export function LicenseQueuePage() { ); } -function SortableTh({ - label, - field, - current, - icon, - onSort, -}: { - label: string; - field: NonNullable; - current?: QueueFilter['sortBy']; - icon: React.ReactNode; - onSort: (field: NonNullable) => void; -}) { - return ( - - onSort(field)} - > - {label} - {current === field && icon} - - - ); -} - -function QueueRow({ - app, - selected, - focused, - claiming, - locale, - onSelect, - onClaim, - onOpen, -}: { - app: LicenseApplication; - selected: boolean; - focused: boolean; - claiming: boolean; - locale: string; - onSelect: (checked: boolean) => void; - onClaim: () => void; - onOpen: () => void; -}) { - const { t } = useTranslation(); - const sla = computeSla(app); - - return ( - - - onSelect(e.currentTarget.checked)} - /> - - - - {app.applicationNumber} - - - - {app.companyName ?? '—'} - - - - {app.tinNumber ?? '—'} - - - - {app.licenseType?.name?.en ?? '—'} - - - - {STATUS_LABELS[app.status]} - - - - - {app.submittedAt ? new Date(app.submittedAt).toLocaleDateString(locale) : '—'} - - - - {/* Colour is never the only signal — the label says the same thing. */} - - - {sla.label} - - - - - - {app.assignedOfficerId === null && app.status === 'SUBMITTED' ? ( - - ) : ( - - )} - - - - ); -} - export default LicenseQueuePage; diff --git a/libs/ui/src/lib/data/AdvancedTable.tsx b/libs/ui/src/lib/data/AdvancedTable.tsx index a42dbc71e..004f55451 100644 --- a/libs/ui/src/lib/data/AdvancedTable.tsx +++ b/libs/ui/src/lib/data/AdvancedTable.tsx @@ -1,4 +1,4 @@ -import { ReactNode, useState } from "react"; +import { CSSProperties, ReactNode, useState } from "react"; import { Table, Button, @@ -41,6 +41,8 @@ interface AdvancedTableProps { onSearchChange?: (q: string) => void; isLoading?: boolean; emptyText?: string; + verticalSpacing?: string | number; + rowStyle?: (row: T, index: number) => CSSProperties | undefined; } function getByPath(obj: unknown, path?: string): unknown { @@ -67,6 +69,8 @@ export function AdvancedTable({ refresh, isLoading = false, emptyText, + verticalSpacing = "sm", + rowStyle, }: AdvancedTableProps) { const { t } = useTranslation(); const [visible, setVisible] = useState( @@ -145,7 +149,7 @@ export function AdvancedTable({ highlightOnHover withTableBorder withColumnBorders - verticalSpacing="sm" + verticalSpacing={verticalSpacing} > @@ -183,7 +187,7 @@ export function AdvancedTable({ ) : ( data.map((row, rowIndex) => ( - + {shownColumns.map((col, i) => { const value = getByPath(row, col.accessorKey); return (