feat: add seafarer scope display to profile and progress tracking panel to license review

This commit is contained in:
estifanos
2026-09-08 09:44:52 +00:00
parent 3ad74cf745
commit f11ccb958d
6 changed files with 1044 additions and 7 deletions

View File

@@ -0,0 +1,182 @@
import {
Badge,
Group,
Paper,
Stack,
Text,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import {
IconAlertTriangle,
IconCheck,
IconClock,
IconUser,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useDateDisplayer } from "@ema-platform/shared";
import type { ReviewStep, ReviewStepsResult } from "../steps";
interface NextStepsPanelProps {
result: ReviewStepsResult;
}
/**
* What is still needed to complete the request, step by step.
*
* The Decision Bar answers "what can I press"; the Progress timeline answers
* "what has happened". Neither answers the question an officer opening a file
* actually has — what has to happen for this to finish, which of it is mine,
* and what is holding the next move up. That is one list, with the current
* step called out and its blocker stated in words rather than left in a
* disabled button's tooltip.
*/
export function NextStepsPanel({ result }: NextStepsPanelProps) {
const { t } = useTranslation();
const showDate = useDateDisplayer();
if (result.steps.length === 0) return null;
// The finish line is not work, and a rejected file's remaining steps will
// never be taken — neither reads as "N left".
const remaining = result.closed
? 0
: result.steps.filter((s) => s.state !== "done" && !s.terminal).length;
return (
<Paper withBorder p="md">
<Group justify="space-between" mb="sm" wrap="nowrap">
<Text fw={600} size="sm">
{t("review.steps.title", "Steps to complete")}
</Text>
{remaining > 0 && (
<Text size="xs" c="dimmed">
{t("review.steps.remaining", {
count: remaining,
defaultValue_one: "1 left",
defaultValue_other: "{{count}} left",
})}
</Text>
)}
</Group>
{result.note && (
<Text size="xs" c="orange" mb="sm">
{result.note}
</Text>
)}
<Stack gap="sm">
{result.steps.map((step, index) => (
<StepRow
key={step.id}
step={step}
number={index + 1}
completedLabel={
step.completedAt ? showDate(step.completedAt) : undefined
}
/>
))}
</Stack>
</Paper>
);
}
function StepRow({
step,
number,
completedLabel,
}: {
step: ReviewStep;
number: number;
completedLabel?: string;
}) {
const { t } = useTranslation();
const done = step.state === "done";
const current = step.state === "current";
const waiting = current && step.owner === "applicant";
return (
<Group gap="xs" wrap="nowrap" align="flex-start">
<ThemeIcon
size={20}
radius="xl"
variant={current ? "filled" : "light"}
color={done ? "teal" : current ? (waiting ? "orange" : "blue") : "gray"}
>
{done ? (
<IconCheck size={12} />
) : waiting ? (
<IconClock size={12} />
) : (
<Text size="10px" fw={700}>
{number}
</Text>
)}
</ThemeIcon>
<div style={{ minWidth: 0, flex: 1 }}>
<Group gap={6} wrap="nowrap" align="center">
<Text size="xs" fw={current ? 700 : 500} c={done ? "dimmed" : undefined}>
{step.title}
</Text>
{current && (
<Badge
size="xs"
variant="light"
color={waiting ? "orange" : "blue"}
leftSection={waiting ? undefined : <IconUser size={9} />}
>
{waiting
? t("review.steps.waitingOnApplicant", "Applicant")
: t("review.steps.yourMove", "You")}
</Badge>
)}
</Group>
{/* Done steps carry their date only — the instruction has served its
purpose and repeating it buries the step that is actually live. */}
{done ? (
completedLabel && (
<Text size="xs" c="dimmed">
{completedLabel}
</Text>
)
) : (
<Text size="xs" c="dimmed">
{step.detail}
</Text>
)}
{step.progress && (
<Text size="xs" c="dimmed" fw={500}>
{step.progress}
</Text>
)}
{step.blockedBy && (
<Tooltip
label={t(
"review.steps.blockedHint",
"Clear this first — the action stays disabled until it is done",
)}
withArrow
multiline
w={240}
>
<Group gap={4} wrap="nowrap" align="flex-start" mt={2}>
<IconAlertTriangle
size={12}
style={{ marginTop: 2, flexShrink: 0 }}
color="var(--mantine-color-orange-6)"
/>
<Text size="xs" c="orange">
{step.blockedBy}
</Text>
</Group>
</Tooltip>
)}
</div>
</Group>
);
}

View File

@@ -100,14 +100,17 @@ import { DocumentsTab } from "../../components/DocumentsTab";
import { FormDetailsTab } from "../../components/FormDetailsTab";
import { ApplicantCard } from "../../components/ApplicantCard";
import { ExamStatePanel } from "../../components/ExamStatePanel";
import { NextStepsPanel } from "../../components/NextStepsPanel";
import { useGetLocationsQuery } from "../../../location/api/location-api";
import { computeSla } from "../../sla";
import { reviewStaffColumns } from "./columns";
import {
evaluateEligibility,
isSeafarerCertificate,
presentationFor,
requiresPassedInspection,
} from "../../config/license-types";
import { buildReviewSteps } from "../../steps";
import {
resolveActions,
type ActionId,
@@ -248,10 +251,8 @@ export function LicenseReviewPage() {
const [resumeApplication] = useResumeApplicationMutation();
const [escalateApplication] = useEscalateApplicationMutation();
const [assignApplication] = useAssignApplicationMutation();
const [assignReviewer, { isLoading: assigningReviewer }] =
useAssignReviewerMutation();
const [assignInspector, { isLoading: assigningInspector }] =
useAssignInspectorMutation();
const [assignReviewer] = useAssignReviewerMutation();
const [assignInspector] = useAssignInspectorMutation();
const [reportReview] = useReportReviewMutation();
const [reportInspection] = useReportInspectionMutation();
@@ -651,21 +652,33 @@ export function LicenseReviewPage() {
const status = app.status;
const staffPaged = staffTable.paginate(data.staff);
const presentation = presentationFor(app.licenseType?.key);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- i18next's overloaded TFunction type doesn't structurally match a plain callback signature
const sla = computeSla(
app,
undefined,
i18n.language,
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- i18next's overloaded TFunction type doesn't structurally match a plain callback signature
(key, options) => t(key, options as any) as string,
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- i18next's overloaded TFunction type doesn't structurally match a plain callback signature
const eligibility = evaluateEligibility(
app,
app.licenseType,
i18n.language,
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- i18next's overloaded TFunction type doesn't structurally match a plain callback signature
(key, options) => t(key, options as any) as string,
);
// What is still outstanding, in order, with the current step's blocker
// spelled out. Derived from the same status and resolved actions the
// Decision Bar runs on, so the panel never offers a step the server refuses.
const stepPlan = buildReviewSteps({
detail: data,
actions,
documentProgress,
skipsAssignment: isSeafarerCertificate(app.licenseType),
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- i18next's overloaded TFunction type doesn't structurally match a plain callback signature
t: (key, options) => t(key, options as any) as string,
});
const rawThreshold = app.licenseType?.capitalThreshold;
const threshold =
rawThreshold === null || rawThreshold === undefined
@@ -1126,11 +1139,27 @@ export function LicenseReviewPage() {
<Grid>
{/* Zone 1 — sticky summary rail. */}
<Grid.Col span={{ base: 12, md: 3 }}>
<Stack style={{ position: "sticky", top: 16 }}>
{/* Sticky, but bounded: with the steps panel the rail can outgrow a
short viewport, and a sticky element taller than the screen simply
pins its bottom off-screen. Scrolling inside the rail keeps every
panel reachable. */}
<Stack
style={{
position: "sticky",
top: 16,
maxHeight: "calc(100vh - 32px)",
overflowY: "auto",
}}
>
{/* Who, before what: a person-centric review is about the applicant,
and the licence facts below are the context. */}
{isPersonal && applicant && <ApplicantCard applicant={applicant} />}
{/* First in the rail: an officer opens a file to move it on, and
the answer to "what does this still need" was previously only
inferable from a disabled button and the status badge. */}
<NextStepsPanel result={stepPlan} />
<Paper withBorder p="md">
<Text fw={600} size="sm" mb="sm">
{t("review.summary", "Summary")}

View File

@@ -0,0 +1,237 @@
import { describe, expect, it } from 'vitest';
import type {
ApplicationDetail,
LicenseStatus,
LicenseType,
StatusHistoryEntry,
} from '@ema-platform/api';
import type { ResolvedAction } from './config/actions';
import { buildReviewSteps } from './steps';
/** Resolves `defaultValue` itself, so the assertions read as English. */
const t = (key: string, options?: unknown): string => {
if (typeof options === 'string') return options;
const opts = (options ?? {}) as Record<string, unknown>;
const template = String(opts['defaultValue'] ?? key);
return template.replace(/\{\{(\w+)\}\}/g, (_, name) => String(opts[name] ?? ''));
};
function licenseType(overrides: Partial<LicenseType> = {}): LicenseType {
return {
key: 'FREIGHT_FORWARDER',
inspectionRequired: true,
issuesCertificate: true,
requiresIssuanceScheduling: false,
feeNewApplication: 5000,
...overrides,
} as LicenseType;
}
function detailAt(
status: LicenseStatus,
history: LicenseStatus[] = [],
type: LicenseType = licenseType(),
): ApplicationDetail {
return {
application: { status, kind: 'NEW', licenseType: type },
history: history.map(
(toStatus, index) =>
({
id: String(index),
toStatus,
createdAt: `2026-01-0${index + 1}T00:00:00.000Z`,
}) as StatusHistoryEntry,
),
availableEvents: [],
} as unknown as ApplicationDetail;
}
const build = (
detail: ApplicationDetail,
actions: ResolvedAction[] = [],
documentProgress = { acceptedCount: 0, total: 0 },
) =>
buildReviewSteps({
detail,
actions,
documentProgress,
skipsAssignment: false,
t,
});
describe('buildReviewSteps', () => {
it('marks the passed stages done and the current status live', () => {
const { steps } = build(
detailAt('UNDER_REVIEW', ['SUBMITTED', 'UNDER_REVIEW']),
);
const byId = Object.fromEntries(steps.map((s) => [s.id, s]));
expect(byId['intake'].state).toBe('done');
expect(byId['intake'].completedAt).toBe('2026-01-02T00:00:00.000Z');
expect(byId['review'].state).toBe('current');
expect(byId['decision'].state).toBe('upcoming');
});
it('carries the disabled reason of the current step as its blocker', () => {
const { steps } = build(detailAt('UNDER_REVIEW', ['UNDER_REVIEW']), [
{
id: 'complete-review',
tier: 'primary',
labelKey: 'x',
enabled: false,
disabledReason: 'Accept all documents first',
} as ResolvedAction,
]);
const review = steps.find((s) => s.id === 'review');
expect(review?.actionId).toBe('complete-review');
expect(review?.blockedBy).toBe('Accept all documents first');
});
it('counts the documents still to accept on the live review step', () => {
const { steps } = build(detailAt('UNDER_REVIEW'), [], {
acceptedCount: 2,
total: 5,
});
expect(steps.find((s) => s.id === 'review')?.progress).toBe(
'2 of 5 documents accepted',
);
});
it('drops the inspection stages for a type that needs no site visit', () => {
const { steps } = build(
detailAt('UNDER_EVALUATION', [], licenseType({ inspectionRequired: false })),
);
expect(steps.map((s) => s.id)).not.toContain('inspection');
// The decision is taken from UNDER_EVALUATION instead.
expect(steps.find((s) => s.id === 'decision')?.state).toBe('current');
});
it('hands the live step to the applicant while the file is out for correction', () => {
const { steps } = build(
detailAt('RESUBMIT_REQUIRED', ['SUBMITTED', 'UNDER_REVIEW']),
);
const review = steps.find((s) => s.id === 'review');
expect(review?.state).toBe('current');
expect(review?.owner).toBe('applicant');
expect(review?.actionId).toBeUndefined();
});
it('leaves the stopped stage unfinished while the file is on hold', () => {
const result = build(
detailAt('ON_HOLD', ['SUBMITTED', 'UNDER_REVIEW', 'ON_HOLD']),
);
expect(result.noteActionId).toBe('resume');
expect(result.steps.find((s) => s.id === 'review')?.state).toBe('upcoming');
expect(result.steps.find((s) => s.id === 'intake')?.state).toBe('done');
});
it('runs the eligibility/exam course for an examined certificate', () => {
const { steps } = build(
detailAt(
'ELIGIBILITY_PAID',
['ELIGIBILITY_PAYMENT_PENDING', 'ELIGIBILITY_PAID'],
licenseType({ key: 'COC_DECK', requiresExamination: true }),
),
);
const ids = steps.map((s) => s.id);
expect(ids).toEqual([
'eligibility-fee',
'decide',
'exam',
'payment',
'confirm-payment',
'issue',
'complete',
]);
expect(steps.find((s) => s.id === 'decide')?.state).toBe('current');
expect(steps.find((s) => s.id === 'exam')?.owner).toBe('applicant');
});
it('adds the pickup booking only for a type that schedules issuance', () => {
const { steps } = build(
detailAt(
'PAYMENT_CONFIRMED',
[],
licenseType({ requiresIssuanceScheduling: true }),
),
);
expect(steps.find((s) => s.id === 'schedule-issuance')?.state).toBe('current');
});
it('says the request is finished once the certificate is out', () => {
const { steps, note } = build(
detailAt('CERTIFICATE_ISSUED', ['APPROVED', 'PAID', 'CERTIFICATE_ISSUED']),
);
expect(note).toBeUndefined();
expect(steps.at(-1)).toMatchObject({ id: 'complete', state: 'current' });
expect(steps.filter((s) => s.state === 'upcoming')).toHaveLength(0);
});
it('reports a rejection as closed rather than as a live step', () => {
const result = build(detailAt('REJECTED', ['SUBMITTED', 'UNDER_REVIEW']));
expect(result.note).toBe('Rejected — nothing further to do.');
expect(result.steps.some((s) => s.state === 'current')).toBe(false);
});
});
describe('buildReviewSteps — edge cases', () => {
it('never takes the blocker from Request Adjustment or Reject', () => {
const { steps } = build(detailAt('INSPECTION_COMPLETED'), [
{
id: 'request-adjustment',
tier: 'primary',
labelKey: 'x',
enabled: false,
disabledReason: 'Flag at least one item to request a correction',
} as ResolvedAction,
]);
const decision = steps.find((s) => s.id === 'decision');
expect(decision?.state).toBe('current');
expect(decision?.blockedBy).toBeUndefined();
expect(decision?.actionId).toBe('final-approve');
});
it('reads history oldest-first whatever order the API sent it in', () => {
const detail = detailAt('UNDER_REVIEW', ['UNDER_REVIEW', 'SUBMITTED']);
// Reverse the timestamps so SUBMITTED is genuinely the earlier entry.
detail.history[0].createdAt = '2026-01-02T00:00:00.000Z';
detail.history[1].createdAt = '2026-01-01T00:00:00.000Z';
const { steps } = build(detail);
expect(steps.find((s) => s.id === 'intake')?.completedAt).toBe(
'2026-01-02T00:00:00.000Z',
);
});
it('ends at approval for a type with no fee and nothing to issue', () => {
const { steps, note } = build(
detailAt(
'APPROVED',
[],
licenseType({
key: 'JOINT_INVESTOR',
inspectionRequired: false,
issuesCertificate: false,
feeNewApplication: null,
}),
),
);
expect(note).toBeUndefined();
expect(steps.map((s) => s.id)).toEqual(['intake', 'review', 'decision', 'complete']);
expect(steps.at(-1)).toMatchObject({ state: 'current', terminal: true });
});
it('parks a just-submitted examined certificate on the eligibility fee', () => {
const { steps, note } = build(
detailAt('SUBMITTED', [], licenseType({ requiresExamination: true })),
);
expect(note).toBeUndefined();
expect(steps[0]).toMatchObject({ id: 'eligibility-fee', state: 'current', owner: 'applicant' });
});
it('flags a rejection as closed', () => {
expect(build(detailAt('REJECTED')).closed).toBe(true);
expect(build(detailAt('ON_HOLD')).closed).toBeUndefined();
});
});

View File

@@ -0,0 +1,444 @@
import type { ApplicationDetail, LicenseStatus, LicenseType } from '@ema-platform/api';
import type { ActionId, ResolvedAction } from './config/actions';
/**
* Who the application is sitting with. An officer reading the rail needs to
* know whether a step is theirs to take or something they are waiting on.
*/
export type StepOwner = 'officer' | 'applicant';
export type StepState = 'done' | 'current' | 'upcoming';
export interface ReviewStep {
id: string;
/** Already translated. */
title: string;
/** What actually has to happen, in a sentence. Already translated. */
detail: string;
state: StepState;
owner: StepOwner;
/** The Decision Bar action that fires it, when the step has one. */
actionId?: ActionId;
/**
* Why the current step cannot be taken yet, already translated — the same
* sentence the disabled button carries, surfaced where the officer is
* looking for what to do next rather than hidden in a tooltip.
*/
blockedBy?: string;
/** Sub-count for the step in progress, e.g. documents accepted. */
progress?: string;
/** When the stage was left, for the ones already behind. */
completedAt?: string;
/** The finish line, not work — never counted as something left to do. */
terminal?: boolean;
}
export interface ReviewStepsResult {
steps: ReviewStep[];
/**
* Set when the application is off the main course — held, rejected, or a
* status this map has no stage for. Already translated; rendered instead of
* pretending one of the steps is live.
*/
note?: string;
/** Resume/claim-style action the note refers to, when there is one. */
noteActionId?: ActionId;
/** Rejected: the remaining steps will never be taken. */
closed?: boolean;
}
/** Loose enough to accept i18next's real `t` — see sla.ts for why. */
type Translate = (key: string, options?: unknown) => string;
interface StageTemplate {
id: string;
/** Statuses that mean the application is sitting at this stage. */
statuses: LicenseStatus[];
owner: StepOwner;
/**
* Actions that move it *forward*; the first one resolvable is the step's
* action and the only source of its blocker. Request Adjustment and Reject
* are deliberately absent: they are always offered at a decision stage, and
* their own disabled reasons ("flag an item first") say nothing about what
* is holding up the approval.
*/
actionIds?: ActionId[];
titleKey: string;
titleFallback: string;
detailKey: string;
detailFallback: string;
}
/** Stages whose work is document review, so the accepted count belongs on them. */
const DOCUMENT_STAGES = new Set(['review', 'evaluation', 'decision', 'decide']);
/** Statuses that end the application, with nothing further for anyone to do. */
const CLOSED_STATUSES: LicenseStatus[] = ['REJECTED', 'COMPLETED', 'CERTIFICATE_ISSUED'];
/**
* The stage list for one application.
*
* Built per licence type rather than as one fixed ladder: a type with no
* inspection has no inspection step, a type that issues nothing has no
* issuance step, and an examined certificate runs the eligibility/exam course
* instead of the licence one. Every status the workflow can hold is claimed by
* exactly one stage, so the current status maps to exactly one live step.
*/
function stagesFor(
licenseType: LicenseType | undefined,
opts: { examined: boolean; skipsAssignment: boolean; hasFee: boolean },
): StageTemplate[] {
const stages: StageTemplate[] = [];
const inspection = Boolean(licenseType?.inspectionRequired);
const issues = licenseType?.issuesCertificate !== false;
const schedules = Boolean(licenseType?.requiresIssuanceScheduling);
if (opts.examined) {
stages.push({
id: 'eligibility-fee',
// SUBMITTED: filed but the assessment fee not yet invoiced.
statuses: ['SUBMITTED', 'ELIGIBILITY_PAYMENT_PENDING'],
owner: 'applicant',
titleKey: 'review.steps.eligibilityFee.title',
titleFallback: 'Eligibility fee',
detailKey: 'review.steps.eligibilityFee.detail',
detailFallback:
'The applicant pays the assessment fee. Review starts once it clears.',
});
stages.push({
id: 'decide',
statuses: [
'ELIGIBILITY_PAID',
'UNDER_REVIEW',
'AWAITING_BIOMETRICS',
'RESUBMIT_REQUIRED',
'UNDER_EVALUATION',
'REVIEW_REPORTED',
],
owner: 'officer',
actionIds: ['final-approve'],
titleKey: 'review.steps.decide.title',
titleFallback: 'Review and decide',
detailKey: 'review.steps.decide.detail',
detailFallback:
'Check the form and accept every document, then approve eligibility, request corrections, or reject.',
});
stages.push({
id: 'exam',
statuses: [
'APPROVED',
'EXAM_PAYMENT_PENDING',
'EXAM_PAID',
'EXAM_SCHEDULED',
'EXAM_FAILED',
'EXAM_PASSED',
],
owner: 'applicant',
titleKey: 'review.steps.exam.title',
titleFallback: 'Examination',
detailKey: 'review.steps.exam.detail',
detailFallback:
'The candidate pays for a sitting, registers for a published schedule and sits the exam. The result comes from the exam engine — no officer action here.',
});
} else {
if (!opts.skipsAssignment) {
stages.push({
id: 'intake',
statuses: ['SUBMITTED'],
owner: 'officer',
actionIds: ['claim', 'assign-reviewer', 'assign'],
titleKey: 'review.steps.intake.title',
titleFallback: 'Take the file',
detailKey: 'review.steps.intake.detail',
detailFallback:
'Claim the application or assign a reviewer, so it has an owner.',
});
}
stages.push({
id: 'review',
statuses: [
...(opts.skipsAssignment ? (['SUBMITTED'] as LicenseStatus[]) : []),
'UNDER_REVIEW',
'AWAITING_BIOMETRICS',
'RESUBMIT_REQUIRED',
],
owner: 'officer',
actionIds: ['complete-review', 'report-review'],
titleKey: 'review.steps.review.title',
titleFallback: 'Review the application',
detailKey: 'review.steps.review.detail',
detailFallback:
'Read every form section and accept or reject each uploaded document, then complete the review.',
});
if (inspection) {
stages.push({
id: 'evaluation',
statuses: ['UNDER_EVALUATION', 'REVIEW_REPORTED'],
owner: 'officer',
actionIds: ['approve-documents', 'assign-inspector'],
titleKey: 'review.steps.evaluation.title',
titleFallback: 'Approve the documents',
detailKey: 'review.steps.evaluation.detail',
detailFallback:
'Confirm the paperwork is in order so the site visit can be booked.',
});
stages.push({
id: 'inspection',
statuses: ['INSPECTION_PENDING', 'INSPECTION_FAILED'],
owner: 'officer',
actionIds: [
'schedule-inspection',
'record-inspection',
'reschedule-inspection',
'assign-inspector',
],
titleKey: 'review.steps.inspection.title',
titleFallback: 'Inspection',
detailKey: 'review.steps.inspection.detail',
detailFallback:
'Book the site visit, then record the checklist outcome after it happens.',
});
}
stages.push({
id: 'decision',
statuses: [
'INSPECTION_COMPLETED',
'INSPECTION_REPORTED',
// Without an inspection leg these two are where the decision is taken,
// rather than a stage of their own.
...(inspection ? [] : (['UNDER_EVALUATION', 'REVIEW_REPORTED'] as LicenseStatus[])),
],
owner: 'officer',
actionIds: ['final-approve'],
titleKey: 'review.steps.decision.title',
titleFallback: 'Decision',
detailKey: 'review.steps.decision.detail',
detailFallback:
'Approve the application, send it back for corrections, or reject it.',
});
}
const paymentLeg = opts.hasFee || issues;
if (paymentLeg) {
stages.push({
id: 'payment',
statuses: [
// An examined certificate is already past APPROVED by the time the
// certificate fee falls due — the exam stage claims it there.
...(opts.examined ? [] : (['APPROVED'] as LicenseStatus[])),
'PAYMENT_PENDING',
],
owner: 'applicant',
titleKey: 'review.steps.payment.title',
titleFallback: 'Fee payment',
detailKey: 'review.steps.payment.detail',
detailFallback:
'The applicant pays the fee and uploads the bank slip. Nothing to do until it arrives.',
});
stages.push({
id: 'confirm-payment',
statuses: ['PAID'],
owner: 'officer',
actionIds: ['confirm-payment'],
titleKey: 'review.steps.confirmPayment.title',
titleFallback: 'Confirm the payment',
detailKey: 'review.steps.confirmPayment.detail',
detailFallback: 'Check the slip against the fee and confirm it.',
});
}
if (issues) {
if (schedules) {
stages.push({
id: 'schedule-issuance',
statuses: ['PAYMENT_CONFIRMED'],
owner: 'officer',
actionIds: ['schedule-issuance'],
titleKey: 'review.steps.scheduleIssuance.title',
titleFallback: 'Book the pickup',
detailKey: 'review.steps.scheduleIssuance.detail',
detailFallback:
'Set the date and half-day the applicant collects the document.',
});
}
stages.push({
id: 'issue',
statuses: schedules
? ['SCHEDULED']
: (['PAYMENT_CONFIRMED', 'SCHEDULED'] as LicenseStatus[]),
owner: 'officer',
actionIds: ['issue-certificate'],
titleKey: 'review.steps.issue.title',
titleFallback: 'Issue the certificate',
detailKey: 'review.steps.issue.detail',
detailFallback: 'Generate the certificate and hand it over.',
});
}
stages.push({
id: 'complete',
statuses: [
// No fee and nothing to issue: approval is the end of the road.
...(paymentLeg ? [] : (['APPROVED'] as LicenseStatus[])),
'CERTIFICATE_ISSUED',
'COMPLETED',
],
owner: 'officer',
titleKey: 'review.steps.complete.title',
titleFallback: 'Complete',
detailKey: 'review.steps.complete.detail',
detailFallback: 'Nothing further — the application is finished.',
});
return stages;
}
export interface StepsContext {
detail: ApplicationDetail;
/** Already resolved for this officer — carries the disabled reasons. */
actions: ResolvedAction[];
documentProgress: { acceptedCount: number; total: number };
/** True when the type is reviewed without a claim/assign step. */
skipsAssignment: boolean;
t: Translate;
}
/**
* What is left to do on this application, as an ordered checklist.
*
* The Decision Bar says what an officer can press right now; it does not say
* where that sits in the request, what has already been passed, or who the
* file is waiting on when the answer is "nobody here". This derives that from
* the same two sources the bar uses — the status and the resolved actions — so
* the panel cannot claim a step the server would refuse.
*/
export function buildReviewSteps(ctx: StepsContext): ReviewStepsResult {
const { detail, actions, documentProgress, t } = ctx;
const app = detail.application;
const status = app.status;
const examined =
Boolean(app.licenseType?.requiresExamination) ||
[status, ...detail.history.map((h) => h.toStatus)].some((s) =>
String(s).startsWith('ELIGIBILITY_') || String(s).startsWith('EXAM_'),
);
const feeRaw =
app.kind === 'RENEWAL'
? app.licenseType?.feeRenewal
: app.licenseType?.feeNewApplication;
const hasFee = feeRaw != null && Number(feeRaw) > 0;
const stages = stagesFor(app.licenseType, {
examined,
skipsAssignment: ctx.skipsAssignment,
hasFee,
});
// Oldest first. The API's order is not part of its contract, and "when was
// this stage left" is the first transition past it.
const history = [...detail.history].sort((a, b) =>
a.createdAt.localeCompare(b.createdAt),
);
const indexByStatus = new Map<LicenseStatus, number>();
stages.forEach((stage, index) => {
stage.statuses.forEach((s) => {
if (!indexByStatus.has(s)) indexByStatus.set(s, index);
});
});
const currentIndex = indexByStatus.get(status);
// Furthest stage the file has actually been at. Carries the done marks when
// the current status is off the ladder (ON_HOLD, REJECTED).
const reachedIndex = history.reduce((furthest, entry) => {
const index = indexByStatus.get(entry.toStatus);
return index != null && index > furthest ? index : furthest;
}, -1);
// A held or rejected file has not finished the stage it stopped in, so the
// anchor is the stage it stopped in rather than the one after it.
const anchor = currentIndex ?? Math.max(reachedIndex, 0);
const actionById = new Map(actions.map((a) => [a.id, a]));
const steps = stages.map((stage, index): ReviewStep => {
const state: StepState =
index < anchor ? 'done' : index === currentIndex ? 'current' : 'upcoming';
const step: ReviewStep = {
id: stage.id,
title: t(stage.titleKey, stage.titleFallback),
detail: t(stage.detailKey, stage.detailFallback),
state,
owner: stage.owner,
};
if (stage.id === 'complete') step.terminal = true;
if (state === 'done') {
// When the stage was left: the first transition into a later one.
const left = history.find((entry) => {
const at = indexByStatus.get(entry.toStatus);
return at != null && at > index;
});
if (left) step.completedAt = left.createdAt;
return step;
}
const resolved = (stage.actionIds ?? [])
.map((id) => actionById.get(id))
.find((action): action is ResolvedAction => Boolean(action));
if (resolved) {
step.actionId = resolved.id;
if (state === 'current' && !resolved.enabled) {
step.blockedBy = resolved.disabledReason;
}
} else if (stage.actionIds?.length) {
step.actionId = stage.actionIds[0];
}
if (
state === 'current' &&
DOCUMENT_STAGES.has(stage.id) &&
documentProgress.total > 0
) {
step.progress = t('review.steps.documentsAccepted', {
accepted: documentProgress.acceptedCount,
total: documentProgress.total,
defaultValue: '{{accepted}} of {{total}} documents accepted',
});
}
// The applicant has the file back: the review stage is live again, but it
// is not the officer's move until the corrections arrive.
if (state === 'current' && status === 'RESUBMIT_REQUIRED') {
step.owner = 'applicant';
step.detail = t(
'review.steps.resubmit.detail',
'Sent back for corrections. The file returns here once the applicant resubmits.',
);
delete step.actionId;
delete step.blockedBy;
}
return step;
});
const result: ReviewStepsResult = { steps };
if (status === 'ON_HOLD') {
result.note = t(
'review.steps.onHold',
'On hold. Resume it to carry on from where it stopped.',
);
result.noteActionId = 'resume';
} else if (status === 'REJECTED') {
result.note = t('review.steps.rejected', 'Rejected — nothing further to do.');
result.closed = true;
} else if (currentIndex == null && !CLOSED_STATUSES.includes(status)) {
result.note = t(
'review.steps.offCourse',
'This status is outside the standard course — use the Decision Bar for what is available.',
);
}
return result;
}

View File

@@ -1485,6 +1485,77 @@ export const am: Translations = {
slaLabel: "የጊዜ ገደብ",
eligibility: "ብቁነት",
statusTimeline: "ሂደት",
steps: {
title: "ለማጠናቀቅ የሚቀሩ ደረጃዎች",
remaining_one: "1 ይቀራል",
remaining_other: "{{count}} ይቀራሉ",
yourMove: "እርስዎ",
waitingOnApplicant: "አመልካች",
blockedHint: "መጀመሪያ ይህን ይጨርሱ — እስከዚያ ድረስ ተግባሩ አይሠራም",
documentsAccepted: "ከ{{total}} ሰነዶች {{accepted}} ተቀብለዋል",
onHold: "ለጊዜው ቆሟል። ከቆመበት ለመቀጠል ይቀጥሉት።",
rejected: "ውድቅ ተደርጓል — ተጨማሪ የሚደረግ የለም።",
offCourse:
"ይህ ሁኔታ ከመደበኛው ሂደት ውጭ ነው — የሚገኙትን ተግባራት በውሳኔ አሞሌው ይመልከቱ።",
resubmit: {
detail: "ለእርማት ተመልሷል። አመልካቹ እንደገና ሲያቀርበው ወደዚህ ይመለሳል።",
},
intake: {
title: "ማመልከቻውን ይረከቡ",
detail: "ማመልከቻውን ይረከቡ ወይም ገምጋሚ ይመድቡ፤ ባለቤት እንዲኖረው።",
},
review: {
title: "ማመልከቻውን ይገምግሙ",
detail:
"እያንዳንዱን የቅጽ ክፍል ያንብቡ፣ የተጫኑትን ሰነዶች ይቀበሉ ወይም ውድቅ ያድርጉ፣ ከዚያ ግምገማውን ያጠናቅቁ።",
},
evaluation: {
title: "ሰነዶቹን ያጽድቁ",
detail: "ጉብኝቱ እንዲያዝ ሰነዶቹ በሥርዓት መሆናቸውን ያረጋግጡ።",
},
inspection: {
title: "ምርመራ",
detail: "የቦታ ጉብኝቱን ይያዙ፣ ከተከናወነ በኋላም የማረጋገጫ ዝርዝሩን ውጤት ይመዝግቡ።",
},
decision: {
title: "ውሳኔ",
detail: "ማመልከቻውን ያጽድቁ፣ ለእርማት ይመልሱ ወይም ውድቅ ያድርጉ።",
},
eligibilityFee: {
title: "የብቁነት ክፍያ",
detail: "አመልካቹ የግምገማ ክፍያውን ይከፍላል። ክፍያው ሲረጋገጥ ግምገማ ይጀምራል።",
},
decide: {
title: "ይገምግሙ እና ይወስኑ",
detail:
"ቅጹን ይመልከቱ፣ ሁሉንም ሰነዶች ይቀበሉ፣ ከዚያ ብቁነቱን ያጽድቁ፣ እርማት ይጠይቁ ወይም ውድቅ ያድርጉ።",
},
exam: {
title: "ፈተና",
detail:
"ተፈታኙ የፈተና ክፍያ ከፍሎ በታተመ መርሐ ግብር ተመዝግቦ ይፈተናል። ውጤቱ ከፈተና ሥርዓቱ ይመጣል — እዚህ የሹም ተግባር የለም።",
},
payment: {
title: "የክፍያ ሂደት",
detail: "አመልካቹ ክፍያውን ፈጽሞ ደረሰኙን ይጭናል። እስከዚያ የሚደረግ የለም።",
},
confirmPayment: {
title: "ክፍያውን ያረጋግጡ",
detail: "ደረሰኙን ከክፍያው መጠን ጋር አመሳክረው ያረጋግጡ።",
},
scheduleIssuance: {
title: "የመረከቢያ ቀን ይያዙ",
detail: "አመልካቹ ሰነዱን የሚረከብበትን ቀንና ግማሽ ቀን ይወስኑ።",
},
issue: {
title: "ሰርተፍኬቱን ይስጡ",
detail: "ሰርተፍኬቱን አዘጋጅተው ያስረክቡ።",
},
complete: {
title: "ተጠናቋል",
detail: "ተጨማሪ የሚደረግ የለም — ማመልከቻው ተጠናቋል።",
},
},
assigned: "ተመድቧል",
decisionBar: "የውሳኔ አሞሌ",
moreActions: "ተጨማሪ ተግባራት",

View File

@@ -1503,6 +1503,80 @@ export const en = {
slaLabel: 'SLA',
eligibility: 'Eligibility',
statusTimeline: 'Progress',
steps: {
title: 'Steps to complete',
remaining_one: '1 left',
remaining_other: '{{count}} left',
yourMove: 'You',
waitingOnApplicant: 'Applicant',
blockedHint: 'Clear this first — the action stays disabled until it is done',
documentsAccepted: '{{accepted}} of {{total}} documents accepted',
onHold: 'On hold. Resume it to carry on from where it stopped.',
rejected: 'Rejected — nothing further to do.',
offCourse:
'This status is outside the standard course — use the Decision Bar for what is available.',
resubmit: {
detail:
'Sent back for corrections. The file returns here once the applicant resubmits.',
},
intake: {
title: 'Take the file',
detail: 'Claim the application or assign a reviewer, so it has an owner.',
},
review: {
title: 'Review the application',
detail:
'Read every form section and accept or reject each uploaded document, then complete the review.',
},
evaluation: {
title: 'Approve the documents',
detail: 'Confirm the paperwork is in order so the site visit can be booked.',
},
inspection: {
title: 'Inspection',
detail:
'Book the site visit, then record the checklist outcome after it happens.',
},
decision: {
title: 'Decision',
detail: 'Approve the application, send it back for corrections, or reject it.',
},
eligibilityFee: {
title: 'Eligibility fee',
detail: 'The applicant pays the assessment fee. Review starts once it clears.',
},
decide: {
title: 'Review and decide',
detail:
'Check the form and accept every document, then approve eligibility, request corrections, or reject.',
},
exam: {
title: 'Examination',
detail:
'The candidate pays for a sitting, registers for a published schedule and sits the exam. The result comes from the exam engine — no officer action here.',
},
payment: {
title: 'Fee payment',
detail:
'The applicant pays the fee and uploads the bank slip. Nothing to do until it arrives.',
},
confirmPayment: {
title: 'Confirm the payment',
detail: 'Check the slip against the fee and confirm it.',
},
scheduleIssuance: {
title: 'Book the pickup',
detail: 'Set the date and half-day the applicant collects the document.',
},
issue: {
title: 'Issue the certificate',
detail: 'Generate the certificate and hand it over.',
},
complete: {
title: 'Complete',
detail: 'Nothing further — the application is finished.',
},
},
assigned: 'Assigned',
decisionBar: 'Decision bar',
moreActions: 'More actions',