mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
Refactor LicenseQueuePage to use AdvancedTable for improved rendering and sorting functionality
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user