mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 13:02:50 +00:00
768 lines
24 KiB
TypeScript
768 lines
24 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from "react";
|
||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||
import {
|
||
Badge,
|
||
Button,
|
||
Card,
|
||
Container,
|
||
Group,
|
||
MultiSelect,
|
||
Paper,
|
||
SegmentedControl,
|
||
Select,
|
||
Skeleton,
|
||
Stack,
|
||
Kbd,
|
||
Modal,
|
||
Tabs,
|
||
Text,
|
||
TextInput,
|
||
} from "@mantine/core";
|
||
import { useDebouncedValue } from "@mantine/hooks";
|
||
import {
|
||
IconAlertCircle,
|
||
IconDownload,
|
||
IconSearch,
|
||
IconSortAscending,
|
||
IconSortDescending,
|
||
IconX,
|
||
} from "@tabler/icons-react";
|
||
import { notifications } from "@mantine/notifications";
|
||
import { useTranslation } from "react-i18next";
|
||
import {
|
||
STATUS_LABELS,
|
||
extractErrorMessage,
|
||
familyLabels,
|
||
localized,
|
||
resolveFamilyKind,
|
||
useAssignReviewerMutation,
|
||
useGetAllApplicationsQuery,
|
||
useGetAssignedToMeQuery,
|
||
useGetLicenseTypesQuery,
|
||
useGetQueueCountsQuery,
|
||
useGetQueueQuery,
|
||
useLazyExportApplicationsQuery,
|
||
type LicenseApplication,
|
||
type LicenseStatus,
|
||
type LicenseType,
|
||
type QueueFilter,
|
||
} from "@ema-platform/api";
|
||
import {
|
||
AdvancedTable,
|
||
EmptyState,
|
||
ErrorState,
|
||
AmharicDatePicker,
|
||
type AdvancedColumn,
|
||
} from "@ema-platform/ui";
|
||
import {
|
||
DEFAULT_VIEW,
|
||
SAVED_VIEWS,
|
||
filterFromSearchParams,
|
||
readLastView,
|
||
savedViewsForFamily,
|
||
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";
|
||
import { licenseQueueColumns } from "./columns";
|
||
import { AssignDialog } from "../../components/AssignDialog";
|
||
import { licenseQueueActionsColumn } from "./actions";
|
||
import { PageHeader } from '@ema-platform/ui';
|
||
|
||
const PAGE_SIZE = 10;
|
||
const SEARCH_DEBOUNCE_MS = 300;
|
||
|
||
/**
|
||
* Statuses only the STANDARD course can reach. A registration goes straight
|
||
* review → approval, so offering these in its facet would be offering filters
|
||
* that can only ever match nothing.
|
||
*/
|
||
const STANDARD_ONLY_STATUSES: LicenseStatus[] = [
|
||
"UNDER_EVALUATION",
|
||
"INSPECTION_PENDING",
|
||
"INSPECTION_COMPLETED",
|
||
];
|
||
|
||
/** Statuses only an examined cert (CoC, or a CoP with requiresExamination) reaches. */
|
||
const EXAM_ONLY_STATUSES: LicenseStatus[] = [
|
||
"ELIGIBILITY_PAYMENT_PENDING",
|
||
"ELIGIBILITY_PAID",
|
||
"EXAM_PAYMENT_PENDING",
|
||
"EXAM_PAID",
|
||
"EXAM_SCHEDULED",
|
||
"EXAM_PASSED",
|
||
"EXAM_FAILED",
|
||
];
|
||
|
||
const ALL_STATUSES: LicenseStatus[] = [
|
||
"SUBMITTED",
|
||
"UNDER_REVIEW",
|
||
"UNDER_EVALUATION",
|
||
"RESUBMIT_REQUIRED",
|
||
"INSPECTION_PENDING",
|
||
"INSPECTION_COMPLETED",
|
||
"ON_HOLD",
|
||
"APPROVED",
|
||
...EXAM_ONLY_STATUSES,
|
||
"PAYMENT_PENDING",
|
||
"PAID",
|
||
"PAYMENT_CONFIRMED",
|
||
"SCHEDULED",
|
||
"CERTIFICATE_ISSUED",
|
||
"COMPLETED",
|
||
"REJECTED",
|
||
];
|
||
|
||
/** Statuses an application of this type can actually occupy. */
|
||
function statusesFor(type: LicenseType | undefined): LicenseStatus[] {
|
||
if (!type) return ALL_STATUSES;
|
||
return ALL_STATUSES.filter((status) => {
|
||
if (
|
||
type.workflowProfile === "REGISTRATION" &&
|
||
STANDARD_ONLY_STATUSES.includes(status)
|
||
) {
|
||
return false;
|
||
}
|
||
if (status === "INSPECTION_PENDING" || status === "INSPECTION_COMPLETED") {
|
||
return type.inspectionRequired;
|
||
}
|
||
if (EXAM_ONLY_STATUSES.includes(status)) return Boolean(type.requiresExamination);
|
||
if (status === "CERTIFICATE_ISSUED") return type.issuesCertificate;
|
||
return true;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 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);
|
||
|
||
// Type-pinned queues resolve a family straight from the URL, no query
|
||
// needed — `resolveFamilyKind` falls back to LOGISTICS_LICENSE for unknown
|
||
// keys and undefined for the mixed All/Mine grids, which is the safe
|
||
// default (nothing hidden) in both cases.
|
||
const isLogistics = typeCode
|
||
? resolveFamilyKind(typeCode) === "LOGISTICS_LICENSE"
|
||
: undefined;
|
||
const visibleViews = savedViewsForFamily(isLogistics !== false);
|
||
|
||
const [view, setView] = useState<SavedViewId>(
|
||
() =>
|
||
(searchParams.get("view") as SavedViewId) ||
|
||
(isLogistics === false ? "all" : readLastView()),
|
||
);
|
||
const [page, setPage] = useState(() => Number(searchParams.get("page")) || 1);
|
||
const [pageSize, setPageSize] = useState(PAGE_SIZE);
|
||
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);
|
||
|
||
// Non-logistics queues have no unassigned/unclaimed pool (see
|
||
// `savedViewsForFamily`), so a stale "unassigned" view — e.g. restored from
|
||
// `readLastView()` — must fall back to "all" rather than land on a tab that
|
||
// no longer exists. Auto-created BTC requests specifically start at
|
||
// PAYMENT_PENDING, outside "mine" too, so "all" is the one view guaranteed
|
||
// to show them.
|
||
useEffect(() => {
|
||
if (
|
||
isLogistics === false &&
|
||
!searchParams.has("view") &&
|
||
view === "unassigned"
|
||
) {
|
||
setView("all");
|
||
}
|
||
}, [isLogistics, searchParams, view]);
|
||
|
||
const urlFilter = useMemo(
|
||
() => filterFromSearchParams(searchParams),
|
||
[searchParams],
|
||
);
|
||
const activeView = SAVED_VIEWS.find((v) => v.id === view) ?? SAVED_VIEWS[0];
|
||
|
||
const { data: licenseTypes } = useGetLicenseTypesQuery();
|
||
// 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]);
|
||
|
||
// Counts endpoint keys off `key`, the facet off `id` — resolve whichever the
|
||
// route or the dropdown set, so the tab badges always count the same rows
|
||
// the grid is showing rather than system-wide totals.
|
||
const countsKey = useMemo(
|
||
() =>
|
||
typeCode ??
|
||
licenseTypes?.items?.find((type) => type.id === urlFilter.licenseTypeId)
|
||
?.key,
|
||
[typeCode, urlFilter.licenseTypeId, licenseTypes],
|
||
);
|
||
const { data: counts } = useGetQueueCountsQuery(countsKey);
|
||
|
||
const selectedType = useMemo(() => {
|
||
const typeId = pinnedTypeId ?? urlFilter.licenseTypeId;
|
||
if (!typeId) return undefined;
|
||
return licenseTypes?.items?.find((type) => type.id === typeId);
|
||
}, [pinnedTypeId, urlFilter.licenseTypeId, licenseTypes]);
|
||
|
||
// The facet offers what the chosen type can actually reach. With no type
|
||
// chosen the queue spans every course, so the full list is correct.
|
||
const statusOptions = useMemo(
|
||
() => statusesFor(selectedType),
|
||
[selectedType],
|
||
);
|
||
|
||
const filter: QueueFilter = useMemo(
|
||
() => ({
|
||
...activeView.filter,
|
||
...urlFilter,
|
||
search: debouncedSearch || undefined,
|
||
licenseTypeId: pinnedTypeId ?? urlFilter.licenseTypeId,
|
||
// No explicit sort in the URL or view → newest submissions first, so
|
||
// the queue opens showing what most needs attention rather than
|
||
// whatever order the backend happens to return. "Mine" sorts by
|
||
// claimedAt instead — officers care when they picked it up, not when
|
||
// it was originally submitted.
|
||
sortBy:
|
||
urlFilter.sortBy ??
|
||
(activeView.id === "mine" ? "submittedAt" : "submittedAt"),
|
||
sortDir: urlFilter.sortDir ?? "DESC",
|
||
take: pageSize,
|
||
skip: (page - 1) * pageSize,
|
||
}),
|
||
[activeView, urlFilter, debouncedSearch, pinnedTypeId, page, pageSize],
|
||
);
|
||
|
||
// 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 [assignReviewer, { isLoading: assigning }] = useAssignReviewerMutation();
|
||
const [assignTarget, setAssignTarget] = useState<LicenseApplication | null>(null);
|
||
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 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);
|
||
};
|
||
|
||
/**
|
||
* Switching type drops any selected status the new type cannot reach —
|
||
* otherwise the facet keeps an invisible filter that matches nothing and the
|
||
* grid looks empty for no reason the officer can see.
|
||
*/
|
||
const changeType = (typeId: string | undefined) => {
|
||
const allowed = statusesFor(
|
||
licenseTypes?.items?.find((type) => type.id === typeId),
|
||
);
|
||
setFacet({
|
||
licenseTypeId: typeId,
|
||
status: urlFilter.status?.filter((s) => allowed.includes(s)),
|
||
});
|
||
};
|
||
|
||
const toggleSort = (field: NonNullable<QueueFilter["sortBy"]>) => {
|
||
const dir =
|
||
urlFilter.sortBy === field && urlFilter.sortDir !== "DESC"
|
||
? "DESC"
|
||
: "ASC";
|
||
setFacet({ sortBy: field, sortDir: dir });
|
||
};
|
||
|
||
/**
|
||
* The team leader dispatching one application.
|
||
*
|
||
* Replaces the old self-service claim: nothing is picked up any more, so the
|
||
* queue's primary action is handing a file to an employee. The dialog holds
|
||
* the choice; this only sends it.
|
||
*/
|
||
async function handleAssign(officerId: string, remark?: string) {
|
||
if (!assignTarget) return;
|
||
try {
|
||
await assignReviewer({ id: assignTarget.id, officerId, remark }).unwrap();
|
||
notifications.show({
|
||
color: "teal",
|
||
title: t("queue.assigned", "Assigned"),
|
||
message: t(
|
||
"queue.assignedBody",
|
||
"The employee has been notified and the review has started.",
|
||
),
|
||
});
|
||
setAssignTarget(null);
|
||
active.refetch();
|
||
} catch (err) {
|
||
notifications.show({
|
||
color: "red",
|
||
title: t("queue.assignFailed", "Could not assign"),
|
||
message: extractErrorMessage(
|
||
err,
|
||
t("queue.assignError", "The application could not be assigned."),
|
||
),
|
||
});
|
||
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: () => {
|
||
// "c" now opens the assign dialog on an undispatched row. Kept on the
|
||
// same key: it is still "do the queue's primary action to this row",
|
||
// and rebinding a shortcut officers have in their fingers costs more
|
||
// than the name mismatch.
|
||
if (isLogistics !== false && cursorRow && cursorRow.assignedOfficerId === null)
|
||
setAssignTarget(cursorRow);
|
||
},
|
||
onEscape: () => setSelected([]),
|
||
onHelp: () => setHelpOpen(true),
|
||
});
|
||
|
||
// Deep-linked by type (`/licence-review/type/:typeCode`), so the queue
|
||
// title/labels read "Certificate applications" for a CoC queue and
|
||
// "Document applications" for a Seaman Book queue rather than always
|
||
// "Licence applications" — the All/Mine views have no single type and stay
|
||
// on the licence-flavoured default, matching today's behaviour.
|
||
const queueLabels = familyLabels(resolveFamilyKind(typeCode));
|
||
const queueTitle = typeCode
|
||
? t("queue.titleByFamily", {
|
||
family: queueLabels.typeLabel,
|
||
defaultValue: `${queueLabels.typeLabel} applications`,
|
||
})
|
||
: t("queue.title", "Licence applications");
|
||
|
||
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,
|
||
);
|
||
|
||
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(
|
||
() => [
|
||
...licenseQueueColumns(t, i18n.language, {
|
||
items,
|
||
selected,
|
||
setSelected,
|
||
allSelected,
|
||
sortableHeader,
|
||
isLogistics,
|
||
}),
|
||
licenseQueueActionsColumn(t, {
|
||
assigning,
|
||
onAssign: setAssignTarget,
|
||
onOpen: (id) => navigate(`/licence-review/${id}`),
|
||
// Non-logistics applications aren't dispatched off a shared queue (see
|
||
// `savedViewsForFamily`) — every row opens straight to Review.
|
||
assignable: isLogistics !== false,
|
||
}),
|
||
],
|
||
[
|
||
t,
|
||
i18n.language,
|
||
urlFilter.sortBy,
|
||
sortIcon,
|
||
selected,
|
||
allSelected,
|
||
items,
|
||
assigning,
|
||
isLogistics,
|
||
],
|
||
);
|
||
|
||
return (
|
||
<Container size="xl" py="md" pb={selected.length ? 80 : "md"}>
|
||
<PageHeader
|
||
title={queueTitle}
|
||
subtitle={typeCode ? t(`nav.type${typeCode}`, { defaultValue: typeCode }) : undefined}
|
||
action={
|
||
<Group gap="xs">
|
||
<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>
|
||
}
|
||
/>
|
||
|
||
{/* Saved views, counted. */}
|
||
<Tabs
|
||
value={view}
|
||
onChange={(v) => changeView((v as SavedViewId) ?? DEFAULT_VIEW)}
|
||
mb="sm"
|
||
>
|
||
<Tabs.List>
|
||
{visibleViews.map((savedView) => (
|
||
<Tabs.Tab
|
||
key={savedView.id}
|
||
value={savedView.id}
|
||
rightSection={
|
||
counts?.[savedView.countKey] ? (
|
||
<Badge size="xs" variant="light">
|
||
{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={statusOptions.map((s) => ({
|
||
value: s,
|
||
label: t(`queue.statusValues.${s}`, STATUS_LABELS[s]),
|
||
}))}
|
||
value={urlFilter.status ?? []}
|
||
onChange={(v) => setFacet({ status: v as LicenseStatus[] })}
|
||
clearable
|
||
w={240}
|
||
/>
|
||
{!typeCode && (
|
||
<Select
|
||
label={t("queue.type", "Type")}
|
||
placeholder={t("queue.anyType", "Any")}
|
||
data={(licenseTypes?.items ?? []).map((type) => ({
|
||
value: type.id,
|
||
label: localized(type.name, i18n.language) || type.key,
|
||
}))}
|
||
value={urlFilter.licenseTypeId ?? null}
|
||
onChange={(v) => changeType(v ?? undefined)}
|
||
clearable
|
||
w={220}
|
||
/>
|
||
)}
|
||
<AmharicDatePicker
|
||
label={t("queue.submittedFrom", "Submitted from")}
|
||
value={urlFilter.submittedFrom ?? ""}
|
||
onChange={(v) => setFacet({ submittedFrom: v || undefined })}
|
||
dateFormat="date"
|
||
w={170}
|
||
/>
|
||
<AmharicDatePicker
|
||
label={t("queue.submittedTo", "Submitted to")}
|
||
value={urlFilter.submittedTo ?? ""}
|
||
onChange={(v) => setFacet({ submittedTo: v || undefined })}
|
||
dateFormat="date"
|
||
w={170}
|
||
/>
|
||
{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
|
||
}
|
||
/>
|
||
) : (
|
||
<>
|
||
<Group justify="flex-end" p="sm" pb={0}>
|
||
<Text size="sm" c="dimmed">
|
||
{t("queue.showing", {
|
||
from: (page - 1) * pageSize + 1,
|
||
to: Math.min(page * pageSize, total),
|
||
total,
|
||
defaultValue: "Showing {{from}}–{{to}} of {{total}}",
|
||
})}
|
||
</Text>
|
||
</Group>
|
||
<AdvancedTable
|
||
columns={columns}
|
||
data={items}
|
||
tableName={queueTitle}
|
||
itemCount={total}
|
||
pageIndex={page - 1}
|
||
onPageChange={(pageIndex) => {
|
||
const next = pageIndex + 1;
|
||
setPage(next);
|
||
updateUrl({}, view, next);
|
||
}}
|
||
pageSize={pageSize}
|
||
onPageSizeChange={(next) => {
|
||
setPageSize(next);
|
||
setPage(1);
|
||
updateUrl({}, view, 1);
|
||
}}
|
||
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>
|
||
|
||
<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>
|
||
</Group>
|
||
</Group>
|
||
</Paper>
|
||
)}
|
||
<AssignDialog
|
||
opened={assignTarget !== null}
|
||
onClose={() => setAssignTarget(null)}
|
||
kind="review"
|
||
applicationNumber={assignTarget?.applicationNumber}
|
||
loading={assigning}
|
||
onConfirm={handleAssign}
|
||
/>
|
||
</Container>
|
||
);
|
||
}
|
||
|
||
export default LicenseQueuePage;
|