Refactor LicenseQueuePage to use AdvancedTable for improved rendering and sorting functionality

This commit is contained in:
estifanos
2026-08-03 08:33:05 +00:00
parent cd1a891d07
commit 52e2f4c009
2 changed files with 147 additions and 205 deletions

View File

@@ -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<QueueFilter>, nextView: SavedViewId, nextPage: number) => {
@@ -287,6 +284,121 @@ export function LicenseQueuePage() {
debouncedSearch,
);
const sortableHeader = (label: string, field: NonNullable<QueueFilter['sortBy']>) => (
<Group gap={4} wrap="nowrap" style={{ cursor: 'pointer' }} onClick={() => toggleSort(field)}>
<span>{label}</span>
{urlFilter.sortBy === field && sortIcon}
</Group>
);
const columns: AdvancedColumn<LicenseApplication>[] = useMemo(
() => [
{
header: (
<Checkbox
aria-label={t('queue.selectAll', 'Select all')}
checked={allSelected}
indeterminate={selected.length > 0 && !allSelected}
onChange={() => setSelected(allSelected ? [] : items.map((a) => a.id))}
/>
),
size: 40,
cell: ({ row }) => (
<Checkbox
aria-label={t('queue.selectRow', {
number: row.original.applicationNumber,
defaultValue: 'Select {{number}}',
})}
checked={selected.includes(row.original.id)}
onChange={(e) =>
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 }) => (
<Text size="sm" fw={500}>
{row.original.applicationNumber}
</Text>
),
},
{
header: sortableHeader(t('queue.company', 'Company'), 'companyName'),
cell: ({ row }) => <Text size="sm">{row.original.companyName ?? '—'}</Text>,
},
{
header: t('queue.tin', 'TIN'),
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{row.original.tinNumber ?? '—'}
</Text>
),
},
{
header: t('queue.typeCol', 'Type'),
cell: ({ row }) => <Text size="sm">{row.original.licenseType?.name?.en ?? '—'}</Text>,
},
{
header: sortableHeader(t('queue.statusCol', 'Status'), 'status'),
cell: ({ row }) => (
<Badge color={STATUS_COLORS[row.original.status]} variant="light">
{STATUS_LABELS[row.original.status]}
</Badge>
),
},
{
header: sortableHeader(t('queue.submitted', 'Submitted'), 'submittedAt'),
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{row.original.submittedAt
? new Date(row.original.submittedAt).toLocaleDateString(i18n.language)
: '—'}
</Text>
),
},
{
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.
<Tooltip label={sla.tooltip} withArrow>
<Badge color={sla.color} variant="light" size="sm">
{sla.label}
</Badge>
</Tooltip>
);
},
},
{
header: '',
align: 'right',
size: 140,
cell: ({ row }) =>
row.original.assignedOfficerId === null && row.original.status === 'SUBMITTED' ? (
<Button size="xs" loading={claiming} onClick={() => handleClaim(row.original.id)}>
{t('queue.claim', 'Claim')}
</Button>
) : (
<Button
size="xs"
variant="light"
onClick={() => navigate(`/licence-review/${row.original.id}`)}
>
{t('queue.review', 'Review')}
</Button>
),
},
],
[t, i18n.language, urlFilter.sortBy, sortIcon, selected, allSelected, items, claiming],
);
return (
<Container size="xl" py="md" pb={selected.length ? 80 : 'md'}>
<Group justify="space-between" mb="md">
@@ -446,77 +558,7 @@ export function LicenseQueuePage() {
/>
) : (
<>
<Table.ScrollContainer minWidth={1100}>
<Table highlightOnHover verticalSpacing={density === "compact" ? 4 : "sm"}>
<Table.Thead>
<Table.Tr>
<Table.Th w={40}>
<Checkbox
aria-label={t('queue.selectAll', 'Select all')}
checked={allSelected}
indeterminate={selected.length > 0 && !allSelected}
onChange={() =>
setSelected(allSelected ? [] : items.map((a) => a.id))
}
/>
</Table.Th>
<SortableTh
label={t('queue.number', 'App #')}
field="applicationNumber"
current={urlFilter.sortBy}
icon={sortIcon}
onSort={toggleSort}
/>
<SortableTh
label={t('queue.company', 'Company')}
field="companyName"
current={urlFilter.sortBy}
icon={sortIcon}
onSort={toggleSort}
/>
<Table.Th>{t('queue.tin', 'TIN')}</Table.Th>
<Table.Th>{t('queue.typeCol', 'Type')}</Table.Th>
<SortableTh
label={t('queue.statusCol', 'Status')}
field="status"
current={urlFilter.sortBy}
icon={sortIcon}
onSort={toggleSort}
/>
<SortableTh
label={t('queue.submitted', 'Submitted')}
field="submittedAt"
current={urlFilter.sortBy}
icon={sortIcon}
onSort={toggleSort}
/>
<Table.Th>{t('queue.sla', 'Age / SLA')}</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{items.map((app, index) => (
<QueueRow
key={app.id}
app={app}
focused={index === cursor}
selected={selected.includes(app.id)}
claiming={claiming}
locale={i18n.language}
onSelect={(checked) =>
setSelected((prev) =>
checked ? [...prev, app.id] : prev.filter((id) => id !== app.id),
)
}
onClaim={() => handleClaim(app.id)}
onOpen={() => navigate(`/licence-review/${app.id}`)}
/>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
<Group justify="space-between" p="sm">
<Group justify="flex-end" p="sm" pb={0}>
<Text size="sm" c="dimmed">
{t('queue.showing', {
from: (page - 1) * PAGE_SIZE + 1,
@@ -525,16 +567,30 @@ export function LicenseQueuePage() {
defaultValue: 'Showing {{from}}{{to}} of {{total}}',
})}
</Text>
<Pagination
value={page}
onChange={(next) => {
setPage(next);
updateUrl({}, view, next);
}}
total={pageCount}
size="sm"
/>
</Group>
<AdvancedTable
columns={columns}
data={items}
tableName={t('queue.title', 'Licence applications')}
itemCount={total}
pageIndex={page - 1}
onPageChange={(pageIndex) => {
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
}
/>
</>
)}
</Card>
@@ -601,122 +657,4 @@ export function LicenseQueuePage() {
);
}
function SortableTh({
label,
field,
current,
icon,
onSort,
}: {
label: string;
field: NonNullable<QueueFilter['sortBy']>;
current?: QueueFilter['sortBy'];
icon: React.ReactNode;
onSort: (field: NonNullable<QueueFilter['sortBy']>) => void;
}) {
return (
<Table.Th>
<Group
gap={4}
wrap="nowrap"
style={{ cursor: 'pointer' }}
onClick={() => onSort(field)}
>
<span>{label}</span>
{current === field && icon}
</Group>
</Table.Th>
);
}
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 (
<Table.Tr
// Keyboard cursor. Marked with a left border rather than a background so
// it stays distinguishable from row selection and from hover.
style={
focused
? { boxShadow: 'inset 3px 0 0 var(--mantine-color-blue-6)' }
: undefined
}
>
<Table.Td>
<Checkbox
aria-label={t('queue.selectRow', { number: app.applicationNumber, defaultValue: 'Select {{number}}' })}
checked={selected}
onChange={(e) => onSelect(e.currentTarget.checked)}
/>
</Table.Td>
<Table.Td>
<Text size="sm" fw={500}>
{app.applicationNumber}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{app.companyName ?? '—'}</Text>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{app.tinNumber ?? '—'}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{app.licenseType?.name?.en ?? '—'}</Text>
</Table.Td>
<Table.Td>
<Badge color={STATUS_COLORS[app.status]} variant="light">
{STATUS_LABELS[app.status]}
</Badge>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{app.submittedAt ? new Date(app.submittedAt).toLocaleDateString(locale) : '—'}
</Text>
</Table.Td>
<Table.Td>
{/* Colour is never the only signal — the label says the same thing. */}
<Tooltip label={sla.tooltip} withArrow>
<Badge color={sla.color} variant="light" size="sm">
{sla.label}
</Badge>
</Tooltip>
</Table.Td>
<Table.Td align="right">
<Group gap="xs" justify="flex-end" wrap="nowrap">
{app.assignedOfficerId === null && app.status === 'SUBMITTED' ? (
<Button size="xs" loading={claiming} onClick={onClaim}>
{t('queue.claim', 'Claim')}
</Button>
) : (
<Button size="xs" variant="light" onClick={onOpen}>
{t('queue.review', 'Review')}
</Button>
)}
</Group>
</Table.Td>
</Table.Tr>
);
}
export default LicenseQueuePage;

View File

@@ -1,4 +1,4 @@
import { ReactNode, useState } from "react";
import { CSSProperties, ReactNode, useState } from "react";
import {
Table,
Button,
@@ -41,6 +41,8 @@ interface AdvancedTableProps<T> {
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<T extends { id?: string | number }>({
refresh,
isLoading = false,
emptyText,
verticalSpacing = "sm",
rowStyle,
}: AdvancedTableProps<T>) {
const { t } = useTranslation();
const [visible, setVisible] = useState<boolean[]>(
@@ -145,7 +149,7 @@ export function AdvancedTable<T extends { id?: string | number }>({
highlightOnHover
withTableBorder
withColumnBorders
verticalSpacing="sm"
verticalSpacing={verticalSpacing}
>
<Table.Thead>
<Table.Tr>
@@ -183,7 +187,7 @@ export function AdvancedTable<T extends { id?: string | number }>({
</Table.Tr>
) : (
data.map((row, rowIndex) => (
<Table.Tr key={row.id ?? rowIndex}>
<Table.Tr key={row.id ?? rowIndex} style={rowStyle?.(row, rowIndex)}>
{shownColumns.map((col, i) => {
const value = getByPath(row, col.accessorKey);
return (