Files
emaui/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/index.tsx
2026-08-22 07:54:35 +00:00

768 lines
24 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,
} 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;