Merge branch 'fix/coc-exam-workflow-defects' of github.com:Tria-plc/emaui into fix/coc-exam-workflow-defects

This commit is contained in:
mihretue
2026-09-01 15:18:27 +03:00
12 changed files with 117 additions and 14 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' ||

View File

@@ -114,7 +114,7 @@ const STATUS_COLOR: Record<string, string> = {
* keys because this page states its statuses the same way.
*/
const EXAM_STAGE_LABELS: Record<string, string> = {
ELIGIBLE_TO_REGISTER: 'Eligible — Register for a Sitting',
EXAM_PAID: 'Exam Paid',
REGISTERED: 'Exam Scheduled',
ATTENDANCE_CONFIRMED: 'Exam Attendance Confirmed',
SITTING: 'Exam In Progress',

View File

@@ -29,8 +29,8 @@ describe('examStageFor', () => {
expect(examStageFor({ status: 'CERTIFICATE_ISSUED' })).toBeNull();
});
it('reads a paid application as eligible to register, not as waiting', () => {
expect(examStageFor({ status: 'EXAM_PAID' })).toBe('ELIGIBLE_TO_REGISTER');
it('reads a paid application as Exam Paid, not as awaiting a date', () => {
expect(examStageFor({ status: 'EXAM_PAID' })).toBe('EXAM_PAID');
});
it('reports a registration whose attendance has not been taken', () => {

View File

@@ -16,7 +16,7 @@ import type { MyRegistration } from '../exams/pages/ExamsPage';
* already recorded — this only names what the records add up to.
*/
export type ExamStage =
| 'ELIGIBLE_TO_REGISTER'
| 'EXAM_PAID'
| 'REGISTERED'
| 'ATTENDANCE_CONFIRMED'
| 'SITTING'
@@ -41,9 +41,11 @@ export function examStageFor(
if (app.status === 'EXAM_PASSED') return 'PASSED';
if (app.status === 'EXAM_FAILED') return 'FAILED';
// EXAM_PAID is "prerequisites done, sitting not yet chosen": the fee has
// cleared and nothing is left but for the candidate to pick a session.
if (app.status === 'EXAM_PAID') return 'ELIGIBLE_TO_REGISTER';
// The fee has cleared and nothing is left but for the candidate to pick a
// session. Named after the status rather than after what the candidate
// should do next: "Eligible to Register" read as a status of its own and
// said less than the plain one.
if (app.status === 'EXAM_PAID') return 'EXAM_PAID';
if (app.status !== 'EXAM_SCHEDULED') return null;

View File

@@ -244,7 +244,7 @@ export const am: Translations = {
empty: 'እስካሁን ምንም ፍቃድ አልተሰጥዎትም። ማመልከቻ ከተፈቀደና ከተከፈለ በኋላ እዚህ ይታያል።',
},
examStage: {
ELIGIBLE_TO_REGISTER: "ብቁ ነዎት — ለፈተና ይመዝገቡ",
EXAM_PAID: "የፈተና ክፍያ ተከፍሏል",
REGISTERED: "የፈተና ቀን፦ {{date}}",
ATTENDANCE_CONFIRMED: "መገኘት ተረጋግጧል",
SITTING: "ፈተና በመካሄድ ላይ",

View File

@@ -249,7 +249,7 @@ export const en = {
* from the application status alone — see exam-stage.ts.
*/
examStage: {
ELIGIBLE_TO_REGISTER: 'Eligible — register for a sitting',
EXAM_PAID: 'Exam Paid',
REGISTERED: 'Exam scheduled: {{date}}',
ATTENDANCE_CONFIRMED: 'Attendance confirmed',
SITTING: 'Exam in progress',

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

View File

@@ -88,7 +88,7 @@ export const STATUS_LABELS: Record<LicenseStatus, string> = {
EXAM_PAYMENT_PENDING: 'Exam Fee Due',
// Nobody assigns a date any more — the fee clearing is what makes the
// candidate eligible to register for a published sitting themselves.
EXAM_PAID: 'Eligible to Register',
EXAM_PAID: 'Exam Paid',
EXAM_SCHEDULED: 'Exam Scheduled',
EXAM_PASSED: 'Exam Passed',
EXAM_FAILED: 'Exam Not Passed',