Files
emaui/apps/backoffice/src/app/features/license-review/config/actions.ts
fitse-yotor 0ac75669d4 feat: add ExamStageActions component for exam fee handling
- Implemented ExamStageActions component to manage actions related to exam booking and payment based on application status.
- Added mock-base-query for development, providing a partial mock backend for various API endpoints.
- Introduced mock-data for simulating responses in the mock-base-query, covering profiles, vessels, applications, licenses, exams, and notifications.
2026-08-15 11:51:14 +03:00

325 lines
9.7 KiB
TypeScript

import type { ApplicationDetail, LicenseStatus } from '@ema-platform/api';
import { LICENSE_PERMISSIONS as PERMISSIONS } from '@ema-platform/auth';
/**
* 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'
| 'confirm-payment'
| 'print'
| 'copy-link'
| 'download-documents'
| 'generate-certificate'
| '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
{
id: 'claim',
tier: 'workflow',
labelKey: 'review.actions.claim',
from: ['SUBMITTED'],
permissions: ['can:claim:license-application'],
emphasis: 'light',
},
{
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'],
permissions: ['can:schedule:exam-candidate'],
emphasis: 'filled',
color: 'cyan',
},
{
id: 'confirm-payment',
tier: 'primary',
labelKey: 'review.actions.confirmPayment',
from: ['PAID'],
permissions: ['can:confirm:license-payment'],
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: 'generate-certificate',
tier: 'secondary',
labelKey: 'review.actions.generateCertificate',
from: ['CERTIFICATE_ISSUED'],
permissions: [PERMISSIONS.VIEW_APPLICATIONS],
},
{ 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;
};
/** Number of sections/documents the officer has flagged for correction. */
flaggedCount: number;
/** True when an inspection is scheduled and awaiting a result. */
hasPendingInspection: boolean;
}
/**
* 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.
*/
export function resolveActions(ctx: ResolveContext): ResolvedAction[] {
const { detail, currentUserId, can, reasons } = ctx;
const app = detail.application;
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 (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 === '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 };
},
);
}