mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-09 08:18:22 +00:00
feat: add seafarer scope display to profile and progress tracking panel to license review
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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")}
|
||||
|
||||
237
apps/backoffice/src/app/features/license-review/steps.test.ts
Normal file
237
apps/backoffice/src/app/features/license-review/steps.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
444
apps/backoffice/src/app/features/license-review/steps.ts
Normal file
444
apps/backoffice/src/app/features/license-review/steps.ts
Normal 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;
|
||||
}
|
||||
@@ -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: "ተጨማሪ ተግባራት",
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user