Files
emaui/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage.tsx
2026-08-02 22:44:08 +03:00

723 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;