Merge branch 'WorkflowChange' of github.com:Tria-plc/emaui into WorkflowChange

This commit is contained in:
Nati
2026-08-26 13:34:24 +00:00
24 changed files with 315 additions and 76 deletions

View File

@@ -94,10 +94,16 @@ export function DocumentRequirementEditorDrawer({
return;
}
if (!draft.name.en?.trim()) return;
// A condition is either a single-field check or an anyOf list — the CoP
// watch_rating_certificate requirement is seeded with anyOf and no field.
const hasCondition =
Boolean(draft.conditionExpression?.field) ||
Boolean(draft.conditionExpression?.anyOf?.length);
if (draft.mode === 'CONDITIONAL' && !hasCondition) {
if (
draft.mode === 'CONDITIONAL' &&
!hasCondition &&
!draft.conditionExpression?.anyOf?.length
) {
setKeyError(t('certReq.doc.conditionRequired', 'A conditional requirement needs a condition'));
return;
}

View File

@@ -372,6 +372,18 @@ export function resolveActions(ctx: ResolveContext): ResolvedAction[] {
if (action.id === 'schedule-inspection' && ctx.hasPendingInspection) return [];
if (action.id === 'record-inspection' && !ctx.hasPendingInspection) return [];
// The transition table doesn't know which types need an inspection, so
// `availableEvents` lists approve-documents at UNDER_EVALUATION even for
// types without one — where the server refuses it
// (`inspection_not_required_use_final_approve`). Final Approve is the
// real action there; don't render its dead twin.
if (
action.id === 'approve-documents' &&
!app.licenseType?.inspectionRequired
) {
return [];
}
const disabled = (reason: string): ResolvedAction => ({
...action,
enabled: false,

View File

@@ -116,11 +116,21 @@ const PRESENTATION: Record<string, LicenseTypePresentation> = {
// no capital threshold, no staff roles.
detailSections: ['overview', 'documents'],
},
ENDORSEMENT_SEAFARER: {
key: 'ENDORSEMENT_SEAFARER',
icon: IconRubberStamp,
// Person-centric, same as seafarer registration: no company entity, no
// capital threshold, no staff roles, no inspection. Covers CoC and GOC
// together — the `endorsementScope` field on the application decides
// which certificate section(s) actually have data.
detailSections: ['overview', 'documents'],
},
// Retired by ENDORSEMENT_SEAFARER (see endorsements.seed-data.ts). Kept so
// an application filed before the switch still renders with the right
// presentation instead of falling back to the generic company layout.
ENDORSEMENT_COC: {
key: 'ENDORSEMENT_COC',
icon: IconRubberStamp,
// Person-centric, same as seafarer registration: no company entity, no
// capital threshold, no staff roles, no inspection.
detailSections: ['overview', 'documents'],
},
ENDORSEMENT_GOC: {

View File

@@ -60,6 +60,7 @@ import {
SAVED_VIEWS,
filterFromSearchParams,
readLastView,
hasUnclaimedPool,
savedViewsForFamily,
searchParamsFromFilter,
writeLastView,
@@ -159,12 +160,16 @@ export function LicenseQueuePage() {
const isLogistics = typeCode
? resolveFamilyKind(typeCode) === "LOGISTICS_LICENSE"
: undefined;
const visibleViews = savedViewsForFamily(isLogistics !== false);
// 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) ||
(isLogistics === false ? "all" : readLastView()),
(claimable ? readLastView() : "all"),
);
const [page, setPage] = useState(() => Number(searchParams.get("page")) || 1);
const [pageSize, setPageSize] = useState(PAGE_SIZE);
@@ -174,21 +179,17 @@ export function LicenseQueuePage() {
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
// 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 (
isLogistics === false &&
!searchParams.has("view") &&
view === "unassigned"
) {
if (!claimable && !searchParams.has("view") && view === "unassigned") {
setView("all");
}
}, [isLogistics, searchParams, view]);
}, [claimable, searchParams, view]);
const urlFilter = useMemo(
() => filterFromSearchParams(searchParams),
@@ -414,9 +415,9 @@ export function LicenseQueuePage() {
onPrevious: () => setCursor((c) => Math.max(c - 1, 0)),
onOpen: () => cursorRow && navigate(`/licence-review/${cursorRow.id}`),
onClaim: () => {
// Only unclaimed rows on a logistics queue can be claimed; pressing c
// 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 (isLogistics !== false && cursorRow && cursorRow.assignedOfficerId === null)
if (claimable && cursorRow && cursorRow.assignedOfficerId === null)
handleClaim(cursorRow.id);
},
onEscape: () => setSelected([]),
@@ -481,9 +482,9 @@ export function LicenseQueuePage() {
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,
// Applications with no unclaimed pool (see `hasUnclaimedPool`) are
// never claimed — every row opens straight to Review.
claimable,
}),
],
[
@@ -496,6 +497,7 @@ export function LicenseQueuePage() {
items,
claiming,
isLogistics,
claimable,
],
);
@@ -763,7 +765,7 @@ export function LicenseQueuePage() {
>
{t("queue.export", "Export CSV")}
</Button>
{isLogistics !== false && (
{claimable && (
<RequirePermission
anyOf={[LICENSE_PERMISSIONS.CLAIM_APPLICATION]}
hideOnly

View File

@@ -535,11 +535,11 @@ export function LicenseReviewPage() {
try {
switch (action.id) {
case "claim":
// Usually fired from the queue, but an officer can also open an
// unclaimed application directly and claim it from here.
// Usually fired from the queue, but an officer who opened an
// unclaimed application directly claims it from here.
await run(
() => claimApplication(id).unwrap(),
t("review.done.claim", "Application claimed"),
t("review.done.claim", "Claimed — the application is now yours"),
);
break;
case "complete-review":

View File

@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { resolveFamilyKind } from "@ema-platform/api";
import { hasUnclaimedPool, savedViewsForFamily } from "./queue-views";
describe("resolveFamilyKind", () => {
it("treats person-centric seafarer applications as document queues", () => {
@@ -8,3 +9,28 @@ describe("resolveFamilyKind", () => {
expect(resolveFamilyKind("BTC_BASIC_TRAINING")).toBe("CERTIFICATE");
});
});
describe("hasUnclaimedPool", () => {
it("keeps the unassigned pool for logistics licences", () => {
expect(hasUnclaimedPool("FREIGHT_FORWARDER")).toBe(true);
});
it("keeps it for vessel registration, a DOCUMENT queue that is still claimed", () => {
expect(resolveFamilyKind("VESSEL_REGISTRATION")).toBe("DOCUMENT");
expect(hasUnclaimedPool("VESSEL_REGISTRATION")).toBe(true);
expect(savedViewsForFamily(hasUnclaimedPool("VESSEL_REGISTRATION")).map((v) => v.id)).toContain(
"unassigned",
);
});
it("drops it for the person-centric services", () => {
expect(hasUnclaimedPool("SEAMAN_BOOK")).toBe(false);
expect(savedViewsForFamily(hasUnclaimedPool("SEAMAN_BOOK")).map((v) => v.id)).not.toContain(
"unassigned",
);
});
it("keeps it for the mixed All/Mine grids, where no type is pinned", () => {
expect(hasUnclaimedPool(undefined)).toBe(true);
});
});

View File

@@ -1,3 +1,4 @@
import { resolveFamilyKind } from '@ema-platform/api';
import type { LicenseStatus, QueueCounts, QueueFilter } from '@ema-platform/api';
export type SavedViewId =
@@ -78,12 +79,39 @@ 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.
* Type keys reviewed off a shared unclaimed pool despite not being logistics
* licences. Whether a queue is claimed is an officer-workflow property, not a
* label one: vessel registrations arrive unassigned and officers claim them,
* even though the family kind is DOCUMENT because the certificate they produce
* is a document rather than a licence.
*/
export function savedViewsForFamily(isLogistics: boolean): SavedView[] {
return isLogistics
const CLAIMABLE_NON_LOGISTICS = new Set([
'VESSEL_REGISTRATION',
// Endorsements run the standard claim-first workflow: applications arrive
// unassigned and an officer claims them, even though the family kind is
// CERTIFICATE.
'ENDORSEMENT_SEAFARER',
]);
/**
* Does this queue have an unclaimed pool to triage?
*
* True for the mixed All/Mine grids (no type pinned) — nothing is hidden when
* the queue spans every type. False for the person-centric services (Seafarer
* Registration, Seaman Book, BTC, CoC, ...), whose applications aren't claimed
* off a shared queue, so the tab and the claim actions don't apply there.
*/
export function hasUnclaimedPool(typeCode: string | undefined): boolean {
if (!typeCode) return true;
return (
resolveFamilyKind(typeCode) === 'LOGISTICS_LICENSE' ||
CLAIMABLE_NON_LOGISTICS.has(typeCode)
);
}
/** Drops the Unassigned tab on queues with no unclaimed pool. */
export function savedViewsForFamily(claimable: boolean): SavedView[] {
return claimable
? SAVED_VIEWS
: SAVED_VIEWS.filter((v) => v.id !== 'unassigned');
}

View File

@@ -48,6 +48,7 @@ export const am: Translations = {
typeVESSEL_OWNERSHIP_TRANSFER: "የመርከብ ባለቤትነት ዝውውር",
typeCERTIFICATE_OF_COMPETENCY: "የብቃት ማረጋገጫ ምስክር ወረቀት",
typeCERTIFICATE_OF_PROFICIENCY: "የብቃት ምስክር ወረቀት",
typeENDORSEMENT_SEAFARER: "የባህረኛ ማስተያየት (CoC / GOC)",
typeENDORSEMENT_COC: "የCoC ማረጋገጫ",
typeENDORSEMENT_GOC: "የGOC ማረጋገጫ",
primary: "ዋና",
@@ -87,8 +88,7 @@ export const am: Translations = {
btcQueue: "የBTC ወረፋ",
cocQueue: "የCoC ወረፋ",
copQueue: "የCoP ወረፋ",
endorsementCocQueue: "የCoC ማረጋገጫ ወረፋ",
endorsementGocQueue: "የGOC ማረጋገጫ ወረፋ",
endorsementQueue: "የማስተያየት ወረፋ",
vesselRegistrations: "የመርከብ ምዝገባ",
// vesselRegistrationReport: 'የምዝገባ ሪፖርት',
vesselTransfers: "የመርከብ ባለቤትነት ዝውውር",

View File

@@ -48,6 +48,7 @@ export const en = {
typeVESSEL_OWNERSHIP_TRANSFER: 'Vessel Ownership Transfer',
typeCERTIFICATE_OF_COMPETENCY: 'Certificate of Competency',
typeCERTIFICATE_OF_PROFICIENCY: 'Certificate of Proficiency',
typeENDORSEMENT_SEAFARER: 'Seafarer Endorsement (CoC / GOC)',
typeENDORSEMENT_COC: 'CoC Endorsement',
typeENDORSEMENT_GOC: 'GOC Endorsement',
primary: 'Primary',
@@ -87,8 +88,7 @@ export const en = {
postWaiverQueue: 'Post-Waiver Queue',
cocQueue: 'CoC Queue',
copQueue: 'CoP Queue',
endorsementCocQueue: 'CoC Endorsement Queue',
endorsementGocQueue: 'GOC Endorsement Queue',
endorsementQueue: 'Endorsement Queue',
vesselRegistrations: 'Vessel Registration',
vesselTransfers: 'Vessel Ownership Transfer',
seafarerRegistry: 'Seafarer Registry',

View File

@@ -101,8 +101,7 @@ export const NAV_SECTIONS: NavSection[] = [
{ to: '/licence-review/type/CERTIFICATE_OF_PROFICIENCY', label: 'nav.copQueue', icon: IconShieldCheck, permissions: APPLICATION_QUEUE },
{ to: '/seaman-book-queue', label: 'nav.seamanBookQueue', icon: IconBook2, permissions: APPLICATION_QUEUE },
{ to: '/btc-queue', label: 'nav.btcQueue', icon: IconShieldCheck, permissions: APPLICATION_QUEUE },
{ to: '/licence-review/type/ENDORSEMENT_COC', label: 'nav.endorsementCocQueue', icon: IconRubberStamp, permissions: APPLICATION_QUEUE },
{ to: '/licence-review/type/ENDORSEMENT_GOC', label: 'nav.endorsementGocQueue', icon: IconRubberStamp, permissions: APPLICATION_QUEUE },
{ to: '/licence-review/type/ENDORSEMENT_SEAFARER', label: 'nav.endorsementQueue', icon: IconRubberStamp, permissions: APPLICATION_QUEUE },
{ to: '/sea-service-verification', label: 'nav.seaServiceVerification', icon: IconAnchor, permissions: [P.VERIFY_SEAFARER_RECORDS] },
{ to: '/medical-verification', label: 'nav.medicalVerification', icon: IconHeart, permissions: [P.VERIFY_SEAFARER_RECORDS] },
],

View File

@@ -90,7 +90,11 @@ const router = createBrowserRouter([
{ path: 'coc-queue', element: <Navigate to="/licence-review/type/CERTIFICATE_OF_COMPETENCY" replace /> },
{ path: 'coc-queue/:id', element: <Navigate to="/licence-review" replace /> },
// Endorsement review happens in the config-driven licence queue.
{ path: 'endorsement-queue', element: <Navigate to="/licence-review/type/ENDORSEMENT_COC" replace /> },
// CoC and GOC endorsement now share one combined type; the two old
// type queues (ENDORSEMENT_COC / ENDORSEMENT_GOC) are left routing
// through the generic `:typeCode` queue below rather than redirected,
// so an application filed before the switch stays reachable there.
{ path: 'endorsement-queue', element: <Navigate to="/licence-review/type/ENDORSEMENT_SEAFARER" replace /> },
{ path: 'endorsement-queue/:id', element: <Navigate to="/licence-review" replace /> },
{ path: 'medical-verification', element: guard([P.VERIFY_SEAFARER_RECORDS], <MedicalVerificationPage />) },
{ path: 'sea-service-verification', element: guard([P.VERIFY_SEAFARER_RECORDS], <SeaServiceVerificationPage />) },