feat: add inspection rescheduling workflow and unlock dependencies for flagged document sections

This commit is contained in:
estifanos
2026-08-28 06:46:06 +00:00
parent 4317e3cb89
commit 51627f9918
9 changed files with 295 additions and 63 deletions

View File

@@ -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

View File

@@ -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() {
</Text>
)}
</div>
<Badge
variant="light"
color={
inspection.result === "FAILED" ? "red" : "teal"
}
>
{inspection.result === "PASSED"
? t("review.passed", "Passed")
: inspection.result === "FAILED"
? t("review.failed", "Failed")
: t(
`review.inspectionStatus.${inspection.status}`,
inspection.status,
<Group gap="xs">
{inspection.status === "SCHEDULED" &&
inspection.id === pendingInspection?.id &&
can([
"can:create:inspection",
"can:update:inspection",
]) && (
<Tooltip
label={t(
"review.actions.rescheduleInspection",
"Reschedule inspection",
)}
</Badge>
>
<ActionIcon
variant="subtle"
size="sm"
aria-label={t(
"review.actions.rescheduleInspection",
"Reschedule inspection",
)}
onClick={openReschedule}
>
<IconPencil size={16} />
</ActionIcon>
</Tooltip>
)}
<Badge
variant="light"
color={
inspection.result === "FAILED" ? "red" : "teal"
}
>
{inspection.result === "PASSED"
? t("review.passed", "Passed")
: inspection.result === "FAILED"
? t("review.failed", "Failed")
: t(
`review.inspectionStatus.${inspection.status}`,
inspection.status,
)}
</Badge>
</Group>
</Group>
))}
</Stack>
@@ -1430,7 +1484,11 @@ export function LicenseReviewPage() {
<Modal
opened={inspectionOpen}
onClose={() => setInspectionOpen(false)}
title={t("review.actions.scheduleInspection", "Schedule inspection")}
title={
rescheduling
? t("review.actions.rescheduleInspection", "Reschedule inspection")
: t("review.actions.scheduleInspection", "Schedule inspection")
}
>
<Stack>
<AmharicDatePicker
@@ -1446,36 +1504,72 @@ export function LicenseReviewPage() {
{ value: "AFTERNOON", label: t("review.afternoon", "Afternoon") },
]}
/>
{rescheduling && (
<Textarea
label={t("review.rescheduleReason", "Why is it moving?")}
description={t(
"review.rescheduleReasonHint",
"Kept in the audit trail and sent to the applicant.",
)}
value={rescheduleReason}
onChange={(e) => setRescheduleReason(e.currentTarget.value)}
autosize
minRows={2}
/>
)}
<ModalFooter>
{/* Mantine strips pointer events from a disabled control, so the
tooltip wraps a span — same trick as DecisionBar's ActionButton;
a disabled button must still say why. */}
<Tooltip
label={t("review.pickDate", "Pick a date first")}
disabled={Boolean(inspectionDate)}
>
<span>
<button type="button" hidden aria-hidden />
</span>
</Tooltip>
<span style={{ display: "inline-flex" }}>
<ActionIcon
variant="filled"
size="lg"
disabled={!inspectionDate}
aria-label={t("review.schedule", "Schedule")}
aria-label={
rescheduling
? t("review.reschedule", "Reschedule")
: t("review.schedule", "Schedule")
}
onClick={() =>
run(
async () => {
await scheduleInspection({
applicationId: id,
scheduledDate: inspectionDate,
timeSlot: inspectionTimeSlot,
}).unwrap();
if (rescheduling) {
// Guarded by the action's own gate, which only offers
// rescheduling while a booking exists.
if (!pendingInspection) return;
await rescheduleInspection({
inspectionId: pendingInspection.id,
applicationId: id,
scheduledDate: inspectionDate,
timeSlot: inspectionTimeSlot,
...(rescheduleReason.trim()
? { reason: rescheduleReason.trim() }
: {}),
}).unwrap();
} else {
await scheduleInspection({
applicationId: id,
scheduledDate: inspectionDate,
timeSlot: inspectionTimeSlot,
}).unwrap();
}
setInspectionOpen(false);
},
t("review.done.scheduled", "Inspection scheduled"),
rescheduling
? t("review.done.rescheduled", "Inspection rescheduled")
: t("review.done.scheduled", "Inspection scheduled"),
)
}
>
<IconCheck size={18} />
</ActionIcon>
</span>
</Tooltip>
</ModalFooter>
</Stack>
</Modal>
@@ -1492,34 +1586,36 @@ export function LicenseReviewPage() {
onChange={setIssuanceDate}
/>
<ModalFooter>
{/* Span-wrapped like DecisionBar's ActionButton — Mantine strips
pointer events from a disabled control, and a disabled button
must still say why. */}
<Tooltip
label={t("review.pickDate", "Pick a date and time first")}
label={t("review.pickDate", "Pick a date first")}
disabled={Boolean(issuanceDate)}
>
<span>
<button type="button" hidden aria-hidden />
<span style={{ display: "inline-flex" }}>
<ActionIcon
variant="filled"
size="lg"
disabled={!issuanceDate}
aria-label={t("review.schedule", "Schedule")}
onClick={() =>
run(
async () => {
await scheduleIssuance({
id,
scheduledDate: issuanceDate,
}).unwrap();
setIssuanceOpen(false);
},
t("review.done.scheduleIssuance", "Pickup scheduled"),
)
}
>
<IconCheck size={18} />
</ActionIcon>
</span>
</Tooltip>
<ActionIcon
variant="filled"
size="lg"
disabled={!issuanceDate}
aria-label={t("review.schedule", "Schedule")}
onClick={() =>
run(
async () => {
await scheduleIssuance({
id,
scheduledDate: issuanceDate,
}).unwrap();
setIssuanceOpen(false);
},
t("review.done.scheduleIssuance", "Pickup scheduled"),
)
}
>
<IconCheck size={18} />
</ActionIcon>
</ModalFooter>
</Stack>
</Modal>