mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 13:02:50 +00:00
723 lines
23 KiB
TypeScript
723 lines
23 KiB
TypeScript
import { useCallback, useMemo, useState } from 'react';
|
||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||
import {
|
||
ActionIcon,
|
||
Badge,
|
||
Button,
|
||
Card,
|
||
Checkbox,
|
||
Container,
|
||
Group,
|
||
MultiSelect,
|
||
Pagination,
|
||
Paper,
|
||
SegmentedControl,
|
||
Select,
|
||
Skeleton,
|
||
Stack,
|
||
Kbd,
|
||
Modal,
|
||
Table,
|
||
Tabs,
|
||
Text,
|
||
TextInput,
|
||
Title,
|
||
Tooltip,
|
||
} from '@mantine/core';
|
||
import { useDebouncedValue } from '@mantine/hooks';
|
||
import {
|
||
IconAlertCircle,
|
||
IconDownload,
|
||
IconRefresh,
|
||
IconSearch,
|
||
IconSortAscending,
|
||
IconSortDescending,
|
||
IconX,
|
||
} from '@tabler/icons-react';
|
||
import { notifications } from '@mantine/notifications';
|
||
import { useTranslation } from 'react-i18next';
|
||
import {
|
||
STATUS_COLORS,
|
||
STATUS_LABELS,
|
||
extractErrorMessage,
|
||
useClaimApplicationMutation,
|
||
useGetAllApplicationsQuery,
|
||
useGetAssignedToMeQuery,
|
||
useGetLicenseTypesQuery,
|
||
useGetQueueCountsQuery,
|
||
useGetQueueQuery,
|
||
useLazyExportApplicationsQuery,
|
||
type LicenseApplication,
|
||
type LicenseStatus,
|
||
type QueueFilter,
|
||
} from '@ema-platform/api';
|
||
import { EmptyState, ErrorState } from '@ema-platform/ui';
|
||
import { computeSla } from '../sla';
|
||
import {
|
||
DEFAULT_VIEW,
|
||
SAVED_VIEWS,
|
||
filterFromSearchParams,
|
||
readLastView,
|
||
searchParamsFromFilter,
|
||
writeLastView,
|
||
type SavedViewId,
|
||
} from '../queue-views';
|
||
import { exportApplicationsCsv } from '../export';
|
||
import { setDensity } from '../../../store/preferences.slice';
|
||
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
|
||
import { KEYBOARD_SHORTCUTS, useQueueKeyboard } from '../useQueueKeyboard';
|
||
|
||
const PAGE_SIZE = 25;
|
||
const SEARCH_DEBOUNCE_MS = 300;
|
||
|
||
const ALL_STATUSES: LicenseStatus[] = [
|
||
'SUBMITTED',
|
||
'UNDER_REVIEW',
|
||
'UNDER_EVALUATION',
|
||
'RESUBMIT_REQUIRED',
|
||
'INSPECTION_PENDING',
|
||
'INSPECTION_COMPLETED',
|
||
'ON_HOLD',
|
||
'APPROVED',
|
||
'PAYMENT_PENDING',
|
||
'PAID',
|
||
'PAYMENT_CONFIRMED',
|
||
'CERTIFICATE_ISSUED',
|
||
'COMPLETED',
|
||
'REJECTED',
|
||
];
|
||
|
||
/**
|
||
* The officer work pool.
|
||
*
|
||
* Saved views across the top, facets serialised into the URL so a filtered
|
||
* queue can be shared, and server-side pagination — the previous version
|
||
* rendered `data.items` unpaged, which was fine at demo volumes and would have
|
||
* stopped being fine somewhere in the hundreds.
|
||
*/
|
||
export function LicenseQueuePage() {
|
||
const { t, i18n } = useTranslation();
|
||
const navigate = useNavigate();
|
||
const { typeCode } = useParams();
|
||
const [searchParams, setSearchParams] = useSearchParams();
|
||
const dispatch = useAppDispatch();
|
||
const density = useAppSelector((state) => state.preferences.density);
|
||
|
||
const [view, setView] = useState<SavedViewId>(
|
||
() => (searchParams.get('view') as SavedViewId) || readLastView(),
|
||
);
|
||
const [page, setPage] = useState(() => Number(searchParams.get('page')) || 1);
|
||
const [selected, setSelected] = useState<string[]>([]);
|
||
const [searchInput, setSearchInput] = useState(searchParams.get('q') ?? '');
|
||
const [cursor, setCursor] = useState(0);
|
||
const [helpOpen, setHelpOpen] = useState(false);
|
||
const [debouncedSearch] = useDebouncedValue(searchInput, SEARCH_DEBOUNCE_MS);
|
||
|
||
const urlFilter = useMemo(
|
||
() => filterFromSearchParams(searchParams),
|
||
[searchParams],
|
||
);
|
||
const activeView = SAVED_VIEWS.find((v) => v.id === view) ?? SAVED_VIEWS[0];
|
||
|
||
const { data: licenseTypes } = useGetLicenseTypesQuery();
|
||
const { data: counts } = useGetQueueCountsQuery();
|
||
|
||
// A `/licence-review/type/:typeCode` deep link pins the type facet.
|
||
const pinnedTypeId = useMemo(() => {
|
||
if (!typeCode) return undefined;
|
||
return licenseTypes?.items?.find((type) => type.key === typeCode)?.id;
|
||
}, [typeCode, licenseTypes]);
|
||
|
||
const filter: QueueFilter = useMemo(
|
||
() => ({
|
||
...activeView.filter,
|
||
...urlFilter,
|
||
search: debouncedSearch || undefined,
|
||
licenseTypeId: pinnedTypeId ?? urlFilter.licenseTypeId,
|
||
take: PAGE_SIZE,
|
||
skip: (page - 1) * PAGE_SIZE,
|
||
}),
|
||
[activeView, urlFilter, debouncedSearch, pinnedTypeId, page],
|
||
);
|
||
|
||
// One query per source; the two inactive ones are skipped, so switching
|
||
// views costs a single request rather than keeping three in flight.
|
||
const queueQuery = useGetQueueQuery(filter, { skip: activeView.source !== 'queue' });
|
||
const mineQuery = useGetAssignedToMeQuery(filter, { skip: activeView.source !== 'mine' });
|
||
const allQuery = useGetAllApplicationsQuery(filter, { skip: activeView.source !== 'all' });
|
||
const active =
|
||
activeView.source === 'queue' ? queueQuery : activeView.source === 'mine' ? mineQuery : allQuery;
|
||
|
||
const [claim, { isLoading: claiming }] = useClaimApplicationMutation();
|
||
const [runExport, { isFetching: exporting }] = useLazyExportApplicationsQuery();
|
||
|
||
/**
|
||
* Exports every row the filter matches, not just the page on screen.
|
||
* The server caps the result set and reports when it did, so a truncated
|
||
* export says so instead of quietly being wrong.
|
||
*/
|
||
async function handleExport() {
|
||
try {
|
||
const result = await runExport({ ...filter, take: undefined, skip: undefined }).unwrap();
|
||
exportApplicationsCsv(result.items, i18n.language);
|
||
if (result.truncated) {
|
||
notifications.show({
|
||
color: 'yellow',
|
||
title: t('queue.exportTruncated', 'Export truncated'),
|
||
message: t('queue.exportTruncatedBody', {
|
||
exported: result.items.length,
|
||
total: result.total,
|
||
defaultValue:
|
||
'Exported the first {{exported}} of {{total}} rows. Narrow the filter for the rest.',
|
||
}),
|
||
});
|
||
}
|
||
} catch (err) {
|
||
notifications.show({
|
||
color: 'red',
|
||
title: t('queue.exportFailed', 'Export failed'),
|
||
message: extractErrorMessage(err),
|
||
});
|
||
}
|
||
}
|
||
|
||
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) => {
|
||
setSearchParams(
|
||
searchParamsFromFilter({ ...urlFilter, ...next }, nextView, nextPage),
|
||
{ replace: true },
|
||
);
|
||
},
|
||
[urlFilter, setSearchParams],
|
||
);
|
||
|
||
const changeView = (next: SavedViewId) => {
|
||
setView(next);
|
||
writeLastView(next);
|
||
setPage(1);
|
||
setSelected([]);
|
||
updateUrl({}, next, 1);
|
||
};
|
||
|
||
const setFacet = (next: Partial<QueueFilter>) => {
|
||
setPage(1);
|
||
updateUrl(next, view, 1);
|
||
};
|
||
|
||
const toggleSort = (field: NonNullable<QueueFilter['sortBy']>) => {
|
||
const dir =
|
||
urlFilter.sortBy === field && urlFilter.sortDir !== 'DESC' ? 'DESC' : 'ASC';
|
||
setFacet({ sortBy: field, sortDir: dir });
|
||
};
|
||
|
||
async function handleClaim(id: string) {
|
||
try {
|
||
await claim(id).unwrap();
|
||
notifications.show({
|
||
color: 'teal',
|
||
title: t('queue.claimed', 'Claimed'),
|
||
message: t('queue.claimedBody', 'The application is now assigned to you.'),
|
||
});
|
||
changeView('mine');
|
||
} catch (err) {
|
||
// A 409 means another officer got there first — refresh so the queue
|
||
// stops showing work that is no longer available.
|
||
notifications.show({
|
||
color: 'red',
|
||
title: t('queue.claimFailed', 'Could not claim'),
|
||
message: extractErrorMessage(
|
||
err,
|
||
t('queue.claimRace', 'Another officer already claimed it.'),
|
||
),
|
||
});
|
||
active.refetch();
|
||
}
|
||
}
|
||
|
||
async function handleBulkClaim() {
|
||
const results = await Promise.allSettled(
|
||
selected.map((id) => claim(id).unwrap()),
|
||
);
|
||
const claimed = results.filter((r) => r.status === 'fulfilled').length;
|
||
const lost = results.length - claimed;
|
||
notifications.show({
|
||
color: lost ? 'yellow' : 'teal',
|
||
title: t('queue.bulkClaimed', { count: claimed, defaultValue: '{{count}} claimed' }),
|
||
// Partial success is the normal case in a shared queue, so it is
|
||
// reported rather than swallowed or treated as total failure.
|
||
message: lost
|
||
? t('queue.bulkClaimPartial', {
|
||
count: lost,
|
||
defaultValue: '{{count}} were already taken by another officer.',
|
||
})
|
||
: '',
|
||
});
|
||
setSelected([]);
|
||
active.refetch();
|
||
}
|
||
|
||
const cursorRow = items[cursor];
|
||
useQueueKeyboard({
|
||
enabled: !helpOpen,
|
||
onNext: () => setCursor((c) => Math.min(c + 1, Math.max(items.length - 1, 0))),
|
||
onPrevious: () => setCursor((c) => Math.max(c - 1, 0)),
|
||
onOpen: () => cursorRow && navigate(`/licence-review/${cursorRow.id}`),
|
||
onClaim: () => {
|
||
// Only unclaimed rows can be claimed; pressing c elsewhere is a no-op
|
||
// rather than an error the officer has to read.
|
||
if (cursorRow && cursorRow.assignedOfficerId === null) handleClaim(cursorRow.id);
|
||
},
|
||
onEscape: () => setSelected([]),
|
||
onHelp: () => setHelpOpen(true),
|
||
});
|
||
|
||
const allSelected = items.length > 0 && selected.length === items.length;
|
||
const sortIcon =
|
||
urlFilter.sortDir === 'DESC' ? <IconSortDescending size={13} /> : <IconSortAscending size={13} />;
|
||
|
||
const hasFacets = Boolean(
|
||
urlFilter.status?.length ||
|
||
urlFilter.licenseTypeId ||
|
||
urlFilter.assignee ||
|
||
urlFilter.submittedFrom ||
|
||
debouncedSearch,
|
||
);
|
||
|
||
return (
|
||
<Container size="xl" py="md" pb={selected.length ? 80 : 'md'}>
|
||
<Group justify="space-between" mb="md">
|
||
<div>
|
||
<Title order={3}>{t('queue.title', 'Licence applications')}</Title>
|
||
{typeCode && (
|
||
<Text size="sm" c="dimmed">
|
||
{t(`nav.type${typeCode}`, { defaultValue: typeCode })}
|
||
</Text>
|
||
)}
|
||
</div>
|
||
<Group gap="xs">
|
||
<Tooltip label={t('queue.refresh', 'Refresh')}>
|
||
<ActionIcon variant="default" size="lg" onClick={() => active.refetch()}>
|
||
<IconRefresh size={18} />
|
||
</ActionIcon>
|
||
</Tooltip>
|
||
<SegmentedControl
|
||
size="xs"
|
||
value={density}
|
||
onChange={(v) => dispatch(setDensity(v as 'comfortable' | 'compact'))}
|
||
data={[
|
||
{ label: t('queue.comfortable', 'Comfortable'), value: 'comfortable' },
|
||
{ label: t('queue.compact', 'Compact'), value: 'compact' },
|
||
]}
|
||
/>
|
||
<Button
|
||
variant="default"
|
||
leftSection={<IconDownload size={16} />}
|
||
onClick={handleExport}
|
||
loading={exporting}
|
||
disabled={total === 0}
|
||
>
|
||
{t('queue.export', 'Export CSV')}
|
||
</Button>
|
||
</Group>
|
||
</Group>
|
||
|
||
{/* Saved views, counted. */}
|
||
<Tabs value={view} onChange={(v) => changeView((v as SavedViewId) ?? DEFAULT_VIEW)} mb="sm">
|
||
<Tabs.List>
|
||
{SAVED_VIEWS.map((savedView) => (
|
||
<Tabs.Tab
|
||
key={savedView.id}
|
||
value={savedView.id}
|
||
rightSection={
|
||
counts?.[savedView.countKey] ? (
|
||
<Badge size="xs" variant="light" circle>
|
||
{counts[savedView.countKey]}
|
||
</Badge>
|
||
) : undefined
|
||
}
|
||
>
|
||
{t(savedView.labelKey)}
|
||
</Tabs.Tab>
|
||
))}
|
||
</Tabs.List>
|
||
</Tabs>
|
||
|
||
{/* Facets — every one of these is reflected in the URL. */}
|
||
<Paper withBorder p="sm" mb="sm">
|
||
<Group gap="sm" align="flex-end" wrap="wrap">
|
||
<TextInput
|
||
label={t('queue.search', 'Search')}
|
||
placeholder={t('queue.searchPlaceholder', 'Company, TIN or number')}
|
||
leftSection={<IconSearch size={14} />}
|
||
value={searchInput}
|
||
onChange={(e) => setSearchInput(e.currentTarget.value)}
|
||
w={240}
|
||
/>
|
||
<MultiSelect
|
||
label={t('queue.status', 'Status')}
|
||
placeholder={t('queue.anyStatus', 'Any')}
|
||
data={ALL_STATUSES.map((s) => ({ value: s, label: STATUS_LABELS[s] }))}
|
||
value={urlFilter.status ?? []}
|
||
onChange={(v) => setFacet({ status: v as LicenseStatus[] })}
|
||
clearable
|
||
w={240}
|
||
/>
|
||
{!typeCode && (
|
||
<Select
|
||
label={t('queue.type', 'Licence type')}
|
||
placeholder={t('queue.anyType', 'Any')}
|
||
data={(licenseTypes?.items ?? []).map((type) => ({
|
||
value: type.id,
|
||
label: type.name.en ?? type.key,
|
||
}))}
|
||
value={urlFilter.licenseTypeId ?? null}
|
||
onChange={(v) => setFacet({ licenseTypeId: v ?? undefined })}
|
||
clearable
|
||
w={220}
|
||
/>
|
||
)}
|
||
<TextInput
|
||
type="date"
|
||
label={t('queue.submittedFrom', 'Submitted from')}
|
||
value={urlFilter.submittedFrom ?? ''}
|
||
onChange={(e) => setFacet({ submittedFrom: e.currentTarget.value || undefined })}
|
||
/>
|
||
<TextInput
|
||
type="date"
|
||
label={t('queue.submittedTo', 'Submitted to')}
|
||
value={urlFilter.submittedTo ?? ''}
|
||
onChange={(e) => setFacet({ submittedTo: e.currentTarget.value || undefined })}
|
||
/>
|
||
{hasFacets && (
|
||
<Button
|
||
variant="subtle"
|
||
leftSection={<IconX size={14} />}
|
||
onClick={() => {
|
||
setSearchInput('');
|
||
setSearchParams(new URLSearchParams(), { replace: true });
|
||
}}
|
||
>
|
||
{t('queue.clearFilters', 'Clear')}
|
||
</Button>
|
||
)}
|
||
</Group>
|
||
</Paper>
|
||
|
||
<Card withBorder padding={0}>
|
||
{active.isLoading ? (
|
||
// Skeleton rows match the real table, so the layout does not jump
|
||
// when data lands.
|
||
<Stack gap={0} p="md">
|
||
{Array.from({ length: 6 }).map((_, i) => (
|
||
<Skeleton key={i} height={44} mb="xs" radius="sm" />
|
||
))}
|
||
</Stack>
|
||
) : active.isError ? (
|
||
<ErrorState
|
||
title={t('queue.errorTitle', 'Could not load the queue')}
|
||
description={extractErrorMessage(active.error)}
|
||
onRetry={() => active.refetch()}
|
||
icon={IconAlertCircle}
|
||
/>
|
||
) : items.length === 0 ? (
|
||
<EmptyState
|
||
title={
|
||
hasFacets
|
||
? t('queue.emptyFiltered', 'No applications match these filters')
|
||
: t('queue.empty', 'Nothing waiting here')
|
||
}
|
||
description={
|
||
hasFacets
|
||
? t('queue.emptyFilteredBody', 'Try widening or clearing the filters.')
|
||
: t('queue.emptyBody', 'New applications will appear here as they are submitted.')
|
||
}
|
||
action={
|
||
hasFacets
|
||
? {
|
||
label: t('queue.clearFilters', 'Clear'),
|
||
onClick: () => setSearchParams(new URLSearchParams(), { replace: true }),
|
||
}
|
||
: undefined
|
||
}
|
||
/>
|
||
) : (
|
||
<>
|
||
<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">
|
||
<Text size="sm" c="dimmed">
|
||
{t('queue.showing', {
|
||
from: (page - 1) * PAGE_SIZE + 1,
|
||
to: Math.min(page * PAGE_SIZE, total),
|
||
total,
|
||
defaultValue: 'Showing {{from}}–{{to}} of {{total}}',
|
||
})}
|
||
</Text>
|
||
<Pagination
|
||
value={page}
|
||
onChange={(next) => {
|
||
setPage(next);
|
||
updateUrl({}, view, next);
|
||
}}
|
||
total={pageCount}
|
||
size="sm"
|
||
/>
|
||
</Group>
|
||
</>
|
||
)}
|
||
</Card>
|
||
|
||
<Modal
|
||
opened={helpOpen}
|
||
onClose={() => setHelpOpen(false)}
|
||
title={t('shortcuts.title', 'Keyboard shortcuts')}
|
||
size="sm"
|
||
>
|
||
<Stack gap="xs">
|
||
{KEYBOARD_SHORTCUTS.map((shortcut) => (
|
||
<Group key={shortcut.keys} justify="space-between">
|
||
<Text size="sm">{t(shortcut.labelKey)}</Text>
|
||
<Kbd>{shortcut.keys}</Kbd>
|
||
</Group>
|
||
))}
|
||
</Stack>
|
||
</Modal>
|
||
|
||
{/* Bulk bar. Floating, with the count stated so the scope of the action
|
||
is never ambiguous. */}
|
||
{selected.length > 0 && (
|
||
<Paper
|
||
withBorder
|
||
shadow="md"
|
||
p="sm"
|
||
style={{ position: 'sticky', bottom: 16, zIndex: 50 }}
|
||
>
|
||
<Group justify="space-between">
|
||
<Text size="sm" fw={500}>
|
||
{t('queue.selectedCount', {
|
||
count: selected.length,
|
||
defaultValue: '{{count}} selected',
|
||
})}
|
||
</Text>
|
||
<Group gap="xs">
|
||
<Button variant="subtle" onClick={() => setSelected([])}>
|
||
{t('common.cancel', 'Cancel')}
|
||
</Button>
|
||
<Button
|
||
variant="default"
|
||
leftSection={<IconDownload size={16} />}
|
||
onClick={() =>
|
||
exportApplicationsCsv(
|
||
items.filter((a) => selected.includes(a.id)),
|
||
i18n.language,
|
||
)
|
||
}
|
||
>
|
||
{t('queue.export', 'Export CSV')}
|
||
</Button>
|
||
<Button loading={claiming} onClick={handleBulkClaim}>
|
||
{t('queue.bulkClaim', {
|
||
count: selected.length,
|
||
defaultValue: 'Claim {{count}}',
|
||
})}
|
||
</Button>
|
||
</Group>
|
||
</Group>
|
||
</Paper>
|
||
)}
|
||
</Container>
|
||
);
|
||
}
|
||
|
||
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;
|