mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-06 06:33:40 +00:00
Merge branch 'fix/coc-exam-workflow-defects' of github.com:Tria-plc/emaui into fix/coc-exam-workflow-defects
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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' ||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -244,7 +244,7 @@ export const am: Translations = {
|
||||
empty: 'እስካሁን ምንም ፍቃድ አልተሰጥዎትም። ማመልከቻ ከተፈቀደና ከተከፈለ በኋላ እዚህ ይታያል።',
|
||||
},
|
||||
examStage: {
|
||||
ELIGIBLE_TO_REGISTER: "ብቁ ነዎት — ለፈተና ይመዝገቡ",
|
||||
EXAM_PAID: "የፈተና ክፍያ ተከፍሏል",
|
||||
REGISTERED: "የፈተና ቀን፦ {{date}}",
|
||||
ATTENDANCE_CONFIRMED: "መገኘት ተረጋግጧል",
|
||||
SITTING: "ፈተና በመካሄድ ላይ",
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user