mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 13:02:50 +00:00
407 lines
13 KiB
TypeScript
407 lines
13 KiB
TypeScript
import type { ApplicationDetail, LicenseStatus } from '@ema-platform/api';
|
|
|
|
/**
|
|
* Where an action is rendered. One tier per action, decided here rather than
|
|
* by whoever happens to be laying out the page — that is what produced buttons
|
|
* scattered down the right-hand column with no ordering principle.
|
|
*/
|
|
export type ActionTier =
|
|
/** Approve / Request Adjustment / Reject. Decision Bar, right. Max three. */
|
|
| 'primary'
|
|
/** Claim, Assign, Escalate, Hold, Return. Decision Bar, left. */
|
|
| 'workflow'
|
|
/** Print, Export, Certificate, Audit, Copy Link. Overflow menu. */
|
|
| 'secondary'
|
|
/** Void, Revoke, Cancel. Overflow menu, separated, red, typed confirm. */
|
|
| 'destructive';
|
|
|
|
export type ActionId =
|
|
| 'claim'
|
|
| 'assign'
|
|
| 'escalate'
|
|
| 'hold'
|
|
| 'resume'
|
|
| 'complete-review'
|
|
| 'approve-documents'
|
|
| 'schedule-inspection'
|
|
| 'record-inspection'
|
|
| 'final-approve'
|
|
| 'request-adjustment'
|
|
| 'reject'
|
|
| 'schedule-exam'
|
|
| 'record-exam-outcome'
|
|
| 'confirm-payment'
|
|
| 'schedule-issuance'
|
|
| 'issue-certificate'
|
|
| 'print'
|
|
| 'copy-link'
|
|
| 'download-documents'
|
|
| 'audit-trail';
|
|
|
|
export interface ActionDefinition {
|
|
id: ActionId;
|
|
tier: ActionTier;
|
|
labelKey: string;
|
|
/** Statuses the action can be fired from. Mirrors the API transition table. */
|
|
from?: LicenseStatus[];
|
|
/** Any one of these authorises it. Omitted means no permission needed. */
|
|
permissions?: string[];
|
|
/** Only one primary action is ever filled; everything else is light. */
|
|
emphasis?: 'filled' | 'light' | 'subtle';
|
|
color?: string;
|
|
/** Requires a typed reason before it will submit. */
|
|
requiresReason?: boolean;
|
|
/** Cannot be undone — the confirmation says so explicitly. */
|
|
irreversible?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Every action an officer can take, in one place.
|
|
*
|
|
* Actions absent from this list are absent because the API has no endpoint for
|
|
* them. Void, Revoke and Cancel are the notable gaps: `SUSPEND_LICENSE` and
|
|
* `CANCEL_LICENSE` permissions exist, but no route does either, so rendering
|
|
* them would be a button that cannot work.
|
|
*/
|
|
export const ACTIONS: ActionDefinition[] = [
|
|
// ------------------------------------------------------------- workflow
|
|
// Claim is deliberately absent here: an officer claims from the queue
|
|
// (LicenseQueuePage), not from this detail page. That implementation is
|
|
// separate — see LicenseQueuePage/actions.tsx — and is unaffected by this.
|
|
{
|
|
id: 'assign',
|
|
tier: 'workflow',
|
|
labelKey: 'review.actions.assign',
|
|
from: [
|
|
'SUBMITTED',
|
|
'UNDER_REVIEW',
|
|
'UNDER_EVALUATION',
|
|
'INSPECTION_PENDING',
|
|
'INSPECTION_COMPLETED',
|
|
'ON_HOLD',
|
|
],
|
|
permissions: ['can:assign:license-application'],
|
|
emphasis: 'subtle',
|
|
},
|
|
{
|
|
id: 'escalate',
|
|
tier: 'workflow',
|
|
labelKey: 'review.actions.escalate',
|
|
from: ['UNDER_REVIEW', 'UNDER_EVALUATION', 'INSPECTION_COMPLETED'],
|
|
permissions: ['can:escalate:license-application'],
|
|
emphasis: 'subtle',
|
|
requiresReason: true,
|
|
},
|
|
{
|
|
id: 'hold',
|
|
tier: 'workflow',
|
|
labelKey: 'review.actions.hold',
|
|
from: [
|
|
'UNDER_REVIEW',
|
|
'UNDER_EVALUATION',
|
|
'INSPECTION_PENDING',
|
|
'INSPECTION_COMPLETED',
|
|
],
|
|
permissions: ['can:hold:license-application'],
|
|
emphasis: 'subtle',
|
|
requiresReason: true,
|
|
},
|
|
{
|
|
id: 'resume',
|
|
tier: 'workflow',
|
|
labelKey: 'review.actions.resume',
|
|
from: ['ON_HOLD'],
|
|
permissions: ['can:hold:license-application'],
|
|
emphasis: 'light',
|
|
},
|
|
|
|
// -------------------------------------------------------------- primary
|
|
{
|
|
id: 'complete-review',
|
|
tier: 'primary',
|
|
labelKey: 'review.actions.completeReview',
|
|
from: ['UNDER_REVIEW'],
|
|
permissions: [
|
|
'can:review:license-application',
|
|
'can:evaluate:license-application',
|
|
],
|
|
emphasis: 'filled',
|
|
},
|
|
{
|
|
id: 'approve-documents',
|
|
tier: 'primary',
|
|
labelKey: 'review.actions.approveDocuments',
|
|
from: ['UNDER_EVALUATION'],
|
|
permissions: ['can:evaluate:license-application'],
|
|
emphasis: 'filled',
|
|
},
|
|
{
|
|
id: 'schedule-inspection',
|
|
tier: 'primary',
|
|
labelKey: 'review.actions.scheduleInspection',
|
|
from: ['INSPECTION_PENDING'],
|
|
permissions: ['can:create:inspection'],
|
|
emphasis: 'filled',
|
|
},
|
|
{
|
|
id: 'record-inspection',
|
|
tier: 'primary',
|
|
labelKey: 'review.actions.recordInspection',
|
|
from: ['INSPECTION_PENDING'],
|
|
permissions: ['can:update:inspection'],
|
|
emphasis: 'filled',
|
|
},
|
|
{
|
|
id: 'final-approve',
|
|
tier: 'primary',
|
|
labelKey: 'review.actions.finalApprove',
|
|
from: ['INSPECTION_COMPLETED', 'UNDER_EVALUATION'],
|
|
permissions: ['can:approve:license-application'],
|
|
emphasis: 'filled',
|
|
color: 'teal',
|
|
irreversible: true,
|
|
},
|
|
{
|
|
id: 'request-adjustment',
|
|
tier: 'primary',
|
|
labelKey: 'review.actions.requestAdjustment',
|
|
from: ['UNDER_REVIEW', 'UNDER_EVALUATION', 'INSPECTION_COMPLETED'],
|
|
permissions: ['can:request-adjustment:license-application'],
|
|
emphasis: 'light',
|
|
color: 'orange',
|
|
requiresReason: true,
|
|
},
|
|
{
|
|
id: 'reject',
|
|
tier: 'primary',
|
|
labelKey: 'review.actions.reject',
|
|
from: [
|
|
'UNDER_REVIEW',
|
|
'UNDER_EVALUATION',
|
|
'INSPECTION_PENDING',
|
|
'INSPECTION_COMPLETED',
|
|
],
|
|
permissions: ['can:reject:license-application'],
|
|
emphasis: 'light',
|
|
color: 'red',
|
|
requiresReason: true,
|
|
irreversible: true,
|
|
},
|
|
{
|
|
id: 'schedule-exam',
|
|
tier: 'primary',
|
|
labelKey: 'review.actions.scheduleExam',
|
|
// Only after the examination fee clears — scheduling an unpaid candidate
|
|
// is what the EXAM_PAID gate exists to prevent.
|
|
from: ['EXAM_PAID'],
|
|
// Matches the controller's guard on `:id/exam-scheduled`
|
|
// (`LICENSE_PERMISSIONS.MANAGE_EXAMS`) — the previous string didn't
|
|
// correspond to any real permission constant, so this button could never
|
|
// actually be granted to anyone.
|
|
permissions: ['can:manage:exams'],
|
|
emphasis: 'filled',
|
|
color: 'cyan',
|
|
},
|
|
{
|
|
id: 'record-exam-outcome',
|
|
tier: 'primary',
|
|
labelKey: 'review.actions.recordExamOutcome',
|
|
// Only once the candidate has actually sat the exam.
|
|
from: ['EXAM_SCHEDULED'],
|
|
permissions: ['can:publish:exam-result'],
|
|
emphasis: 'filled',
|
|
color: 'cyan',
|
|
},
|
|
{
|
|
id: 'confirm-payment',
|
|
tier: 'primary',
|
|
labelKey: 'review.actions.confirmPayment',
|
|
from: ['PAID'],
|
|
permissions: ['can:confirm:license-payment'],
|
|
emphasis: 'filled',
|
|
color: 'teal',
|
|
},
|
|
{
|
|
id: 'schedule-issuance',
|
|
tier: 'primary',
|
|
labelKey: 'review.actions.scheduleIssuance',
|
|
// Only reachable for a license type with `requiresIssuanceScheduling` —
|
|
// everything else cascades straight to CERTIFICATE_ISSUED and never
|
|
// shows PAYMENT_CONFIRMED with this action available (the server's
|
|
// `availableEvents` omits it there, same as the rest of this list).
|
|
from: ['PAYMENT_CONFIRMED'],
|
|
permissions: ['can:schedule:license-issuance'],
|
|
emphasis: 'filled',
|
|
color: 'cyan',
|
|
},
|
|
{
|
|
id: 'issue-certificate',
|
|
tier: 'primary',
|
|
labelKey: 'review.actions.issueCertificate',
|
|
from: ['SCHEDULED'],
|
|
permissions: ['can:issue:license-certificate'],
|
|
emphasis: 'filled',
|
|
color: 'teal',
|
|
},
|
|
|
|
// ------------------------------------------------------------ secondary
|
|
{ id: 'print', tier: 'secondary', labelKey: 'review.actions.print' },
|
|
{ id: 'copy-link', tier: 'secondary', labelKey: 'review.actions.copyLink' },
|
|
{
|
|
id: 'download-documents',
|
|
tier: 'secondary',
|
|
labelKey: 'review.actions.downloadDocuments',
|
|
},
|
|
{ id: 'audit-trail', tier: 'secondary', labelKey: 'review.actions.auditTrail' },
|
|
];
|
|
|
|
export interface ResolvedAction extends ActionDefinition {
|
|
/** False when the officer can see it but cannot fire it right now. */
|
|
enabled: boolean;
|
|
/**
|
|
* Why it is disabled, already translated. Never null when `enabled` is
|
|
* false — a greyed-out control with no explanation is the thing this whole
|
|
* model exists to prevent.
|
|
*/
|
|
disabledReason?: string;
|
|
}
|
|
|
|
export interface ResolveContext {
|
|
detail: ApplicationDetail;
|
|
currentUserId: string;
|
|
can: (permissions?: string[]) => boolean;
|
|
/** Translated strings for the disabled explanations. */
|
|
reasons: {
|
|
wrongStatus: string;
|
|
notAssigned: string;
|
|
noPermission: string;
|
|
needsFlags: string;
|
|
needsCapital: string;
|
|
needsInspection: string;
|
|
needsDocumentReviews: string;
|
|
};
|
|
/** Number of sections/documents the officer has flagged for correction. */
|
|
flaggedCount: number;
|
|
/** True when an inspection is scheduled and awaiting a result. */
|
|
hasPendingInspection: boolean;
|
|
/**
|
|
* False while any uploaded document is still unjudged or rejected. Approving
|
|
* is a statement that every document was checked, so the button stays dead
|
|
* until the officer has actually judged each one.
|
|
*/
|
|
allDocumentsAccepted: boolean;
|
|
}
|
|
|
|
/**
|
|
* Action ids that are workflow events, so `availableEvents` decides them.
|
|
*
|
|
* The rest (`schedule-inspection`, `schedule-exam`, the secondary tools) are
|
|
* screens and side effects rather than transitions, and the server has no
|
|
* opinion on them — those keep using their own `from` list.
|
|
*/
|
|
const WORKFLOW_EVENT_IDS = new Set<ActionId>([
|
|
'claim',
|
|
'assign',
|
|
'escalate',
|
|
'hold',
|
|
'resume',
|
|
'complete-review',
|
|
'approve-documents',
|
|
'record-inspection',
|
|
'final-approve',
|
|
'request-adjustment',
|
|
'reject',
|
|
'confirm-payment',
|
|
'schedule-issuance',
|
|
'issue-certificate',
|
|
]);
|
|
|
|
/**
|
|
* Which actions to render, and for each, whether it can fire and why not.
|
|
*
|
|
* Actions the user has no permission for are dropped entirely; actions that
|
|
* are merely unavailable right now are kept and disabled with a reason, so the
|
|
* officer can see what the next step would be rather than wondering whether
|
|
* the screen is broken.
|
|
*
|
|
* For anything that is a workflow event, `detail.availableEvents` is the
|
|
* authority on what fires from here — it comes from the same transition table
|
|
* the server validates against, and it is workflow-profile aware. The local
|
|
* `from` lists describe the licence course only, so a registration (which skips
|
|
* evaluation and inspection, and approves straight out of UNDER_REVIEW) was
|
|
* offered Complete Review — rejected server-side with
|
|
* `event_not_available_for_service` — while Final Approve, the one action that
|
|
* would work, was hidden.
|
|
*/
|
|
export function resolveActions(ctx: ResolveContext): ResolvedAction[] {
|
|
const { detail, currentUserId, can, reasons } = ctx;
|
|
const app = detail.application;
|
|
const serverEvents = detail.availableEvents;
|
|
|
|
return ACTIONS.filter((action) => can(action.permissions)).flatMap<ResolvedAction>(
|
|
(action) => {
|
|
// Status-scoped actions vanish outside their stage rather than piling up
|
|
// as a column of permanently dead buttons.
|
|
if (WORKFLOW_EVENT_IDS.has(action.id)) {
|
|
// Tolerate an older server that sends no list rather than rendering an
|
|
// empty action bar.
|
|
if (serverEvents?.length && !serverEvents.includes(action.id)) return [];
|
|
if (!serverEvents?.length && action.from && !action.from.includes(app.status)) {
|
|
return [];
|
|
}
|
|
} else if (action.from && !action.from.includes(app.status)) {
|
|
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 [];
|
|
if (action.id === 'record-inspection' && !ctx.hasPendingInspection) return [];
|
|
|
|
const disabled = (reason: string): ResolvedAction => ({
|
|
...action,
|
|
enabled: false,
|
|
disabledReason: reason,
|
|
});
|
|
|
|
// Decisions belong to whoever holds the application.
|
|
const needsOwnership =
|
|
action.tier === 'primary' && action.id !== 'confirm-payment';
|
|
if (
|
|
needsOwnership &&
|
|
app.assignedOfficerId &&
|
|
app.assignedOfficerId !== currentUserId
|
|
) {
|
|
return disabled(reasons.notAssigned);
|
|
}
|
|
|
|
if (
|
|
(action.id === 'approve-documents' || action.id === 'final-approve') &&
|
|
!ctx.allDocumentsAccepted
|
|
) {
|
|
return disabled(reasons.needsDocumentReviews);
|
|
}
|
|
|
|
if (action.id === 'request-adjustment' && ctx.flaggedCount === 0) {
|
|
return disabled(reasons.needsFlags);
|
|
}
|
|
|
|
if (action.id === 'final-approve') {
|
|
const threshold = app.licenseType?.capitalThreshold;
|
|
const needsCapital = threshold != null && Number(threshold) > 0;
|
|
if (needsCapital && app.capitalAmountVerified == null) {
|
|
return disabled(reasons.needsCapital);
|
|
}
|
|
if (
|
|
app.licenseType?.inspectionRequired &&
|
|
app.status !== 'INSPECTION_COMPLETED'
|
|
) {
|
|
return disabled(reasons.needsInspection);
|
|
}
|
|
}
|
|
|
|
return { ...action, enabled: true };
|
|
},
|
|
);
|
|
}
|