Files
emaui/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/index.tsx

794 lines
25 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, 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,
Title,
} 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,
useClaimApplicationMutation,
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 { LICENSE_PERMISSIONS, RequirePermission } from "@ema-platform/auth";
import {
DEFAULT_VIEW,
SAVED_VIEWS,
filterFromSearchParams,
readLastView,
hasUnclaimedPool,
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 { licenseQueueActionsColumn } from "./actions";
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;
// Claiming is a workflow property, not a label one — Vessel Registration is
// a DOCUMENT family but is still triaged off a shared unclaimed pool, so it
// keeps the Unassigned tab and the claim actions.
const claimable = hasUnclaimedPool(typeCode);
const visibleViews = savedViewsForFamily(claimable);
const [view, setView] = useState<SavedViewId>(
() =>
(searchParams.get("view") as SavedViewId) ||
(claimable ? readLastView() : "all"),
);
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);
// Queues with no unclaimed pool (see `hasUnclaimedPool`) have no unassigned
// tab, 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 (!claimable && !searchParams.has("view") && view === "unassigned") {
setView("all");
}
}, [claimable, 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 [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 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 });
};
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 on a claimable queue can be claimed; pressing c
// elsewhere is a no-op rather than an error the officer has to read.
if (claimable && cursorRow && cursorRow.assignedOfficerId === null)
handleClaim(cursorRow.id);
},
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, {
claiming,
onClaim: handleClaim,
onOpen: (id) => navigate(`/licence-review/${id}`),
// Applications with no unclaimed pool (see `hasUnclaimedPool`) are
// never claimed — every row opens straight to Review.
claimable,
}),
],
[
t,
i18n.language,
urlFilter.sortBy,
sortIcon,
selected,
allSelected,
items,
claiming,
isLogistics,
claimable,
],
);
return (
<Container size="xl" py="md" pb={selected.length ? 80 : "md"}>
<Group justify="space-between" mb="md">
<div>
<Title order={3}>{queueTitle}</Title>
{typeCode && (
<Text size="sm" c="dimmed">
{t(`nav.type${typeCode}`, { defaultValue: typeCode })}
</Text>
)}
</div>
<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>
</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>
{claimable && (
<RequirePermission
anyOf={[LICENSE_PERMISSIONS.CLAIM_APPLICATION]}
hideOnly
>
<Button loading={claiming} onClick={handleBulkClaim}>
{t("queue.bulkClaim", {
count: selected.length,
defaultValue: "Claim {{count}}",
})}
</Button>
</RequirePermission>
)}
</Group>
</Group>
</Paper>
)}
</Container>
);
}
export default LicenseQueuePage;