mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-07 14:15:45 +00:00
feat: add inspection rescheduling workflow and unlock dependencies for flagged document sections
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user