Merge pull request #53 from Tria-plc/WorkflowChange

This commit is contained in:
Nati Nigussie
2026-08-31 16:09:47 +03:00
committed by Nati
6 changed files with 105 additions and 4 deletions

3
.gitignore vendored
View File

@@ -36,3 +36,6 @@ local-packages/iamui-extracted/
# Playwright visual-regression artifacts (baselines under apps/e2e/visual are tracked)
test-results/
dist/visual-report/
branch_structure.json
temp_auto_push.bat
temp_interactive_push.bat

View File

@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import type { ApplicationDetail } from "@ema-platform/api";
import { resolveActions } from "./actions";
const REASONS = {
wrongStatus: "wrong status",
notAssigned: "not assigned",
noPermission: "no permission",
needsFlags: "needs flags",
needsCapital: "needs capital",
needsInspection: "needs inspection",
needsDocumentReviews: "needs document reviews",
inspectionNotYetDue: "not yet due",
};
/** Held by someone else, so ownership gating is what the assertions turn on. */
function detailFor(licenseTypeKey: string): ApplicationDetail {
return {
application: {
id: "app-1",
status: "SUBMITTED",
assignedOfficerId: "another-officer",
licenseType: { key: licenseTypeKey },
},
availableEvents: ["claim", "assign", "request-adjustment"],
} as unknown as ApplicationDetail;
}
function resolve(licenseTypeKey: string) {
return resolveActions({
detail: detailFor(licenseTypeKey),
currentUserId: "me",
can: () => true,
reasons: REASONS,
flaggedCount: 1,
hasPendingInspection: false,
inspectionNotYetDue: false,
allDocumentsAccepted: true,
});
}
describe("resolveActions — seafarer certificates skip the queue", () => {
it("drops claim and assign for a CoC", () => {
const ids = resolve("CERTIFICATE_OF_COMPETENCY").map((a) => a.id);
expect(ids).not.toContain("claim");
expect(ids).not.toContain("assign");
expect(ids).not.toContain("assign-reviewer");
});
it("lets an officer act on a CoP another officer holds", () => {
const adjust = resolve("CERTIFICATE_OF_PROFICIENCY").find(
(a) => a.id === "request-adjustment",
);
expect(adjust?.enabled).toBe(true);
});
it("leaves an ordinary licence claimed and owner-gated", () => {
const actions = resolve("FREIGHT_FORWARDER");
expect(actions.map((a) => a.id)).toContain("claim");
const adjust = actions.find((a) => a.id === "request-adjustment");
expect(adjust?.enabled).toBe(false);
expect(adjust?.disabledReason).toBe(REASONS.notAssigned);
});
});

View File

@@ -1,4 +1,5 @@
import type { ApplicationDetail, LicenseStatus } from '@ema-platform/api';
import { isSeafarerCertificate } from './license-types';
/**
* Where an action is rendered. One tier per action, decided here rather than
@@ -412,6 +413,10 @@ export function resolveActions(ctx: ResolveContext): ResolvedAction[] {
const { detail, currentUserId, can, reasons } = ctx;
const app = detail.application;
const serverEvents = detail.availableEvents;
// A seafarer's certificate is reviewed by whoever opens it. There is no
// queue to claim it from and no reviewer to assign, so the two actions that
// move it through one are dropped and ownership stops gating the decisions.
const skipsAssignment = isSeafarerCertificate(app.licenseType?.key);
return ACTIONS.filter((action) => can(action.permissions)).flatMap<ResolvedAction>(
(action) => {
@@ -428,6 +433,15 @@ export function resolveActions(ctx: ResolveContext): ResolvedAction[] {
return [];
}
if (
skipsAssignment &&
(action.id === 'claim' ||
action.id === 'assign' ||
action.id === 'assign-reviewer')
) {
return [];
}
// Scheduling and recording are the same slot at the same status; which
// one applies depends on whether an inspection is already booked.
if (action.id === 'schedule-inspection' && ctx.hasPendingInspection) return [];
@@ -465,7 +479,9 @@ export function resolveActions(ctx: ResolveContext): ResolvedAction[] {
// Decisions belong to whoever holds the application.
const needsOwnership =
action.tier === 'primary' && action.id !== 'confirm-payment';
!skipsAssignment &&
action.tier === 'primary' &&
action.id !== 'confirm-payment';
if (
needsOwnership &&
app.assignedOfficerId &&

View File

@@ -152,7 +152,13 @@ const PRESENTATION: Record<string, LicenseTypePresentation> = {
const CERTIFICATE_KEY_PREFIXES = ['COC_', 'COP_', 'GOC_'];
const CERTIFICATE_SECTIONS: DetailSection[] = ['overview', 'documents'];
function isSeafarerCertificate(key: string): boolean {
/**
* CoC, CoP and the endorsement/GOC family: a seafarer's own certificate rather
* than an organisation's licence. They skip the claim/assign queue — see
* `resolveActions`.
*/
export function isSeafarerCertificate(key: string | undefined): boolean {
if (!key) return false;
return (
CERTIFICATE_KEY_PREFIXES.some((prefix) => key.startsWith(prefix)) ||
key === 'CERTIFICATE_OF_COMPETENCY' ||

File diff suppressed because one or more lines are too long

View File

@@ -1154,8 +1154,17 @@ export const licensingApi = baseApi
url: `/license-application-review/${id}/issue-certificate`,
method: 'POST',
}),
// The certificate this writes is what `License` lists read, so the
// issued register and the holder's own licences refresh with it —
// without them an officer issues a certificate that nobody's list shows.
invalidatesTags: (_r, error, id) =>
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
error
? []
: [
itemTag('LicenseApplication', id),
listTag('ApplicationQueue'),
listTag('License'),
],
}),
// ------------------------------------------------------------- pickup