From 51627f991864385052f94071c208f272208b7b31 Mon Sep 17 00:00:00 2001 From: estifanos Date: Fri, 28 Aug 2026 06:46:06 +0000 Subject: [PATCH] feat: add inspection rescheduling workflow and unlock dependencies for flagged document sections --- .../features/license-review/config/actions.ts | 18 ++ .../pages/LicenseReviewPage/index.tsx | 198 +++++++++++++----- apps/backoffice/src/app/i18n/locales/am.ts | 5 + apps/backoffice/src/app/i18n/locales/en.ts | 5 + apps/backoffice/vite.config.mts | 20 +- .../licensing/components/DocumentSlots.tsx | 13 +- .../pages/LicenseApplicationPage.tsx | 31 ++- .../lib/features/licensing/licensing-api.ts | 25 +++ .../features/licensing/licensing.helpers.ts | 43 ++++ 9 files changed, 295 insertions(+), 63 deletions(-) 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 8511b7b29..a5ac52faa 100644 --- a/apps/backoffice/src/app/features/license-review/config/actions.ts +++ b/apps/backoffice/src/app/features/license-review/config/actions.ts @@ -28,6 +28,7 @@ export type ActionId = | 'complete-review' | 'approve-documents' | 'schedule-inspection' + | 'reschedule-inspection' | 'record-inspection' | 'final-approve' | 'request-adjustment' @@ -200,6 +201,17 @@ export const ACTIONS: ActionDefinition[] = [ permissions: ['can:create:inspection'], emphasis: 'filled', }, + { + id: 'reschedule-inspection', + tier: 'primary', + labelKey: 'review.actions.rescheduleInspection', + from: ['INSPECTION_PENDING', 'INSPECTION_FAILED'], + // Either permission: the team leader who booked the visit holds CREATE, + // the inspector who has to attend holds UPDATE, and both have a reason to + // move it. The server guards the same pair. + permissions: ['can:create:inspection', 'can:update:inspection'], + emphasis: 'light', + }, { id: 'record-inspection', tier: 'primary', @@ -441,6 +453,12 @@ export function resolveActions(ctx: ResolveContext): ResolvedAction[] { // one applies depends on whether an inspection is already booked. if (action.id === 'schedule-inspection' && ctx.hasPendingInspection) return []; if (action.id === 'record-inspection' && !ctx.hasPendingInspection) return []; + // The mirror of the scheduling gate: there is nothing to move until a + // visit is booked, and once one is, moving it is the officer's only + // option until the day arrives. + if (action.id === 'reschedule-inspection' && !ctx.hasPendingInspection) { + return []; + } // The transition table doesn't know which types need an inspection, so // `availableEvents` lists approve-documents at UNDER_EVALUATION even for 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 3c5b8f2f1..cfb2bf6ee 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 @@ -32,6 +32,7 @@ import { IconLayoutSidebarRightCollapse, IconLayoutSidebarRightExpand, IconPaperclip, + IconPencil, IconQuestionMark, IconX, } from "@tabler/icons-react"; @@ -70,6 +71,7 @@ import { useRequestAdjustmentMutation, useResumeApplicationMutation, useScheduleInspectionMutation, + useRescheduleInspectionMutation, useGetCertificateUrlForOfficerMutation, uploadDocument, type RemarkTargetType, @@ -218,6 +220,7 @@ export function LicenseReviewPage() { const [finalApprove] = useFinalApproveMutation(); const [rejectApplication] = useRejectApplicationMutation(); const [scheduleInspection] = useScheduleInspectionMutation(); + const [rescheduleInspection] = useRescheduleInspectionMutation(); const [recordResult] = useRecordInspectionResultMutation(); const [confirmPayment] = useConfirmPaymentMutation(); const [scheduleIssuance] = useScheduleIssuanceMutation(); @@ -261,6 +264,9 @@ export function LicenseReviewPage() { const [inspectionTimeSlot, setInspectionTimeSlot] = useState<"MORNING" | "AFTERNOON">( "MORNING", ); + /** The booking modal moves an existing visit rather than creating one. */ + const [rescheduling, setRescheduling] = useState(false); + const [rescheduleReason, setRescheduleReason] = useState(""); const [issuanceOpen, setIssuanceOpen] = useState(false); const [issuanceDate, setIssuanceDate] = useState(""); const [resultOpen, setResultOpen] = useState(false); @@ -311,10 +317,14 @@ export function LicenseReviewPage() { 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. + // inspection_not_yet_due guard. The column holds a calendar day, so the + // comparison is between day strings in the authority's timezone: parsing + // "2026-08-28" as a Date would read it as UTC midnight, i.e. 03:00 in Addis. const inspectionNotYetDue = Boolean( pendingInspection?.scheduledDate && - new Date(pendingInspection.scheduledDate) > new Date(), + new Intl.DateTimeFormat("en-CA", { timeZone: "Africa/Addis_Ababa" }).format( + new Date(), + ) < pendingInspection.scheduledDate.slice(0, 10), ); const { data: findingsEvidence = [], refetch: refetchFindingsEvidence } = @@ -558,12 +568,29 @@ export function LicenseReviewPage() { } } + /** Opens the booking modal seeded with the visit already on the books. */ + function openReschedule() { + if (!pendingInspection) return; + setRescheduling(true); + setInspectionDate(pendingInspection.scheduledDate ?? ""); + setInspectionTimeSlot(pendingInspection.timeSlot ?? "MORNING"); + setRescheduleReason(""); + setInspectionOpen(true); + } + /** Actions with their own dedicated form open that; the rest confirm. */ function handleAction(action: ResolvedAction) { switch (action.id) { case "schedule-inspection": + setRescheduling(false); + setInspectionDate(""); + setInspectionTimeSlot("MORNING"); + setRescheduleReason(""); setInspectionOpen(true); return; + case "reschedule-inspection": + openReschedule(); + return; case "schedule-issuance": setIssuanceOpen(true); return; @@ -1246,21 +1273,48 @@ export function LicenseReviewPage() { )} - - {inspection.result === "PASSED" - ? t("review.passed", "Passed") - : inspection.result === "FAILED" - ? t("review.failed", "Failed") - : t( - `review.inspectionStatus.${inspection.status}`, - inspection.status, + + {inspection.status === "SCHEDULED" && + inspection.id === pendingInspection?.id && + can([ + "can:create:inspection", + "can:update:inspection", + ]) && ( + + > + + + + + )} + + {inspection.result === "PASSED" + ? t("review.passed", "Passed") + : inspection.result === "FAILED" + ? t("review.failed", "Failed") + : t( + `review.inspectionStatus.${inspection.status}`, + inspection.status, + )} + + ))} @@ -1430,7 +1484,11 @@ export function LicenseReviewPage() { setInspectionOpen(false)} - title={t("review.actions.scheduleInspection", "Schedule inspection")} + title={ + rescheduling + ? t("review.actions.rescheduleInspection", "Reschedule inspection") + : t("review.actions.scheduleInspection", "Schedule inspection") + } > + {rescheduling && ( +