feat: enhance license queue functionality for logistics and non-logistics applications

This commit is contained in:
Nati
2026-08-19 13:20:21 +00:00
parent e1d52adb0c
commit acbb789960
5 changed files with 97 additions and 40 deletions

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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,6 +685,7 @@ export function LicenseQueuePage() {
>
{t("queue.export", "Export CSV")}
</Button>
{isLogistics !== false && (
<RequirePermission
anyOf={[LICENSE_PERMISSIONS.CLAIM_APPLICATION]}
hideOnly
@@ -678,6 +697,7 @@ export function LicenseQueuePage() {
})}
</Button>
</RequirePermission>
)}
</Group>
</Group>
</Paper>

View File

@@ -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');
});
});

View File

@@ -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 {