feat: add INSPECTION_FAILED state, enforce inspection scheduling locks, and improve adjustment round UI for section locking and remark labeling

This commit is contained in:
estifanos
2026-08-27 07:36:07 +00:00
parent b85281812d
commit f01c0ec31e
9 changed files with 139 additions and 15 deletions

View File

@@ -78,6 +78,7 @@ export const ACTIONS: ActionDefinition[] = [
'UNDER_EVALUATION',
'INSPECTION_PENDING',
'INSPECTION_COMPLETED',
'INSPECTION_FAILED',
'ON_HOLD',
],
permissions: ['can:assign:license-application'],
@@ -101,6 +102,7 @@ export const ACTIONS: ActionDefinition[] = [
'UNDER_EVALUATION',
'INSPECTION_PENDING',
'INSPECTION_COMPLETED',
'INSPECTION_FAILED',
],
permissions: ['can:hold:license-application'],
emphasis: 'subtle',
@@ -139,7 +141,8 @@ export const ACTIONS: ActionDefinition[] = [
id: 'schedule-inspection',
tier: 'primary',
labelKey: 'review.actions.scheduleInspection',
from: ['INSPECTION_PENDING'],
// INSPECTION_FAILED: booking the re-inspection after a failed visit.
from: ['INSPECTION_PENDING', 'INSPECTION_FAILED'],
permissions: ['can:create:inspection'],
emphasis: 'filled',
},
@@ -147,7 +150,7 @@ export const ACTIONS: ActionDefinition[] = [
id: 'record-inspection',
tier: 'primary',
labelKey: 'review.actions.recordInspection',
from: ['INSPECTION_PENDING'],
from: ['INSPECTION_PENDING', 'INSPECTION_FAILED'],
permissions: ['can:update:inspection'],
emphasis: 'filled',
},
@@ -167,7 +170,13 @@ export const ACTIONS: ActionDefinition[] = [
id: 'request-adjustment',
tier: 'primary',
labelKey: 'review.actions.requestAdjustment',
from: ['UNDER_REVIEW', 'UNDER_EVALUATION', 'INSPECTION_COMPLETED', 'ELIGIBILITY_PAID'],
from: [
'UNDER_REVIEW',
'UNDER_EVALUATION',
'INSPECTION_COMPLETED',
'INSPECTION_FAILED',
'ELIGIBILITY_PAID',
],
permissions: ['can:request-adjustment:license-application'],
emphasis: 'light',
color: 'orange',
@@ -182,6 +191,7 @@ export const ACTIONS: ActionDefinition[] = [
'UNDER_EVALUATION',
'INSPECTION_PENDING',
'INSPECTION_COMPLETED',
'INSPECTION_FAILED',
'ELIGIBILITY_PAID',
],
permissions: ['can:reject:license-application'],
@@ -282,11 +292,18 @@ export interface ResolveContext {
needsCapital: string;
needsInspection: string;
needsDocumentReviews: string;
inspectionNotYetDue: string;
};
/** Number of sections/documents the officer has flagged for correction. */
flaggedCount: number;
/** True when an inspection is scheduled and awaiting a result. */
hasPendingInspection: boolean;
/**
* True while the booked inspection's scheduled instant is still in the
* future — a visit cannot have an outcome before it happens, so the record
* button waits (the server refuses early results the same way).
*/
inspectionNotYetDue: boolean;
/**
* False while any uploaded document is still unjudged or rejected. Approving
* is a statement that every document was checked, so the button stays dead
@@ -379,6 +396,12 @@ export function resolveActions(ctx: ResolveContext): ResolvedAction[] {
disabledReason: reason,
});
// Booked but not yet due: keep the button visible so the officer sees
// the next step, disabled with the scheduled time as the reason.
if (action.id === 'record-inspection' && ctx.inspectionNotYetDue) {
return disabled(reasons.inspectionNotYetDue);
}
// Decisions belong to whoever holds the application.
const needsOwnership =
action.tier === 'primary' && action.id !== 'confirm-payment';

View File

@@ -279,6 +279,12 @@ export function LicenseReviewPage() {
}, [flags]);
const pendingInspection = inspections.find((i) => i.status === 'SCHEDULED');
// A visit cannot have an outcome before it happens — mirror of the server's
// inspection_not_yet_due guard, compared instant-to-instant.
const inspectionNotYetDue = Boolean(
pendingInspection?.scheduledDate &&
new Date(pendingInspection.scheduledDate) > new Date(),
);
// Approving means every uploaded document was accepted — one unjudged or
// rejected file is enough to keep the decision buttons dead. The counts feed
@@ -335,6 +341,7 @@ export function LicenseReviewPage() {
can,
flaggedCount: flagged.length,
hasPendingInspection: Boolean(pendingInspection),
inspectionNotYetDue,
allDocumentsAccepted,
reasons: {
wrongStatus: t('review.disabled.wrongStatus', 'Not available at this stage'),
@@ -343,6 +350,13 @@ export function LicenseReviewPage() {
needsFlags: t('review.disabled.needsFlags', 'Flag at least one item to request a correction'),
needsCapital: t('review.disabled.needsCapital', 'Record the verified capital first'),
needsInspection: t('review.disabled.needsInspection', 'Requires an inspection result'),
inspectionNotYetDue: t('review.disabled.inspectionNotYetDue', {
date: pendingInspection?.scheduledDate
? showDate(pendingInspection.scheduledDate)
: '',
defaultValue:
'Inspection scheduled for {{date}}. Results can be recorded after the scheduled time.',
}),
needsDocumentReviews:
documentProgress.total === 0
? t(
@@ -357,7 +371,7 @@ export function LicenseReviewPage() {
}),
},
});
}, [data, currentUserId, can, flagged.length, pendingInspection, t]);
}, [data, currentUserId, can, flagged.length, pendingInspection, inspectionNotYetDue, showDate, t]);
// Location answers are tree ids. The picker the applicant used resolves them
// client-side from the same list, so the reviewer reads the place rather than
@@ -1063,6 +1077,18 @@ export function LicenseReviewPage() {
</Tabs.Panel>
<Tabs.Panel value="inspection">
{status === "INSPECTION_FAILED" && (
<Alert
mb="md"
color="red"
icon={<IconAlertTriangle size={16} />}
>
{t(
"review.inspectionFailedBlocked",
"Approval is unavailable because the inspection failed. Schedule a re-inspection, request corrections, or reject the application.",
)}
</Alert>
)}
<Paper withBorder p="md">
{inspections.length === 0 ? (
<Text size="sm" c="dimmed">
@@ -1386,12 +1412,23 @@ export function LicenseReviewPage() {
autosize
minRows={3}
/>
{inspectionNotYetDue && (
<Alert color="yellow" icon={<IconAlertTriangle size={16} />}>
{t("review.disabled.inspectionNotYetDue", {
date: pendingInspection?.scheduledDate
? showDate(pendingInspection.scheduledDate)
: "",
defaultValue:
"Inspection scheduled for {{date}}. Results can be recorded after the scheduled time.",
})}
</Alert>
)}
<ModalFooter grow>
<ActionIcon
variant="light"
color="teal"
size="lg"
disabled={!findings.trim() || !pendingInspection}
disabled={!findings.trim() || !pendingInspection || inspectionNotYetDue}
aria-label={t("review.passed", "Passed")}
onClick={() =>
run(
@@ -1418,7 +1455,7 @@ export function LicenseReviewPage() {
variant="light"
color="red"
size="lg"
disabled={!findings.trim() || !pendingInspection}
disabled={!findings.trim() || !pendingInspection || inspectionNotYetDue}
aria-label={t("review.failed", "Failed")}
onClick={() =>
run(

View File

@@ -1027,10 +1027,14 @@ export const am: Translations = {
needsFlags: "ማስተካከያ ለመጠየቅ ቢያንስ አንድ ነገር ምልክት ያድርጉ",
needsCapital: "መጀመሪያ የተረጋገጠውን ካፒታል ይመዝግቡ",
needsInspection: "የምርመራ ውጤት ያስፈልጋል",
inspectionNotYetDue:
"ምርመራ ለ{{date}} ተይዟል። ውጤቶች ከተያዘው ሰዓት በኋላ መመዝገብ ይችላሉ።",
needsDocumentReviews:
"መጀመሪያ ሁሉንም ሰነዶች ይቀበሉ — ከ{{total}} {{accepted}} ተቀብለዋል። የሰነዶች ትር ከፍተው ቀሪዎቹን ይቀበሉ።",
needsDocumentsUploaded: "እስካሁን የሚገመገም ሰነድ አልተጫነም",
},
inspectionFailedBlocked:
"ምርመራው ስላልተሳካ ማጽደቅ አይቻልም። ድጋሚ ምርመራ ያስይዙ፣ ማስተካከያ ይጠይቁ ወይም ማመልከቻውን ውድቅ ያድርጉ።",
reasons: {
incompleteDocuments: "ያልተሟሉ ሰነዶች",
belowCapital: "ካፒታል ከሚያስፈልገው በታች",

View File

@@ -1036,10 +1036,14 @@ export const en = {
needsFlags: 'Flag at least one item to request a correction',
needsCapital: 'Record the verified capital first',
needsInspection: 'Requires an inspection result',
inspectionNotYetDue:
'Inspection scheduled for {{date}}. Results can be recorded after the scheduled time.',
needsDocumentReviews:
'Accept all documents first — {{accepted}} of {{total}} accepted. Open the Documents tab and accept the rest.',
needsDocumentsUploaded: 'No documents uploaded to review yet',
},
inspectionFailedBlocked:
'Approval is unavailable because the inspection failed. Schedule a re-inspection, request corrections, or reject the application.',
reasons: {
incompleteDocuments: 'Incomplete documents',
belowCapital: 'Capital below the required minimum',

View File

@@ -322,24 +322,44 @@ export function LicenseApplicationPage() {
const isAdjusting = application?.status === "RESUBMIT_REQUIRED";
const openRemarks = detail?.openRemarks ?? [];
// The whole round's remarks, resolved or not — the server's section lock
// (`assertSectionUnlocked`) ignores `isResolved`, and this page bulk-resolves
// remarks right before resubmitting, so `openRemarks` would re-freeze a
// section the moment the applicant ticked it off.
const roundRemarks = useMemo(
() =>
(detail?.remarks ?? []).filter(
(r) => r.roundNumber === detail?.application?.adjustmentRound,
),
[detail?.remarks, detail?.application?.adjustmentRound],
);
const flaggedSections = useMemo(
() =>
Object.fromEntries(
openRemarks
roundRemarks
.filter((r) => r.targetType === "FORM_SECTION")
.map((r) => [r.targetKey, r.remark]),
),
[openRemarks],
[roundRemarks],
);
const flaggedDocuments = useMemo(
() =>
Object.fromEntries(
openRemarks
roundRemarks
.filter((r) => r.targetType === "DOCUMENT")
.map((r) => [r.targetKey, r.remark]),
),
[openRemarks],
[roundRemarks],
);
const hasSectionRemarks = Object.keys(flaggedSections).length > 0;
const hasDocRemarks = Object.keys(flaggedDocuments).length > 0;
// A round that flagged no form sections carries no section locks — mirror of
// the server's fallback, without which a documents-only correction round
// froze every field and the applicant could not edit anything at all.
const isSectionLocked = (sectionKey: string) =>
isAdjusting && hasSectionRemarks && !flaggedSections[sectionKey];
// Sections that share a group collapse onto one step, so the stepper stays
// short instead of showing a page per section.
@@ -383,6 +403,24 @@ export function LicenseApplicationPage() {
// to the summary first.
const showSummary = application.status !== "DRAFT" && viewingSummary;
// The applicant reads "Vessel Particulars", not "vesselParticulars" — and a
// staff remark is keyed by a uuid, which reads as nothing at all.
function remarkLabel(remark: (typeof openRemarks)[number]): string {
if (remark.targetType === "FORM_SECTION") {
const section = config?.licenseType.formSchema.sections.find(
(s) => s.key === remark.targetKey,
);
return section ? localized(section.title) : remark.targetKey;
}
if (remark.targetType === "DOCUMENT") {
const requirement = config?.documentRequirements.find(
(r) => r.key === remark.targetKey,
);
return requirement ? localized(requirement.name) : remark.targetKey;
}
return t("licenseApplication.staffMember", "Staff member");
}
// Vessel Information and Current Ownership are separate form sections, so
// ConfigDrivenSection (one instance per section) can't fill both itself —
// it reports the pick up here and this fans it out across every section.
@@ -400,7 +438,7 @@ export function LicenseApplicationPage() {
async function saveSection(sectionKey: string) {
// During an adjustment round only flagged sections are editable, so don't
// even attempt a write the server would reject.
if (isAdjusting && !flaggedSections[sectionKey]) return;
if (isSectionLocked(sectionKey)) return;
const values = { ...(draft[sectionKey] ?? {}) };
// The picker works in alpha-2 codes (CountrySelect); the backend, like
// the profile Address endpoint, stores the full country name.
@@ -679,7 +717,7 @@ export function LicenseApplicationPage() {
<Stack gap={4}>
{openRemarks.map((remark) => (
<Text size="sm" key={remark.id}>
<b>{remark.targetKey}</b>: {remark.remark}
<b>{remarkLabel(remark)}</b>: {remark.remark}
</Text>
))}
<Text size="xs" c="dimmed" mt={4}>
@@ -741,7 +779,7 @@ export function LicenseApplicationPage() {
{currentStep?.kind === "sections" && (
<Stack gap="lg">
{currentStep.sections.map((section, index) => {
const locked = isAdjusting && !flaggedSections[section.key];
const locked = isSectionLocked(section.key);
return (
<div key={section.key}>
{index > 0 && <Divider mb="lg" />}
@@ -899,7 +937,7 @@ export function LicenseApplicationPage() {
ownerType="APPLICATION"
ownerId={appId}
flagged={flaggedDocuments}
restrictToFlagged={isAdjusting}
restrictToFlagged={isAdjusting && hasDocRemarks}
readOnly={readOnly}
onUploaded={() => {
refetchAttachments();
@@ -922,7 +960,10 @@ export function LicenseApplicationPage() {
formData={draft}
errors={fieldErrors}
vessels={vessels}
disabled={readOnly}
// Same lock as the earlier steps — without it this step
// looked editable during an adjustment round while
// saveSection silently dropped the changes.
disabled={readOnly || isSectionLocked(section.key)}
onChange={(key, value) => {
setDraft((prev) => ({
...prev,

View File

@@ -242,6 +242,7 @@ export const am: Translations = {
RESUBMIT_REQUIRED: 'እንደገና ማስገባት ያስፈልጋል',
INSPECTION_PENDING: 'ቁጥጥር በመጠባበቅ ላይ',
INSPECTION_COMPLETED: 'ቁጥጥር ተጠናቋል',
INSPECTION_FAILED: 'ቁጥጥር አልተሳካም',
APPROVED: 'ጸድቋል',
REJECTED: 'ውድቅ ተደርጓል',
ON_HOLD: 'ላይ ቆሟል',

View File

@@ -242,6 +242,7 @@ export const en = {
RESUBMIT_REQUIRED: 'Resubmit Required',
INSPECTION_PENDING: 'Inspection Pending',
INSPECTION_COMPLETED: 'Inspection Completed',
INSPECTION_FAILED: 'Inspection Failed',
APPROVED: 'Approved',
REJECTED: 'Rejected',
ON_HOLD: 'On Hold',

View File

@@ -67,6 +67,7 @@ export const STATUS_LABELS: Record<LicenseStatus, string> = {
RESUBMIT_REQUIRED: 'Resubmit Required',
INSPECTION_PENDING: 'Inspection Pending',
INSPECTION_COMPLETED: 'Inspection Completed',
INSPECTION_FAILED: 'Inspection Failed',
APPROVED: 'Approved',
REJECTED: 'Rejected',
ON_HOLD: 'On Hold',
@@ -94,6 +95,8 @@ export const STATUS_COLORS: Record<LicenseStatus, string> = {
RESUBMIT_REQUIRED: 'orange',
INSPECTION_PENDING: 'cyan',
INSPECTION_COMPLETED: 'cyan',
// Orange, not red: recoverable — a re-inspection can still pass.
INSPECTION_FAILED: 'orange',
APPROVED: 'teal',
REJECTED: 'red',
ON_HOLD: 'gray',
@@ -127,6 +130,8 @@ export const STATUS_PROGRESS: Record<LicenseStatus, number> = {
RESUBMIT_REQUIRED: 30,
INSPECTION_PENDING: 55,
INSPECTION_COMPLETED: 65,
// A re-inspection returns to the pending step, so no further along than it.
INSPECTION_FAILED: 55,
APPROVED: 75,
// Parked, so it keeps the progress of wherever it was held from.
ON_HOLD: 45,
@@ -376,6 +381,10 @@ const ERROR_MESSAGES: Record<string, string> = {
application_not_awaiting_inspection:
'This application is not waiting for an inspection.',
inspection_already_completed: 'This inspection has already been recorded.',
inspection_not_yet_due:
'Inspection results can be recorded after the scheduled inspection date and time.',
inspection_not_passed:
'Approval requires a passed inspection. Schedule a re-inspection or request corrections.',
license_type_inactive: 'This licence type is not currently accepting applications.',
};

View File

@@ -29,6 +29,10 @@ export type LicenseStatus =
| "RESUBMIT_REQUIRED"
| "INSPECTION_PENDING"
| "INSPECTION_COMPLETED"
// The inspection was conducted and failed. Approval and issuance are
// unreachable until a re-inspection passes; the officer chooses between a
// repeat visit, an adjustment round, and rejection.
| "INSPECTION_FAILED"
| "APPROVED"
| "REJECTED"
| "ON_HOLD"