mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-28 03:10:55 +00:00
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:
@@ -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';
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1027,10 +1027,14 @@ export const am: Translations = {
|
||||
needsFlags: "ማስተካከያ ለመጠየቅ ቢያንስ አንድ ነገር ምልክት ያድርጉ",
|
||||
needsCapital: "መጀመሪያ የተረጋገጠውን ካፒታል ይመዝግቡ",
|
||||
needsInspection: "የምርመራ ውጤት ያስፈልጋል",
|
||||
inspectionNotYetDue:
|
||||
"ምርመራ ለ{{date}} ተይዟል። ውጤቶች ከተያዘው ሰዓት በኋላ መመዝገብ ይችላሉ።",
|
||||
needsDocumentReviews:
|
||||
"መጀመሪያ ሁሉንም ሰነዶች ይቀበሉ — ከ{{total}} {{accepted}} ተቀብለዋል። የሰነዶች ትር ከፍተው ቀሪዎቹን ይቀበሉ።",
|
||||
needsDocumentsUploaded: "እስካሁን የሚገመገም ሰነድ አልተጫነም",
|
||||
},
|
||||
inspectionFailedBlocked:
|
||||
"ምርመራው ስላልተሳካ ማጽደቅ አይቻልም። ድጋሚ ምርመራ ያስይዙ፣ ማስተካከያ ይጠይቁ ወይም ማመልከቻውን ውድቅ ያድርጉ።",
|
||||
reasons: {
|
||||
incompleteDocuments: "ያልተሟሉ ሰነዶች",
|
||||
belowCapital: "ካፒታል ከሚያስፈልገው በታች",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -242,6 +242,7 @@ export const am: Translations = {
|
||||
RESUBMIT_REQUIRED: 'እንደገና ማስገባት ያስፈልጋል',
|
||||
INSPECTION_PENDING: 'ቁጥጥር በመጠባበቅ ላይ',
|
||||
INSPECTION_COMPLETED: 'ቁጥጥር ተጠናቋል',
|
||||
INSPECTION_FAILED: 'ቁጥጥር አልተሳካም',
|
||||
APPROVED: 'ጸድቋል',
|
||||
REJECTED: 'ውድቅ ተደርጓል',
|
||||
ON_HOLD: 'ላይ ቆሟል',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
};
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user