mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-30 07:38:12 +00:00
Refactor LicenseQueuePage to use AdvancedTable for improved rendering and sorting functionality
This commit is contained in:
@@ -9,7 +9,6 @@ import {
|
|||||||
Container,
|
Container,
|
||||||
Group,
|
Group,
|
||||||
MultiSelect,
|
MultiSelect,
|
||||||
Pagination,
|
|
||||||
Paper,
|
Paper,
|
||||||
SegmentedControl,
|
SegmentedControl,
|
||||||
Select,
|
Select,
|
||||||
@@ -17,7 +16,6 @@ import {
|
|||||||
Stack,
|
Stack,
|
||||||
Kbd,
|
Kbd,
|
||||||
Modal,
|
Modal,
|
||||||
Table,
|
|
||||||
Tabs,
|
Tabs,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
@@ -51,7 +49,7 @@ import {
|
|||||||
type LicenseStatus,
|
type LicenseStatus,
|
||||||
type QueueFilter,
|
type QueueFilter,
|
||||||
} from '@ema-platform/api';
|
} 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 { computeSla } from '../sla';
|
||||||
import {
|
import {
|
||||||
DEFAULT_VIEW,
|
DEFAULT_VIEW,
|
||||||
@@ -183,7 +181,6 @@ export function LicenseQueuePage() {
|
|||||||
|
|
||||||
const items = active.data?.items ?? [];
|
const items = active.data?.items ?? [];
|
||||||
const total = active.data?.total ?? 0;
|
const total = active.data?.total ?? 0;
|
||||||
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
|
||||||
|
|
||||||
const updateUrl = useCallback(
|
const updateUrl = useCallback(
|
||||||
(next: Partial<QueueFilter>, nextView: SavedViewId, nextPage: number) => {
|
(next: Partial<QueueFilter>, nextView: SavedViewId, nextPage: number) => {
|
||||||
@@ -287,6 +284,121 @@ export function LicenseQueuePage() {
|
|||||||
debouncedSearch,
|
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 (
|
return (
|
||||||
<Container size="xl" py="md" pb={selected.length ? 80 : 'md'}>
|
<Container size="xl" py="md" pb={selected.length ? 80 : 'md'}>
|
||||||
<Group justify="space-between" mb="md">
|
<Group justify="space-between" mb="md">
|
||||||
@@ -446,77 +558,7 @@ export function LicenseQueuePage() {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Table.ScrollContainer minWidth={1100}>
|
<Group justify="flex-end" p="sm" pb={0}>
|
||||||
<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">
|
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{t('queue.showing', {
|
{t('queue.showing', {
|
||||||
from: (page - 1) * PAGE_SIZE + 1,
|
from: (page - 1) * PAGE_SIZE + 1,
|
||||||
@@ -525,16 +567,30 @@ export function LicenseQueuePage() {
|
|||||||
defaultValue: 'Showing {{from}}–{{to}} of {{total}}',
|
defaultValue: 'Showing {{from}}–{{to}} of {{total}}',
|
||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
<Pagination
|
|
||||||
value={page}
|
|
||||||
onChange={(next) => {
|
|
||||||
setPage(next);
|
|
||||||
updateUrl({}, view, next);
|
|
||||||
}}
|
|
||||||
total={pageCount}
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
</Group>
|
</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>
|
</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;
|
export default LicenseQueuePage;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ReactNode, useState } from "react";
|
import { CSSProperties, ReactNode, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
Button,
|
Button,
|
||||||
@@ -41,6 +41,8 @@ interface AdvancedTableProps<T> {
|
|||||||
onSearchChange?: (q: string) => void;
|
onSearchChange?: (q: string) => void;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
emptyText?: string;
|
emptyText?: string;
|
||||||
|
verticalSpacing?: string | number;
|
||||||
|
rowStyle?: (row: T, index: number) => CSSProperties | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getByPath(obj: unknown, path?: string): unknown {
|
function getByPath(obj: unknown, path?: string): unknown {
|
||||||
@@ -67,6 +69,8 @@ export function AdvancedTable<T extends { id?: string | number }>({
|
|||||||
refresh,
|
refresh,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
emptyText,
|
emptyText,
|
||||||
|
verticalSpacing = "sm",
|
||||||
|
rowStyle,
|
||||||
}: AdvancedTableProps<T>) {
|
}: AdvancedTableProps<T>) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [visible, setVisible] = useState<boolean[]>(
|
const [visible, setVisible] = useState<boolean[]>(
|
||||||
@@ -145,7 +149,7 @@ export function AdvancedTable<T extends { id?: string | number }>({
|
|||||||
highlightOnHover
|
highlightOnHover
|
||||||
withTableBorder
|
withTableBorder
|
||||||
withColumnBorders
|
withColumnBorders
|
||||||
verticalSpacing="sm"
|
verticalSpacing={verticalSpacing}
|
||||||
>
|
>
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
@@ -183,7 +187,7 @@ export function AdvancedTable<T extends { id?: string | number }>({
|
|||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
) : (
|
) : (
|
||||||
data.map((row, rowIndex) => (
|
data.map((row, rowIndex) => (
|
||||||
<Table.Tr key={row.id ?? rowIndex}>
|
<Table.Tr key={row.id ?? rowIndex} style={rowStyle?.(row, rowIndex)}>
|
||||||
{shownColumns.map((col, i) => {
|
{shownColumns.map((col, i) => {
|
||||||
const value = getByPath(row, col.accessorKey);
|
const value = getByPath(row, col.accessorKey);
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user