implement contract cancellation feature and update contract statuses

- Added functionality to cancel contracts, allowing users to provide a reason for cancellation.
- Updated contract statuses to include SUSPENDED and changed CLOSED to COMPLETED.
- Enhanced the UI to reflect the new cancellation option and updated messaging for contract statuses.
- Refactored contract booking actions to accommodate changes in booking logic for ONE_TIME and GENERAL contracts.
- Removed clearance document management from the contract detail page, as it is now handled per booking.
- Introduced a SQL script to reset bookings and train schedules for development purposes.
This commit is contained in:
Marshal
2026-07-28 05:02:58 +00:00
parent 481878e553
commit 2429f6b629
83 changed files with 3083 additions and 3729 deletions

View File

@@ -1,13 +1,15 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { Button, Modal, Stack, Text, Textarea } from "@mantine/core";
import { Button, Group, Modal, Stack, Text, Textarea } from "@mantine/core";
import {
Check,
Eye,
FilePen,
FileSignature,
MessageSquareWarning,
PauseCircle,
PlayCircle,
ShieldCheck,
XCircle,
Zap,
@@ -43,6 +45,21 @@ const CLEARANCE_REVIEW_STATUSES = [
"CLEARANCE_READY_FOR_BOOKING",
];
/**
* Every step from the customer signature onward can be frozen. Mirrors
* SUSPENDABLE_CONTRACT_STATUSES on the API — the server is the authority, this
* list only decides whether the button is drawn.
*/
const SUSPENDABLE_STATUSES = [
"SIGNED_CUSTOMER",
"FULLY_EXECUTED",
"CONTRACT_ACTIVE",
"AWAITING_CLEARANCE_DOCUMENTS",
"CLEARANCE_UNDER_REVIEW",
"CLEARANCE_READY_FOR_BOOKING",
"ACTIVE_SHIPMENT_IN_PROGRESS",
];
/** Detail-page staff actions: accept / request changes / reject / generate / sign. */
export function ContractActionsToolbar({
contract,
@@ -62,6 +79,8 @@ export function ContractActionsToolbar({
FREIGHT_PERMS.contracts.requestChanges[arm],
);
const mayReject = hasPermission(user, FREIGHT_PERMS.contracts.reject[arm]);
// One key both ways — whoever can freeze a contract can unfreeze it.
const maySuspend = hasPermission(user, FREIGHT_PERMS.contracts.suspend);
const [editorOpen, setEditorOpen] = useState(false);
const [editorMode, setEditorMode] = useState<"accept" | "edit">("accept");
@@ -70,6 +89,10 @@ export function ContractActionsToolbar({
const [changesNote, setChangesNote] = useState("");
const [rejectOpen, setRejectOpen] = useState(false);
const [rejectReason, setRejectReason] = useState("");
const [suspendOpen, setSuspendOpen] = useState(false);
const [suspendReason, setSuspendReason] = useState("");
const [resumeOpen, setResumeOpen] = useState(false);
const [resumeNote, setResumeNote] = useState("");
// Whether the document is editable depends on WHO is viewing — only the
// approver whose turn it is may edit — so the server decides, not the client.
@@ -110,6 +133,86 @@ export function ContractActionsToolbar({
);
}
// Frozen: nothing on this contract moves — no new bookings, no progress on
// the shipments already under it — until the suspension is lifted, which
// returns the contract to the status it was suspended at.
if (status === "SUSPENDED") {
return (
<SectionCard icon={PauseCircle} title="Contract suspended">
<Stack gap="sm">
<Text size="sm" c="dimmed">
This contract is frozen. New bookings are blocked and its existing
shipments cannot progress.
{contract.statusBeforeSuspension
? ` Lifting the suspension returns it to ${contract.statusBeforeSuspension}.`
: ""}
</Text>
{contract.latestSuspensionNote && (
<Text size="sm">
<b>Reason:</b> {contract.latestSuspensionNote}
</Text>
)}
{maySuspend ? (
<Button
fullWidth
color="edr-green"
leftSection={<PlayCircle size={16} />}
onClick={() => setResumeOpen(true)}
>
Lift suspension
</Button>
) : (
<Text size="sm" c="dimmed">
You do not have permission to lift a suspension.
</Text>
)}
</Stack>
<Modal
opened={resumeOpen}
onClose={() => setResumeOpen(false)}
title="Lift suspension?"
centered
>
<Stack gap="md">
<Text size="sm">
Contract <b>{contract.reference}</b> will return to{" "}
<b>{contract.statusBeforeSuspension ?? "CONTRACT_ACTIVE"}</b> and
the customer will be notified. Bookings on it resume immediately.
</Text>
<Textarea
label="Note (optional)"
placeholder="Why the suspension is being lifted…"
autosize
minRows={2}
value={resumeNote}
onChange={(e) => setResumeNote(e.currentTarget.value)}
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setResumeOpen(false)}>
Cancel
</Button>
<Button
color="edr-green"
loading={mutations.resume.isPending}
onClick={() =>
mutations.resume.mutate(resumeNote.trim() || undefined, {
onSuccess: () => {
setResumeOpen(false);
setResumeNote("");
},
})
}
>
Lift suspension
</Button>
</Group>
</Stack>
</Modal>
</SectionCard>
);
}
const canAccept =
status === "SUBMITTED" && (mayAccept || mayRequestChanges || mayReject);
// The document stays editable for the whole approval chain, but only by the
@@ -130,6 +233,7 @@ export function ContractActionsToolbar({
const clearanceReviewer = contract.customsClearingEnabled
? "Review clearance (GL)"
: "Review clearance (Ops)";
const canSuspend = maySuspend && SUSPENDABLE_STATUSES.includes(status);
return (
<SectionCard icon={Zap} title="Staff actions">
@@ -241,10 +345,23 @@ export function ContractActionsToolbar({
{/* GL "Create booking" removed for now — clearance ends at finalize and
the customer creates the booking in the portal. */}
{canSuspend && (
<Button
fullWidth
variant="light"
color="orange"
leftSection={<PauseCircle size={16} />}
onClick={() => setSuspendOpen(true)}
>
Suspend contract
</Button>
)}
{!canAccept &&
!inApproval &&
!canViewContract &&
!canReviewClearance && (
!canReviewClearance &&
!canSuspend && (
<Text size="sm" c="dimmed">
No staff actions available for this status. Monitor until the
workflow advances.
@@ -315,6 +432,51 @@ export function ContractActionsToolbar({
</Stack>
</Modal>
{/* Suspend — freezes the contract AND every shipment under it */}
<Modal
opened={suspendOpen}
onClose={() => setSuspendOpen(false)}
title="Suspend this contract?"
centered
>
<Stack gap="md">
<Text size="sm">
Contract <b>{contract.reference}</b> will be frozen at its current
step (<b>{status}</b>). No new shipments can be booked and the
shipments already under it stop moving until the suspension is
lifted. The customer is notified.
</Text>
<Textarea
label="Reason for suspension"
placeholder="Explain why this contract is being suspended…"
autosize
minRows={3}
value={suspendReason}
onChange={(e) => setSuspendReason(e.currentTarget.value)}
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setSuspendOpen(false)}>
Cancel
</Button>
<Button
color="orange"
disabled={!suspendReason.trim()}
loading={mutations.suspend.isPending}
onClick={() =>
mutations.suspend.mutate(suspendReason, {
onSuccess: () => {
setSuspendOpen(false);
setSuspendReason("");
},
})
}
>
Suspend contract
</Button>
</Group>
</Stack>
</Modal>
{/* Reject */}
<Modal
opened={rejectOpen}

View File

@@ -31,6 +31,17 @@ const isHazardStep = (requiredRole: string): boolean =>
const roleLabel = (requiredRole: string): string =>
CONTRACT_APPROVAL_ROLE_LABELS[requiredRole] ?? requiredRole;
/** When the approver acted — "27 Jul 2026, 18:18". */
const fmtActedAt = (iso: string): string =>
new Date(iso).toLocaleString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
type Mutations = ReturnType<typeof useContractMutations>;
interface ContractApprovalStepsCardProps {
@@ -349,6 +360,10 @@ function StepRow({
? "edr-green"
: "gray";
const hazard = isHazardStep(step.requiredRole);
// A send-back wipes acted_at with the status, so a re-opened step shows no
// stale timestamp.
const acted =
step.actedAt && step.status !== "PENDING" ? fmtActedAt(step.actedAt) : null;
return (
<Group
@@ -412,6 +427,14 @@ function StepRow({
</Badge>
)}
</Group>
{/* Decided steps carry their verdict time — the chain doubles as an
audit trail, so "who was waiting on whom, and for how long" has to
be readable without opening the revision history. */}
{acted && (
<Text size="xs" c="dimmed" truncate>
{step.status === "REJECTED" ? "Rejected" : "Approved"} {acted}
</Text>
)}
{step.note && (
<Text size="xs" c="dimmed" truncate>
{step.note}

View File

@@ -37,6 +37,13 @@ function newArticleId(): string {
return `art-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
}
/** Midnight today — the earliest day a contract's validity may start. */
function startOfToday(): Date {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d;
}
interface EditableArticle {
id: string;
title: string;
@@ -118,6 +125,14 @@ export function ContractDocumentEditorModal({
);
}, [opened, draft]);
// Accept mode opens on today — a contract never starts in the past, and the
// pickers below refuse earlier days.
useEffect(() => {
if (!opened || mode !== "accept") return;
setValidityStart(startOfToday());
setValidityEnd(null);
}, [opened, mode]);
// Default validity to the first configured option (accept mode).
// useEffect(() => {
// if (mode === "accept" && !validityDays && validityOptions.length > 0) {
@@ -419,6 +434,7 @@ export function ContractDocumentEditorModal({
placeholder="Contract validity start"
value={validityStart}
onChange={(v) => setValidityStart(v ? new Date(v) : null)}
minDate={startOfToday()}
maxDate={validityEnd ?? undefined}
clearable
/>
@@ -427,7 +443,7 @@ export function ContractDocumentEditorModal({
placeholder="Contract validity end"
value={validityEnd}
onChange={(v) => setValidityEnd(v ? new Date(v) : null)}
minDate={validityStart ?? undefined}
minDate={validityStart ?? startOfToday()}
clearable
/>
</Group>

View File

@@ -1018,14 +1018,9 @@ export default function GlCreateBookingForm() {
// Non-fatal
}
}
if (contract.contractKind === "GENERAL") {
// GENERAL per-booking clearance: land on the booking's clearance
// detail — the same page the Shipments tab on the hub opens.
navigate(`/dashboard/clearance/${booking.id}`);
} else {
// ONE_TIME customs keeps its clearance on the contract.
navigate(`/dashboard/contracts/clearance/${contract.id}`);
}
// Clearance is always per booking — land on that booking's clearance
// detail, the same page the hub opens.
navigate(`/dashboard/clearance/${booking.id}`);
},
});
};
@@ -1067,7 +1062,13 @@ export default function GlCreateBookingForm() {
variant="default"
radius="md"
leftSection={<ChevronLeft size={16} />}
onClick={() => navigate(`/dashboard/contracts/clearance/${contract.id}`)}
onClick={() =>
navigate(
completeBookingId
? `/dashboard/clearance/${completeBookingId}`
: "/dashboard/contracts/clearance",
)
}
>
Back to clearance
</Button>