diff --git a/apps/backoffice/src/app/features/license-review/config/actions.ts b/apps/backoffice/src/app/features/license-review/config/actions.ts index 4bd1d9446..d1547d43f 100644 --- a/apps/backoffice/src/app/features/license-review/config/actions.ts +++ b/apps/backoffice/src/app/features/license-review/config/actions.ts @@ -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'; 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 166b512eb..71a13403a 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 @@ -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() { + {status === "INSPECTION_FAILED" && ( + } + > + {t( + "review.inspectionFailedBlocked", + "Approval is unavailable because the inspection failed. Schedule a re-inspection, request corrections, or reject the application.", + )} + + )} {inspections.length === 0 ? ( @@ -1386,12 +1412,23 @@ export function LicenseReviewPage() { autosize minRows={3} /> + {inspectionNotYetDue && ( + }> + {t("review.disabled.inspectionNotYetDue", { + date: pendingInspection?.scheduledDate + ? showDate(pendingInspection.scheduledDate) + : "", + defaultValue: + "Inspection scheduled for {{date}}. Results can be recorded after the scheduled time.", + })} + + )} 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( diff --git a/apps/backoffice/src/app/i18n/locales/am.ts b/apps/backoffice/src/app/i18n/locales/am.ts index 40aab6f6e..1c3b813b4 100644 --- a/apps/backoffice/src/app/i18n/locales/am.ts +++ b/apps/backoffice/src/app/i18n/locales/am.ts @@ -1027,10 +1027,14 @@ export const am: Translations = { needsFlags: "ማስተካከያ ለመጠየቅ ቢያንስ አንድ ነገር ምልክት ያድርጉ", needsCapital: "መጀመሪያ የተረጋገጠውን ካፒታል ይመዝግቡ", needsInspection: "የምርመራ ውጤት ያስፈልጋል", + inspectionNotYetDue: + "ምርመራ ለ{{date}} ተይዟል። ውጤቶች ከተያዘው ሰዓት በኋላ መመዝገብ ይችላሉ።", needsDocumentReviews: "መጀመሪያ ሁሉንም ሰነዶች ይቀበሉ — ከ{{total}} {{accepted}} ተቀብለዋል። የሰነዶች ትር ከፍተው ቀሪዎቹን ይቀበሉ።", needsDocumentsUploaded: "እስካሁን የሚገመገም ሰነድ አልተጫነም", }, + inspectionFailedBlocked: + "ምርመራው ስላልተሳካ ማጽደቅ አይቻልም። ድጋሚ ምርመራ ያስይዙ፣ ማስተካከያ ይጠይቁ ወይም ማመልከቻውን ውድቅ ያድርጉ።", reasons: { incompleteDocuments: "ያልተሟሉ ሰነዶች", belowCapital: "ካፒታል ከሚያስፈልገው በታች", diff --git a/apps/backoffice/src/app/i18n/locales/en.ts b/apps/backoffice/src/app/i18n/locales/en.ts index ac4ae177d..209d12138 100644 --- a/apps/backoffice/src/app/i18n/locales/en.ts +++ b/apps/backoffice/src/app/i18n/locales/en.ts @@ -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', diff --git a/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx b/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx index a7b4f92ac..30b801ab3 100644 --- a/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx +++ b/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx @@ -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() { {openRemarks.map((remark) => ( - {remark.targetKey}: {remark.remark} + {remarkLabel(remark)}: {remark.remark} ))} @@ -741,7 +779,7 @@ export function LicenseApplicationPage() { {currentStep?.kind === "sections" && ( {currentStep.sections.map((section, index) => { - const locked = isAdjusting && !flaggedSections[section.key]; + const locked = isSectionLocked(section.key); return (
{index > 0 && } @@ -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, diff --git a/apps/portal/src/app/i18n/locales/am.ts b/apps/portal/src/app/i18n/locales/am.ts index 8b1efe359..5f921d909 100644 --- a/apps/portal/src/app/i18n/locales/am.ts +++ b/apps/portal/src/app/i18n/locales/am.ts @@ -242,6 +242,7 @@ export const am: Translations = { RESUBMIT_REQUIRED: 'እንደገና ማስገባት ያስፈልጋል', INSPECTION_PENDING: 'ቁጥጥር በመጠባበቅ ላይ', INSPECTION_COMPLETED: 'ቁጥጥር ተጠናቋል', + INSPECTION_FAILED: 'ቁጥጥር አልተሳካም', APPROVED: 'ጸድቋል', REJECTED: 'ውድቅ ተደርጓል', ON_HOLD: 'ላይ ቆሟል', diff --git a/apps/portal/src/app/i18n/locales/en.ts b/apps/portal/src/app/i18n/locales/en.ts index cc50ad5c1..f3e0d4d83 100644 --- a/apps/portal/src/app/i18n/locales/en.ts +++ b/apps/portal/src/app/i18n/locales/en.ts @@ -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', diff --git a/libs/api/src/lib/features/licensing/licensing.helpers.ts b/libs/api/src/lib/features/licensing/licensing.helpers.ts index 8f3cff394..83f0e8098 100644 --- a/libs/api/src/lib/features/licensing/licensing.helpers.ts +++ b/libs/api/src/lib/features/licensing/licensing.helpers.ts @@ -67,6 +67,7 @@ export const STATUS_LABELS: Record = { 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 = { 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 = { 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 = { 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.', }; diff --git a/libs/api/src/lib/features/licensing/licensing.types.ts b/libs/api/src/lib/features/licensing/licensing.types.ts index 0be26f0ab..22731177b 100644 --- a/libs/api/src/lib/features/licensing/licensing.types.ts +++ b/libs/api/src/lib/features/licensing/licensing.types.ts @@ -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"