mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
feat: enhance license queue functionality for logistics and non-logistics applications
This commit is contained in:
@@ -10,6 +10,8 @@ export function licenseQueueActionsColumn(
|
||||
claiming: boolean;
|
||||
onClaim: (id: string) => void;
|
||||
onOpen: (id: string) => void;
|
||||
/** False for a non-logistics queue — there's no unclaimed pool to claim from. */
|
||||
claimable?: boolean;
|
||||
},
|
||||
): AdvancedColumn<LicenseApplication> {
|
||||
return {
|
||||
@@ -18,6 +20,7 @@ export function licenseQueueActionsColumn(
|
||||
align: "right",
|
||||
size: 140,
|
||||
cell: ({ row }) =>
|
||||
handlers.claimable !== false &&
|
||||
row.original.assignedOfficerId === null &&
|
||||
row.original.status === "SUBMITTED" ? (
|
||||
<RequirePermission
|
||||
|
||||
@@ -20,7 +20,15 @@ import { computeSla } from "../../sla";
|
||||
* grid from holding both, and a static header can't be correct for both at
|
||||
* once. Falls back to the combined label until the page has data to look at.
|
||||
*/
|
||||
function companyColumnHeader(t: TFunction, items: LicenseApplication[]): string {
|
||||
function companyColumnHeader(
|
||||
t: TFunction,
|
||||
items: LicenseApplication[],
|
||||
isLogistics: boolean | undefined,
|
||||
): string {
|
||||
// A type-pinned non-logistics queue (Seafarer Registration, Seaman Book,
|
||||
// BTC, ...) is always "Applicant" — no need to guess from loaded rows.
|
||||
if (isLogistics === false) return t("queue.applicant", "Applicant");
|
||||
if (isLogistics === true) return t("queue.company", "Company");
|
||||
if (items.length === 0) {
|
||||
return t("queue.companyOrApplicant", "Applicant / Company");
|
||||
}
|
||||
@@ -43,10 +51,12 @@ export function licenseQueueColumns(
|
||||
label: string,
|
||||
field: NonNullable<QueueFilter["sortBy"]>,
|
||||
) => ReactNode;
|
||||
/** Set for a type-pinned queue; undefined for the mixed All/Mine grids. */
|
||||
isLogistics?: boolean;
|
||||
},
|
||||
): AdvancedColumn<LicenseApplication>[] {
|
||||
const { items, selected, setSelected, allSelected, sortableHeader } = opts;
|
||||
return [
|
||||
const { items, selected, setSelected, allSelected, sortableHeader, isLogistics } = opts;
|
||||
const columns: AdvancedColumn<LicenseApplication>[] = [
|
||||
{
|
||||
header: (
|
||||
<Checkbox
|
||||
@@ -93,26 +103,12 @@ export function licenseQueueColumns(
|
||||
// certificate/document rows side by side, so no single static label is
|
||||
// right for the whole column there — "Applicant / Company" covers
|
||||
// both without claiming a row is one or the other.
|
||||
header: sortableHeader(companyColumnHeader(t, items), "companyName"),
|
||||
header: sortableHeader(companyColumnHeader(t, items, isLogistics), "companyName"),
|
||||
label: t("queue.company", "Company"),
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm">{applicantOrCompanyName(row.original) ?? "—"}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t("queue.tin", "TIN"),
|
||||
cell: ({ row }) =>
|
||||
// A certificate/document application is filed by a person, never a
|
||||
// business — there is no TIN to show, not even a blank one. Hiding
|
||||
// the cell (rather than "—") is what makes the row visibly not a
|
||||
// logistics-licence row, which is the whole point of `familyKind`
|
||||
// being a real column now instead of a frontend guess.
|
||||
row.original.familyKind === "LOGISTICS_LICENSE" ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
{row.original.tinNumber ?? "—"}
|
||||
</Text>
|
||||
) : null,
|
||||
},
|
||||
{
|
||||
header: t("queue.typeCol", "Type"),
|
||||
cell: ({ row }) => (
|
||||
@@ -161,4 +157,21 @@ export function licenseQueueColumns(
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// A type-pinned non-logistics queue never has a TIN to show — a business
|
||||
// registration number doesn't apply to a certificate/document filed by a
|
||||
// person — so the column itself is dropped rather than left showing blanks.
|
||||
if (isLogistics !== false) {
|
||||
columns.splice(2, 0, {
|
||||
header: t("queue.tin", "TIN"),
|
||||
cell: ({ row }) =>
|
||||
row.original.familyKind === "LOGISTICS_LICENSE" ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
{row.original.tinNumber ?? "—"}
|
||||
</Text>
|
||||
) : null,
|
||||
});
|
||||
}
|
||||
|
||||
return columns;
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
SAVED_VIEWS,
|
||||
filterFromSearchParams,
|
||||
readLastView,
|
||||
savedViewsForFamily,
|
||||
searchParamsFromFilter,
|
||||
writeLastView,
|
||||
type SavedViewId,
|
||||
@@ -107,10 +108,19 @@ export function LicenseQueuePage() {
|
||||
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) ||
|
||||
(typeCode === "BTC_BASIC_TRAINING" ? "all" : readLastView()),
|
||||
(isLogistics === false ? "all" : readLastView()),
|
||||
);
|
||||
const [page, setPage] = useState(() => Number(searchParams.get("page")) || 1);
|
||||
const [pageSize, setPageSize] = useState(PAGE_SIZE);
|
||||
@@ -120,18 +130,21 @@ export function LicenseQueuePage() {
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
const [debouncedSearch] = useDebouncedValue(searchInput, SEARCH_DEBOUNCE_MS);
|
||||
|
||||
// Auto-created BTC requests start at PAYMENT_PENDING, which is not part of
|
||||
// the unassigned officer work pool. A dedicated BTC Queue must therefore
|
||||
// open its all-status view so those requests are visible immediately.
|
||||
// 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 (
|
||||
typeCode === "BTC_BASIC_TRAINING" &&
|
||||
isLogistics === false &&
|
||||
!searchParams.has("view") &&
|
||||
view !== "all"
|
||||
view === "unassigned"
|
||||
) {
|
||||
setView("all");
|
||||
}
|
||||
}, [typeCode, searchParams, view]);
|
||||
}, [isLogistics, searchParams, view]);
|
||||
|
||||
const urlFilter = useMemo(
|
||||
() => filterFromSearchParams(searchParams),
|
||||
@@ -319,9 +332,9 @@ export function LicenseQueuePage() {
|
||||
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)
|
||||
// Only unclaimed rows on a logistics queue can be claimed; pressing c
|
||||
// elsewhere is a no-op rather than an error the officer has to read.
|
||||
if (isLogistics !== false && cursorRow && cursorRow.assignedOfficerId === null)
|
||||
handleClaim(cursorRow.id);
|
||||
},
|
||||
onEscape: () => setSelected([]),
|
||||
@@ -380,11 +393,15 @@ export function LicenseQueuePage() {
|
||||
setSelected,
|
||||
allSelected,
|
||||
sortableHeader,
|
||||
isLogistics,
|
||||
}),
|
||||
licenseQueueActionsColumn(t, {
|
||||
claiming,
|
||||
onClaim: handleClaim,
|
||||
onOpen: (id) => navigate(`/licence-review/${id}`),
|
||||
// Non-logistics applications aren't claimed off a shared queue (see
|
||||
// `savedViewsForFamily`) — every row opens straight to Review.
|
||||
claimable: isLogistics !== false,
|
||||
}),
|
||||
],
|
||||
[
|
||||
@@ -396,6 +413,7 @@ export function LicenseQueuePage() {
|
||||
allSelected,
|
||||
items,
|
||||
claiming,
|
||||
isLogistics,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -444,7 +462,7 @@ export function LicenseQueuePage() {
|
||||
mb="sm"
|
||||
>
|
||||
<Tabs.List>
|
||||
{SAVED_VIEWS.map((savedView) => (
|
||||
{visibleViews.map((savedView) => (
|
||||
<Tabs.Tab
|
||||
key={savedView.id}
|
||||
value={savedView.id}
|
||||
@@ -667,17 +685,19 @@ export function LicenseQueuePage() {
|
||||
>
|
||||
{t("queue.export", "Export CSV")}
|
||||
</Button>
|
||||
<RequirePermission
|
||||
anyOf={[LICENSE_PERMISSIONS.CLAIM_APPLICATION]}
|
||||
hideOnly
|
||||
>
|
||||
<Button loading={claiming} onClick={handleBulkClaim}>
|
||||
{t("queue.bulkClaim", {
|
||||
count: selected.length,
|
||||
defaultValue: "Claim {{count}}",
|
||||
})}
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
{isLogistics !== false && (
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { resolveFamilyKind } from '@ema-platform/api';
|
||||
|
||||
describe('resolveFamilyKind', () => {
|
||||
it('treats person-centric seafarer applications as document queues', () => {
|
||||
expect(resolveFamilyKind('SEAFARER_REGISTRATION')).toBe('DOCUMENT');
|
||||
expect(resolveFamilyKind('SEAMAN_BOOK')).toBe('DOCUMENT');
|
||||
expect(resolveFamilyKind('BTC_BASIC_TRAINING')).toBe('CERTIFICATE');
|
||||
});
|
||||
});
|
||||
@@ -77,6 +77,17 @@ export const SAVED_VIEWS: SavedView[] = [
|
||||
|
||||
export const DEFAULT_VIEW: SavedViewId = 'unassigned';
|
||||
|
||||
/**
|
||||
* Non-logistics queues (Seafarer Registration, Seaman Book, BTC, CoC, ...)
|
||||
* have no unclaimed pool to triage — those applications aren't claimed off a
|
||||
* shared queue — so the tab that lists it doesn't apply there.
|
||||
*/
|
||||
export function savedViewsForFamily(isLogistics: boolean): SavedView[] {
|
||||
return isLogistics
|
||||
? SAVED_VIEWS
|
||||
: SAVED_VIEWS.filter((v) => v.id !== 'unassigned');
|
||||
}
|
||||
|
||||
const LAST_VIEW_KEY = 'ema-backoffice-queue-view';
|
||||
|
||||
export function readLastView(): SavedViewId {
|
||||
|
||||
Reference in New Issue
Block a user