diff --git a/apps/backoffice/src/app/features/license-review/components/NextStepsPanel.tsx b/apps/backoffice/src/app/features/license-review/components/NextStepsPanel.tsx new file mode 100644 index 000000000..a6b547f21 --- /dev/null +++ b/apps/backoffice/src/app/features/license-review/components/NextStepsPanel.tsx @@ -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 ( + + + + {t("review.steps.title", "Steps to complete")} + + {remaining > 0 && ( + + {t("review.steps.remaining", { + count: remaining, + defaultValue_one: "1 left", + defaultValue_other: "{{count}} left", + })} + + )} + + + {result.note && ( + + {result.note} + + )} + + + {result.steps.map((step, index) => ( + + ))} + + + ); +} + +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 ( + + + {done ? ( + + ) : waiting ? ( + + ) : ( + + {number} + + )} + + +
+ + + {step.title} + + {current && ( + } + > + {waiting + ? t("review.steps.waitingOnApplicant", "Applicant") + : t("review.steps.yourMove", "You")} + + )} + + + {/* Done steps carry their date only — the instruction has served its + purpose and repeating it buries the step that is actually live. */} + {done ? ( + completedLabel && ( + + {completedLabel} + + ) + ) : ( + + {step.detail} + + )} + + {step.progress && ( + + {step.progress} + + )} + + {step.blockedBy && ( + + + + + {step.blockedBy} + + + + )} +
+
+ ); +} diff --git a/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx b/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx index f253d8e16..ae2b786bf 100644 --- a/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx +++ b/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx @@ -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() { {/* Zone 1 — sticky summary rail. */} - + {/* 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. */} + {/* Who, before what: a person-centric review is about the applicant, and the licence facts below are the context. */} {isPersonal && 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. */} + + {t("review.summary", "Summary")} diff --git a/apps/backoffice/src/app/features/license-review/steps.test.ts b/apps/backoffice/src/app/features/license-review/steps.test.ts new file mode 100644 index 000000000..36f4d8d29 --- /dev/null +++ b/apps/backoffice/src/app/features/license-review/steps.test.ts @@ -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; + const template = String(opts['defaultValue'] ?? key); + return template.replace(/\{\{(\w+)\}\}/g, (_, name) => String(opts[name] ?? '')); +}; + +function licenseType(overrides: Partial = {}): 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(); + }); +}); diff --git a/apps/backoffice/src/app/features/license-review/steps.ts b/apps/backoffice/src/app/features/license-review/steps.ts new file mode 100644 index 000000000..79e66bf7e --- /dev/null +++ b/apps/backoffice/src/app/features/license-review/steps.ts @@ -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(); + 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; +} diff --git a/apps/backoffice/src/app/i18n/locales/am.ts b/apps/backoffice/src/app/i18n/locales/am.ts index b41668272..2815a5723 100644 --- a/apps/backoffice/src/app/i18n/locales/am.ts +++ b/apps/backoffice/src/app/i18n/locales/am.ts @@ -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: "ተጨማሪ ተግባራት", diff --git a/apps/backoffice/src/app/i18n/locales/en.ts b/apps/backoffice/src/app/i18n/locales/en.ts index 3bdc6d072..927a73b03 100644 --- a/apps/backoffice/src/app/i18n/locales/en.ts +++ b/apps/backoffice/src/app/i18n/locales/en.ts @@ -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',