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

@@ -168,8 +168,8 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
icon: <FileText />,
permission: FREIGHT_PERMS.bookings.view,
},
// Operations hub: clearance-document review for contracts WITHOUT
// customs clearing (contract-level for one-time, per-booking for general).
// Operations hub: per-shipment clearance-document review for services
// WITHOUT customs clearing (self-clearance) — bookings only.
{
label: "Clearance Documents",
href: "/dashboard/contracts/clearance-documents",

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>

View File

@@ -32,6 +32,11 @@ const KIND_META: Record<string, { label: string; color: string; icon: ReactNode
color: "orange",
icon: <Wrench size={14} />,
},
MAINTENANCE: {
label: "Sent to maintenance",
color: "red",
icon: <Wrench size={14} />,
},
};
const yardLabel = (
@@ -103,10 +108,16 @@ const WagonMovementHistoryModal = ({
<Text size="sm" fw={600}>
{from}
</Text>
<ArrowRight size={13} />
<Text size="sm" fw={600}>
{to}
</Text>
{/* Status events (maintenance) sit in one yard — an arrow
pointing at the same yard reads as a broken row. */}
{movement.fromYardId !== movement.toYardId && (
<>
<ArrowRight size={13} />
<Text size="sm" fw={600}>
{to}
</Text>
</>
)}
<Badge size="xs" variant="light" color={meta.color}>
{meta.label}
</Badge>

View File

@@ -30,7 +30,7 @@ const parseError = (error: unknown, fallback: string) => {
/**
* Step one of the Train Builder: pick the yard it is being assembled in and
* couple at least two locomotives from that yard. The train code is assigned by
* couple at least one locomotive from that yard. The train code is assigned by
* the system. Wagons are attached afterwards on the composition page.
*/
export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrainModalProps) {
@@ -86,9 +86,9 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
}, [opened]);
const handleBuild = async () => {
if (!yardId || locomotiveIds.length < 2) {
if (!yardId || locomotiveIds.length < 1) {
toast({
title: "Pick a yard and couple at least two locomotives",
title: "Pick a yard and couple at least one locomotive",
variant: "destructive",
});
return;
@@ -185,17 +185,15 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
/>
<MultiSelect
label="Locomotives"
description="A train must be pulled by at least two locomotives (front and back). First pick becomes the lead."
placeholder={yardId ? "Select at least two locomotives" : "Select a yard first"}
description="A train must be pulled by at least one locomotive. First pick becomes the lead."
placeholder={yardId ? "Select at least one locomotive" : "Select a yard first"}
data={locomotiveOptions}
value={locomotiveIds}
onChange={setLocomotiveIds}
searchable
disabled={!yardId}
error={
locomotiveIds.length > 0 && locomotiveIds.length < 2
? "Select at least two locomotives"
: undefined
locomotiveIds.length < 1 ? "Select at least one locomotive" : undefined
}
nothingFoundMessage={
yardId ? "No available locomotives in this yard" : "Select a yard first"

View File

@@ -16,7 +16,7 @@ const parseError = (error: unknown, fallback: string) => {
return fallback;
};
/** Swap the locomotive set of a built train (minimum 2, same-yard rule). */
/** Swap the locomotive set of a built train (minimum 1, same-yard rule). */
export default function ChangeLocomotivesModal({
composition,
opened,
@@ -74,8 +74,8 @@ export default function ChangeLocomotivesModal({
const handleSave = async () => {
if (!composition) return;
if (locomotiveIds.length < 2) {
toast({ title: "A train needs at least two locomotives", variant: "destructive" });
if (locomotiveIds.length < 1) {
toast({ title: "A train needs at least one locomotive", variant: "destructive" });
return;
}
try {
@@ -112,9 +112,7 @@ export default function ChangeLocomotivesModal({
onChange={setLocomotiveIds}
searchable
error={
locomotiveIds.length > 0 && locomotiveIds.length < 2
? "Select at least two locomotives"
: undefined
locomotiveIds.length < 1 ? "Select at least one locomotive" : undefined
}
nothingFoundMessage="No available locomotives in this yard"
/>

View File

@@ -12,6 +12,7 @@ import { type ReactNode } from "react";
import { createPortal } from "react-dom";
import type { TrainCompositionWagon } from "@/services/trainBuilder.service";
import { wagonTypeColor } from "./trainStatus";
/** Reparent dragged row to body — fixes position:fixed inside transformed parents. */
const PortalAwareRow = ({
@@ -58,11 +59,36 @@ export default function ConsistWagonList({
);
}
// Legend of the types actually coupled, in consist order — the colour code is
// only readable if the row tints are keyed somewhere.
const legend = [
...new Map(
wagons
.filter((w) => w.wagonType)
.map((w) => [w.wagonType!.code, w.wagonType!]),
).values(),
];
return (
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="train-consist-wagons" isDropDisabled={!editable || busy}>
{(dropProvided) => (
<Stack gap="xs" ref={dropProvided.innerRef} {...dropProvided.droppableProps}>
{legend.length > 1 ? (
<Group gap={6} wrap="wrap">
{legend.map((type) => (
<Badge
key={type.code}
size="sm"
radius="sm"
variant="light"
color={wagonTypeColor(type.code)}
>
{type.code} · {type.name}
</Badge>
))}
</Group>
) : null}
{wagons.map((wagon, index) => (
<Draggable
key={wagon.id}
@@ -97,8 +123,8 @@ export interface ConsistWagonListProps {
editable: boolean;
onReorder: (wagonIds: string[]) => void;
onRemove: (wagonId: string) => void;
/** Detach the wagon and move it to MAINTENANCE status. */
onMaintenance: (wagonId: string) => void;
/** Detach the wagon and move it to MAINTENANCE status (page confirms first). */
onMaintenance: (wagon: TrainCompositionWagon) => void;
busy?: boolean;
}
@@ -119,8 +145,10 @@ function WagonRow({
editable: boolean;
busy: boolean;
onRemove: (wagonId: string) => void;
onMaintenance: (wagonId: string) => void;
onMaintenance: (wagon: TrainCompositionWagon) => void;
}) {
const color = wagonTypeColor(wagon.wagonType?.code);
return (
<PortalAwareRow snapshot={snapshot}>
<Group
@@ -132,9 +160,14 @@ function WagonRow({
p="sm"
style={{
...dragProvided.draggableProps.style,
border: "1px solid var(--mantine-color-gray-3)",
border: `1px solid var(--mantine-color-${color}-2)`,
borderLeft: `4px solid var(--mantine-color-${color}-5)`,
borderRadius: "var(--mantine-radius-md)",
background: snapshot.isDragging ? "var(--mantine-color-gray-0)" : "white",
// Tinted by wagon type so a mixed consist is scannable at a glance;
// the drag state keeps its own neutral lift.
background: snapshot.isDragging
? "white"
: `var(--mantine-color-${color}-0)`,
boxShadow: snapshot.isDragging ? "0 8px 24px rgba(0, 0, 0, 0.12)" : undefined,
cursor: editable ? (snapshot.isDragging ? "grabbing" : "grab") : "default",
userSelect: "none",
@@ -145,13 +178,20 @@ function WagonRow({
<GripVertical size={18} />
</Box>
) : null}
<Badge variant="light" color="gray" size="sm">
<Badge variant="filled" color={color} size="sm">
{index + 1}
</Badge>
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={600} ff="monospace" truncate>
{wagon.wagonNumber}
</Text>
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={600} ff="monospace" truncate>
{wagon.wagonNumber}
</Text>
{wagon.wagonType ? (
<Badge variant="light" color={color} size="xs" radius="sm">
{wagon.wagonType.code}
</Badge>
) : null}
</Group>
<Text size="xs" c="dimmed" truncate>
{wagon.wagonType
? `${wagon.wagonType.name} · ${wagon.wagonType.capacityTons}T cap · ${wagon.wagonType.lengthMeters}m`
@@ -165,7 +205,7 @@ function WagonRow({
variant="subtle"
color="orange"
disabled={busy}
onClick={() => onMaintenance(wagon.id)}
onClick={() => onMaintenance(wagon)}
aria-label={`Send wagon ${wagon.wagonNumber} to maintenance`}
>
<Wrench size={16} />

View File

@@ -56,6 +56,58 @@ export const locomotiveStatusLabel = (status: string): string =>
.replace(/_/g, " ")
.replace(/^\w/, (c) => c.toUpperCase());
/**
* Hues for the wagon-type color code. No red or gray — red reads as a fault on
* a consist row, gray is the "unknown type" fallback.
*/
const WAGON_TYPE_COLORS = [
"blue",
"teal",
"grape",
"orange",
"cyan",
"indigo",
"pink",
"lime",
"violet",
"yellow",
];
/**
* Fixed hue per seeded wagon-type code. Related families sit on neighbouring
* hues (gondolas blue/indigo, hoppers grape/violet, flats teal/lime) so a
* consist reads as groups, not confetti. Explicit rather than hashed because
* hashing 10 codes into 10 hues collides — and two types sharing a colour is
* exactly what a colour code must not do.
*/
const WAGON_TYPE_CODE_COLORS: Record<string, string> = {
CW3: "blue", // Gondola open
CW4: "indigo", // Gondola covered
KW2: "grape", // Hopper covered
KW3: "violet", // Hopper open
NW5: "teal", // Flat
NW6: "lime", // Flat (long)
NW7: "pink", // Double deck sedan
BW1: "cyan", // Refrigerated
GW2: "orange", // Tank
PW2: "yellow", // Box
};
/**
* Stable hue per wagon-type code. Unseeded codes fall back to a hash so a new
* type still gets a consistent colour instead of collapsing to gray.
*/
export const wagonTypeColor = (code?: string | null): string => {
if (!code) return "gray";
const seeded = WAGON_TYPE_CODE_COLORS[code];
if (seeded) return seeded;
let hash = 0;
for (let i = 0; i < code.length; i++) {
hash = (hash * 31 + code.charCodeAt(i)) >>> 0;
}
return WAGON_TYPE_COLORS[hash % WAGON_TYPE_COLORS.length]!;
};
/** Badge color per trade direction (Mantine palette keys). */
export const directionColor = (direction?: string | null): string =>
direction === "IMPORT" ? "blue" : direction === "EXPORT" ? "orange" : "gray";

View File

@@ -170,6 +170,8 @@ export const URL_CONSTANTS = {
STAFF_REQUEST_CHANGES: (id: string) =>
`/contracts/${id}/staff/request-changes`,
STAFF_REJECT: (id: string) => `/contracts/${id}/staff/reject`,
SUSPEND: (id: string) => `/contracts/${id}/suspend`,
RESUME: (id: string) => `/contracts/${id}/resume`,
APPROVE_STEP: (id: string, stepId: string) =>
`/contracts/${id}/approval-steps/${stepId}/approve`,
REJECT_STEP: (id: string, stepId: string) =>
@@ -217,10 +219,7 @@ export const URL_CONSTANTS = {
`/contracts/${id}/clearance/export-release`,
CLEARANCE_FINALIZE_EXPORT: (id: string) =>
`/contracts/${id}/clearance/finalize-export-clearance`,
CLEARANCE_ET_QUEUE: "/contracts/clearance/et-queue",
CLEARANCE_DJ_QUEUE: "/contracts/clearance/dj-queue",
// Path A self-clearance — Operations reviews the customer's own clearance docs.
OPS_CLEARANCE_QUEUE: "/contracts/clearance/ops-queue",
OPS_CLEARANCE_REVIEW: (id: string) =>
`/contracts/${id}/clearance/ops-review`,
OPS_CLEARANCE_FINALIZE: (id: string) =>
@@ -228,6 +227,9 @@ export const URL_CONSTANTS = {
CLEARANCE_HISTORY: "/contracts/clearance/history",
OPS_CLEARANCE_HISTORY: "/contracts/clearance/ops-history",
BOOKINGS: (id: string) => `/contracts/${id}/bookings`,
BOOKINGS_INITIATE: (id: string) => `/contracts/${id}/bookings/initiate`,
// GL worklist: executed customs contracts with no shipment instance yet.
AWAITING_SHIPMENT: "/contracts/awaiting-shipment",
BOOKINGS_COMPLETE: (id: string, bookingId: string) =>
`/contracts/${id}/bookings/${bookingId}/complete`,
VALIDATE_SHIPMENT: (id: string) => `/contracts/${id}/validate-shipment`,

View File

@@ -67,8 +67,14 @@ export const CONTRACT_STATUS_STYLES: Record<string, StatusStyle> = {
label: "Shipment in Progress",
color: "bg-sky-50 text-sky-700 border-sky-200",
},
SUSPENDED: {
label: "Suspended",
color: "bg-orange-50 text-orange-700 border-orange-200",
},
CONTRACT_CLOSED: {
label: "Closed",
// A fulfilled contract (one-time shipment delivered, or cap consumed) —
// greyed out to read as inactive.
label: "Completed",
color: "bg-slate-100 text-slate-700 border-slate-300",
},
EXPIRED: {
@@ -122,6 +128,7 @@ export const CONTRACT_STATUS_COLOR: Record<string, string> = {
CLEARANCE_UNDER_REVIEW: "yellow",
CLEARANCE_READY_FOR_BOOKING: "edr-green",
ACTIVE_SHIPMENT_IN_PROGRESS: "cyan",
SUSPENDED: "orange",
CONTRACT_CLOSED: "gray",
EXPIRED: "red",
REJECTED: "red",
@@ -232,11 +239,17 @@ export const CONTRACT_STATUS_META: Record<string, StatusMeta> = {
stage: 4,
},
CONTRACT_CLOSED: {
title: "Closed",
description: "Contract fulfilled and closed.",
title: "Completed",
description: "Contract fulfilled — its shipment was delivered.",
color: "text-slate-500",
stage: 5,
},
SUSPENDED: {
title: "Suspended",
description: "Frozen by EDR — bookings and shipments are on hold.",
color: "text-orange-600",
stage: -1,
},
EXPIRED: {
title: "Expired",
description: "Validity window elapsed.",

View File

@@ -53,27 +53,14 @@ export function useContractClearanceQueue(enabled = true) {
});
}
export function useEtClearanceQueue(enabled = true) {
/**
* GL worklist: executed customs contracts still waiting for their shipment
* instance to be opened (clearance itself lives on the booking).
*/
export function useAwaitingShipmentContracts(enabled = true) {
return useQuery({
queryKey: QUERY_KEYS.CONTRACTS.clearanceQueue("ET"),
queryFn: () => contractsService.getEtClearanceQueue(),
enabled,
});
}
export function useDjClearanceQueue(enabled = true) {
return useQuery({
queryKey: QUERY_KEYS.CONTRACTS.clearanceQueue("DJ"),
queryFn: () => contractsService.getDjClearanceQueue(),
enabled,
});
}
/** Path A self-clearance queue (Operations reviews non-customs contracts). */
export function useOpsClearanceQueue(enabled = true) {
return useQuery({
queryKey: QUERY_KEYS.CONTRACTS.clearanceQueue("OPS"),
queryFn: () => contractsService.getOpsClearanceQueue(),
queryKey: QUERY_KEYS.CONTRACTS.clearanceQueue("AWAITING_SHIPMENT"),
queryFn: () => contractsService.getAwaitingShipmentContracts(),
enabled,
});
}
@@ -172,6 +159,19 @@ export function useContractMutations(contractId: string) {
onError: (error) => toast.error(extractErrorMessage(error, "Failed to reject contract")),
});
const suspend = useMutation({
mutationFn: (reason: string) => contractsService.suspend(contractId, reason),
onSuccess: (data) => onSuccess(data, "Contract suspended"),
onError: (error) => toast.error(extractErrorMessage(error, "Failed to suspend contract")),
});
const resume = useMutation({
mutationFn: (note: string | undefined) => contractsService.resume(contractId, note),
onSuccess: (data) =>
onSuccess(data, `Suspension lifted — contract is back to ${data.status}`),
onError: (error) => toast.error(extractErrorMessage(error, "Failed to lift suspension")),
});
const approveStep = useMutation({
// The server derives the required role from the step itself, so the client
// does not send one.
@@ -277,6 +277,8 @@ export function useContractMutations(contractId: string) {
updateDocument,
requestChanges,
reject,
suspend,
resume,
approveStep,
rejectStep,
generateContract,

View File

@@ -60,6 +60,7 @@ export const FREIGHT_PERMS = {
clearanceDutyAdvise: "edr_freight_app:contracts:clearance_duty_advise",
clearanceEtActions: "edr_freight_app:contracts:clearance_et_actions",
clearanceDjActions: "edr_freight_app:contracts:clearance_dj_actions",
suspend: "edr_freight_app:contracts:suspend",
},
trainScheduling: {
view: "edr_freight_app:train_scheduling:view",

View File

@@ -95,10 +95,10 @@ export default function DocumentClearanceDetailPage() {
}, [clearance]);
const reference = booking?.reference ?? "Clearance";
// Phased customs clearance runs on every contract booking now — ONE_TIME and
// GENERAL alike; the persisted phase is what marks the workflow as running.
const isPhasedGeneral =
Boolean(booking?.customsClearingEnabled) &&
booking?.contractKind === "GENERAL" &&
Boolean(clearance?.phase);
Boolean(booking?.customsClearingEnabled) && Boolean(clearance?.phase);
// Bare initiated instance whose clearance is done: GL completes the booking
// (container numbers, VGM, shipment day) via the completion form.

View File

@@ -1,4 +1,3 @@
import { directionLabel } from "@/lib/utils";
import {
ActionIcon,
Box,
@@ -6,36 +5,20 @@ import {
Group,
Select,
Stack,
Tabs,
Text,
TextInput,
ThemeIcon,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import {
ArrowRight,
FileText,
Inbox,
RefreshCw,
Repeat,
Search,
User,
X,
} from "lucide-react";
import { FileText, Inbox, RefreshCw, Search, User, X } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { bookingTable } from "@/components/bookings/booking-ui.styles";
import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge";
import { PageContainer, PageHeader } from "@/components/page";
import {
toContractListRow,
type ContractListRow,
} from "@/features/contracts/mapContractListRow";
import { bookingsService } from "@/services/bookings.service";
import { contractsService } from "@/services/contracts.service";
import type { BookingDetail } from "@/types/booking";
import {
Badge,
@@ -46,45 +29,16 @@ import {
} from "@edr/ui-common";
/**
* Operations "Clearance Documents" hub — worklist for clearance-document
* review on contracts WITHOUT customs clearing (self-clearance):
* Contracts tab = contract-level review (one-time flow), General tab =
* per-booking review under GENERAL non-customs contracts. Rows deep-link to
* the existing review detail pages; search / status filter / pagination are
* all server-side.
* Operations "Clearance Documents" hub — the worklist for self-clearance
* (non-customs) document review. Clearance is always per SHIPMENT: the customer
* uploads his documents on the booking he initiated, whatever kind of contract
* it draws on, so this hub lists bookings only. Rows deep-link to the booking
* clearance review page; search / status filter / pagination are server-side.
*/
type HubTab = "contracts" | "general";
const PAGE_SIZE = 10;
/** Status filter options for the Contracts tab (values = `statuses` param). */
const CONTRACT_STATUS_OPTIONS = [
{
value: [
"AWAITING_CLEARANCE_DOCUMENTS",
"CLEARANCE_UNDER_REVIEW",
"CLEARANCE_READY_FOR_BOOKING",
"FULLY_EXECUTED",
"CONTRACT_ACTIVE",
"ACTIVE_SHIPMENT_IN_PROGRESS",
"CONTRACT_CLOSED",
"CANCELLED",
].join(","),
label: "All statuses",
},
{ value: "AWAITING_CLEARANCE_DOCUMENTS", label: "Awaiting documents" },
{ value: "CLEARANCE_UNDER_REVIEW", label: "Under review" },
{ value: "CLEARANCE_READY_FOR_BOOKING", label: "Ready for booking" },
{ value: "FULLY_EXECUTED,CONTRACT_ACTIVE", label: "Finalized" },
{
value: "ACTIVE_SHIPMENT_IN_PROGRESS,CONTRACT_CLOSED",
label: "In progress / closed",
},
{ value: "CANCELLED", label: "Cancelled" },
];
/** Status filter options for the General (per-booking) tab. */
/** Status filter options (values = `statuses` param). */
const BOOKING_STATUS_OPTIONS = [
{
value: "AWAITING_DOCUMENTS,DOCUMENTS_UNDER_REVIEW,CLEARANCE_READY",
@@ -97,12 +51,8 @@ const BOOKING_STATUS_OPTIONS = [
export default function ClearanceDocumentsPage() {
const navigate = useNavigate();
const [hubTab, setHubTab] = useState<HubTab>("contracts");
const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query, 300);
const [contractStatuses, setContractStatuses] = useState(
CONTRACT_STATUS_OPTIONS[0].value,
);
const [bookingStatuses, setBookingStatuses] = useState(
BOOKING_STATUS_OPTIONS[0].value,
);
@@ -116,34 +66,12 @@ export default function ClearanceDocumentsPage() {
const page = pagination.pageIndex + 1;
const contractsQuery = useQuery({
queryKey: [
"clearance-documents",
"contracts",
contractStatuses,
page,
search,
],
const bookingsQuery = useQuery({
queryKey: ["clearance-documents", "bookings", bookingStatuses, page, search],
queryFn: () =>
contractsService.getOpsClearanceQueue({
page,
pageSize: PAGE_SIZE,
statuses: contractStatuses,
search,
}),
enabled: hubTab === "contracts",
placeholderData: keepPreviousData,
});
const generalQuery = useQuery({
queryKey: ["clearance-documents", "general", bookingStatuses, page, search],
queryFn: () =>
// Per-booking self-clearance instances are drawdowns under GENERAL
// non-customs contracts: they carry bookingType=ONE_TIME (each shipment
// is one-time) with contractKind=GENERAL, so filtering on
// bookingType=GENERAL_CONTRACT returned nothing. customsClearingEnabled
// =false + the three per-booking clearance statuses already isolate
// exactly this worklist — the same set the old booking-request tab showed.
// Self-clearance instances carry bookingType=ONE_TIME whatever their
// contract kind, so customsClearingEnabled=false + the three per-booking
// clearance statuses are what isolate exactly this worklist.
bookingsService.list({
statuses: bookingStatuses,
customsClearingEnabled: "false",
@@ -151,112 +79,10 @@ export default function ClearanceDocumentsPage() {
pageSize: PAGE_SIZE,
search,
}),
enabled: hubTab === "general",
placeholderData: keepPreviousData,
});
const contractRows = useMemo(
() => (contractsQuery.data?.items ?? []).map(toContractListRow),
[contractsQuery.data?.items],
);
const bookingRows = generalQuery.data?.items ?? [];
const contractColumns: ColumnDef<ContractListRow>[] = useMemo(
() => [
{
id: "contract",
header: () => <span className={bookingTable.headerCell}>Customer</span>,
cell: ({ row }) => {
const c = row.original;
return (
<div className="flex items-center gap-3 py-1.5">
<div className={bookingTable.rowIcon}>
<User className="size-4" strokeWidth={1.75} />
</div>
<div className="min-w-0">
<p className="truncate font-medium text-foreground">
{c.customerLabel}
</p>
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
<FileText className="size-3 shrink-0 opacity-70" />
{c.reference}
</p>
</div>
</div>
);
},
},
{
id: "route",
header: () => <span className={bookingTable.headerCell}>Route</span>,
cell: ({ row }) => {
const c = row.original;
return (
<div className="space-y-1 py-1">
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
<span className="max-w-[8rem] truncate">{c.originLabel}</span>
<ArrowRight className="size-3.5 shrink-0 text-muted-foreground" />
<span className="max-w-[8rem] truncate">
{c.destinationLabel}
</span>
</div>
<div className="flex gap-1.5">
<Badge
variant="outline"
className="h-5 border-border/50 bg-background/50 px-1.5 text-[10px] font-medium uppercase backdrop-blur-sm"
>
{directionLabel(c.tradeDirection)}
</Badge>
<Badge
variant="secondary"
className="h-5 bg-muted/40 px-1.5 text-[10px] font-medium"
>
{c.freightType}
</Badge>
</div>
</div>
);
},
},
{
id: "kind",
header: () => <span className={bookingTable.headerCell}>Kind</span>,
cell: ({ row }) => (
<Badge
variant="outline"
className="h-5 border-border/50 bg-background/50 px-1.5 text-[10px] font-medium uppercase"
>
{row.original.contractKind === "GENERAL" ? (
<span className="inline-flex items-center gap-1">
<Repeat className="size-3" /> General
</span>
) : (
"One-time"
)}
</Badge>
),
},
{
id: "status",
size: 200,
minSize: 180,
header: () => <span className={bookingTable.headerCell}>Status</span>,
cell: ({ row }) => (
<div className="py-1">
<ContractStatusBadge
status={row.original.status}
isRenewal={row.original.isRenewal}
/>
</div>
),
meta: {
headerClassName: "min-w-[11rem]",
cellClassName: "min-w-[11rem]",
},
},
],
[],
);
const bookingRows = bookingsQuery.data?.items ?? [];
const bookingColumns: ColumnDef<BookingDetail>[] = useMemo(
() => [
@@ -335,39 +161,29 @@ export default function ClearanceDocumentsPage() {
[],
);
const isContracts = hubTab === "contracts";
const activeQuery = isContracts ? contractsQuery : generalQuery;
const total = activeQuery.data?.total ?? 0;
const total = bookingsQuery.data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
const showEmpty =
!activeQuery.isLoading &&
!activeQuery.isError &&
(isContracts ? contractRows.length : bookingRows.length) === 0;
const tableStatus = activeQuery.isLoading
!bookingsQuery.isLoading && !bookingsQuery.isError && bookingRows.length === 0;
const tableStatus = bookingsQuery.isLoading
? "loading"
: activeQuery.isError
: bookingsQuery.isError
? "error"
: "success";
const statusOptions = isContracts
? CONTRACT_STATUS_OPTIONS
: BOOKING_STATUS_OPTIONS;
const statusValue = isContracts ? contractStatuses : bookingStatuses;
const setStatusValue = isContracts ? setContractStatuses : setBookingStatuses;
return (
<PageContainer>
<Stack gap="lg">
<PageHeader
title="Clearance Documents"
subtitle="Operations review of customer clearance documents for contracts without customs clearing."
subtitle="Operations review of the clearance documents customers upload on their shipments (services without customs clearing)."
action={
<ActionIcon
variant="default"
size="lg"
radius="md"
loading={activeQuery.isFetching}
onClick={() => void activeQuery.refetch()}
loading={bookingsQuery.isFetching}
onClick={() => void bookingsQuery.refetch()}
aria-label="Refresh"
>
<RefreshCw size={16} />
@@ -375,29 +191,12 @@ export default function ClearanceDocumentsPage() {
}
/>
<Tabs
value={hubTab}
onChange={(v) => {
setHubTab((v as HubTab) ?? "contracts");
resetPage();
}}
>
<Tabs.List>
<Tabs.Tab value="contracts">Contracts</Tabs.Tab>
<Tabs.Tab value="general">General</Tabs.Tab>
</Tabs.List>
</Tabs>
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<Group justify="space-between" gap="md" wrap="wrap">
<TextInput
placeholder={
isContracts
? "Search reference or customer…"
: "Search booking, contract or customer…"
}
placeholder="Search booking, contract or customer…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => {
@@ -424,10 +223,10 @@ export default function ClearanceDocumentsPage() {
radius="lg"
/>
<Select
data={statusOptions}
value={statusValue}
data={BOOKING_STATUS_OPTIONS}
value={bookingStatuses}
onChange={(v) => {
setStatusValue(v ?? statusOptions[0].value);
setBookingStatuses(v ?? BOOKING_STATUS_OPTIONS[0].value);
resetPage();
}}
allowDeselect={false}
@@ -446,61 +245,30 @@ export default function ClearanceDocumentsPage() {
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
<Inbox size={22} />
</ThemeIcon>
<Text c="dimmed">
No {isContracts ? "contracts" : "bookings"} match this view.
</Text>
<Text c="dimmed">No shipments match this view.</Text>
</Stack>
) : (
<Box style={{ overflowX: "auto" }} w="100%">
{isContracts ? (
<DataTable
columns={contractColumns}
data={contractRows}
status={tableStatus}
onRowClick={(row) =>
navigate(
`/dashboard/contracts/clearance-documents/${row.id}`,
)
}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>
) : (
<DataTable
columns={bookingColumns}
data={bookingRows}
status={tableStatus}
onRowClick={(row) =>
navigate(`/dashboard/clearance/${row.id}`)
}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>
)}
<DataTable
columns={bookingColumns}
data={bookingRows}
status={tableStatus}
onRowClick={(row) => navigate(`/dashboard/clearance/${row.id}`)}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>
</Box>
)}
</Stack>

View File

@@ -8,7 +8,6 @@ import {
Button,
Card,
Group,
SegmentedControl,
Select,
Stack,
Text,
@@ -21,14 +20,11 @@ import {
ArrowRight,
CalendarClock,
ChevronRight,
FileSignature,
FileText,
Inbox,
PackageCheck,
RefreshCw,
Search,
ShieldCheck,
Ship,
ShipWheel,
Truck,
User,
@@ -41,18 +37,14 @@ import {
usePagination,
type ColumnDef,
} from "@edr/ui-common";
import type { Freight } from "@edr/types";
import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
import { KpiStrip } from "@/components/page/KpiStrip";
import { bookingTable } from "@/components/bookings/booking-ui.styles";
import { useDjClearanceQueue } from "@/hooks/contracts/useContracts";
import { useBookingDjClearanceQueue } from "@/hooks/bookings/useBookings";
import type { BookingDetail } from "@/types/booking";
type QueueTab = "contracts" | "shipments";
const prettyStatus = (s?: string | null) =>
(s ?? "")
.toLowerCase()
@@ -198,52 +190,6 @@ function toShipmentRow(b: BookingDetail): ShipmentRow {
};
}
interface ContractRow {
id: string;
reference: string;
customerLabel: string;
originLabel: string;
destinationLabel: string;
tradeDirection: string;
freightType: string;
serviceTypeName: string;
customs: boolean;
status: string;
clearanceStatus: string;
phase: string | null;
cycleNumber: number;
validFrom: string | null;
validUntil: string | null;
validityDays: number | null;
estimatedShipmentDate: string | null;
}
function toContractRow(c: Freight.IContract): ContractRow {
const routes = [...(c.routes ?? [])].sort((a, b) => a.sortOrder - b.sortOrder);
const first = routes[0];
const last = routes[routes.length - 1] ?? first;
return {
id: c.id,
reference: c.reference,
customerLabel: c.isGovernment
? (c.governmentInstitution ?? "Government")
: (c.company?.name ?? "—"),
originLabel: yardLabel(first?.originYard),
destinationLabel: yardLabel(last?.destinationYard),
tradeDirection: c.tradeDirection ?? "—",
freightType: c.freightType ?? "—",
serviceTypeName: c.serviceType?.serviceName ?? "—",
customs: c.serviceType?.includesCustoms ?? Boolean(c.customsClearingEnabled),
status: c.status,
clearanceStatus: c.clearanceStatus,
phase: (c.clearancePhase as string | null) ?? null,
cycleNumber: c.clearanceCycleNumber ?? 1,
validFrom: c.contractValidFrom ?? null,
validUntil: c.contractValidUntil ?? null,
validityDays: c.contractValidityDays ?? null,
estimatedShipmentDate: c.estimatedShipmentDate ?? null,
};
}
// ── Shared cell pieces ───────────────────────────────────────────────────────
@@ -301,15 +247,13 @@ function RouteCell({
// ── Page ─────────────────────────────────────────────────────────────────────
/**
* GL Djibouti clearance queues:
* - Contracts: ONE_TIME customs contracts in phased clearance (legacy flow).
* - Shipments: GENERAL-contract bookings in per-booking clearance awaiting a DJ
* action (DO collection after ET finalizes pre-clearance, RO for exports,
* loading milestones). Managed like the one-time flow, but per booking.
* GL Djibouti clearance queue. Clearance runs per SHIPMENT — every booking in
* per-booking clearance awaiting a Djibouti action (DO collection after ET
* finalizes pre-clearance, RO for exports, loading milestones), whatever kind
* of contract it draws on.
*/
export default function GlDjiboutiClearanceListPage() {
const navigate = useNavigate();
const [tab, setTab] = useState<QueueTab>("shipments");
const [query, setQuery] = useState("");
const [direction, setDirection] = useState<string | null>(null);
const [freight, setFreight] = useState<string | null>(null);
@@ -317,13 +261,6 @@ export default function GlDjiboutiClearanceListPage() {
const [action, setAction] = useState<string | null>(null);
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const {
data: contractQueue,
isLoading: contractsLoading,
isError: contractsError,
isFetching: contractsFetching,
refetch: refetchContracts,
} = useDjClearanceQueue();
const {
data: bookingQueue,
isLoading: bookingsLoading,
@@ -341,34 +278,26 @@ export default function GlDjiboutiClearanceListPage() {
() => (bookingQueue ?? []).map(toShipmentRow),
[bookingQueue],
);
const allContractRows = useMemo(
() => (contractQueue?.items ?? []).map(toContractRow),
[contractQueue?.items],
);
// KPI metrics span both queues, regardless of active tab or filters.
// KPI metrics span the whole queue, regardless of filters.
const metrics = useMemo(
() => ({
shipments: allShipmentRows.length,
contracts: allContractRows.length,
collectDo: allShipmentRows.filter((r) => r.action.key === "COLLECT_DO")
.length,
issueRo: allShipmentRows.filter((r) => r.action.key === "ISSUE_RO").length,
roHolds: allShipmentRows.filter((r) => r.action.key === "RO_HOLD").length,
}),
[allShipmentRows, allContractRows],
[allShipmentRows],
);
const statusOptions = useMemo(() => {
const source =
tab === "shipments"
? allShipmentRows.map((r) => r.status)
: allContractRows.map((r) => r.status);
return [...new Set(source)].sort().map((s) => ({
value: s,
label: prettyStatus(s),
}));
}, [tab, allShipmentRows, allContractRows]);
const statusOptions = useMemo(
() =>
[...new Set(allShipmentRows.map((r) => r.status))].sort().map((s) => ({
value: s,
label: prettyStatus(s),
})),
[allShipmentRows],
);
const matchesShared = useCallback(
(
@@ -411,16 +340,10 @@ export default function GlDjiboutiClearanceListPage() {
[allShipmentRows, action, matchesShared],
);
const contractRows = useMemo(
() => allContractRows.filter((r) => matchesShared(r)),
[allContractRows, matchesShared],
);
const rows = tab === "shipments" ? shipmentRows : contractRows;
const isLoading = tab === "contracts" ? contractsLoading : bookingsLoading;
const isError = tab === "contracts" ? contractsError : bookingsError;
const isFetching = contractsFetching || bookingsFetching;
const total = rows.length;
const isLoading = bookingsLoading;
const isError = bookingsError;
const isFetching = bookingsFetching;
const total = shipmentRows.length;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const showEmpty = !isLoading && !isError && total === 0;
@@ -429,11 +352,6 @@ export default function GlDjiboutiClearanceListPage() {
return shipmentRows.slice(start, start + pagination.pageSize);
}, [shipmentRows, pagination.pageIndex, pagination.pageSize]);
const pagedContractRows = useMemo(() => {
const start = pagination.pageIndex * pagination.pageSize;
return contractRows.slice(start, start + pagination.pageSize);
}, [contractRows, pagination.pageIndex, pagination.pageSize]);
const hasFilters = Boolean(query || direction || freight || status || action);
const clearFilters = useCallback(() => {
@@ -446,9 +364,8 @@ export default function GlDjiboutiClearanceListPage() {
}, [resetPage]);
const handleRefresh = useCallback(() => {
void refetchContracts();
void refetchBookings();
}, [refetchContracts, refetchBookings]);
}, [refetchBookings]);
const openDetail = useCallback(
(id: string) => navigate(`/dashboard/gl-djibouti/clearance/${id}`),
@@ -599,161 +516,12 @@ export default function GlDjiboutiClearanceListPage() {
[],
);
const contractColumns: ColumnDef<ContractRow>[] = useMemo(
() => [
{
id: "contract",
header: () => <span className={bookingTable.headerCell}>Contract</span>,
cell: ({ row }) => {
const r = row.original;
return (
<div className="flex items-center gap-3 py-1.5">
<div className={bookingTable.rowIcon}>
<Ship className="size-4" strokeWidth={1.75} />
</div>
<div className="min-w-0">
<p className="truncate font-medium text-foreground">
{r.reference}
</p>
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
<User className="size-3 shrink-0 opacity-70" />
{r.customerLabel}
</p>
</div>
</div>
);
},
},
{
id: "route",
header: () => <span className={bookingTable.headerCell}>Route</span>,
cell: ({ row }) => {
const r = row.original;
return (
<RouteCell
origin={r.originLabel}
destination={r.destinationLabel}
direction={r.tradeDirection}
freightType={r.freightType}
/>
);
},
},
{
id: "service",
header: () => <span className={bookingTable.headerCell}>Service</span>,
cell: ({ row }) => {
const r = row.original;
return (
<Stack gap={4} py={2} style={{ minWidth: 0 }}>
<Text size="sm" fw={500} truncate maw={160}>
{r.serviceTypeName}
</Text>
{r.customs ? (
<Badge
size="xs"
variant="light"
color="edr-green"
radius="sm"
leftSection={<ShieldCheck size={11} />}
>
Customs
</Badge>
) : (
<Badge size="xs" variant="light" color="gray" radius="sm">
No customs
</Badge>
)}
</Stack>
);
},
},
{
id: "clearance",
header: () => <span className={bookingTable.headerCell}>Clearance</span>,
cell: ({ row }) => {
const r = row.original;
return (
<Stack gap={4} py={2}>
<Badge
size="sm"
variant="light"
color={statusColor(r.clearanceStatus)}
radius="sm"
>
{prettyStatus(r.clearanceStatus)}
</Badge>
<Text size="xs" c="dimmed">
{phaseLabel(r.phase)}
{r.cycleNumber > 1 ? ` · Cycle ${r.cycleNumber}` : ""}
</Text>
</Stack>
);
},
},
{
id: "status",
header: () => <span className={bookingTable.headerCell}>Status</span>,
cell: ({ row }) => (
<Badge
size="sm"
variant="light"
color={statusColor(row.original.status)}
radius="sm"
>
{prettyStatus(row.original.status)}
</Badge>
),
},
{
id: "validity",
header: () => <span className={bookingTable.headerCell}>Validity</span>,
cell: ({ row }) => {
const r = row.original;
return (
<Stack gap={2} py={2}>
<Group gap={6} wrap="nowrap">
<CalendarClock
size={13}
className="shrink-0 text-muted-foreground"
/>
<Text size="sm" c="dimmed">
{r.validUntil
? `Until ${formatDate(r.validUntil)}`
: r.validityDays
? `${r.validityDays} days`
: "—"}
</Text>
</Group>
{r.estimatedShipmentDate ? (
<Text size="xs" c="dimmed">
Est. shipment {formatDate(r.estimatedShipmentDate)}
</Text>
) : null}
</Stack>
);
},
},
{
id: "chevron",
size: 40,
header: "",
cell: () => (
<Group justify="flex-end" pr="xs">
<ChevronRight size={16} className="text-muted-foreground" />
</Group>
),
},
],
[],
);
return (
<PageContainer>
<Stack gap="lg">
<PageHeader
title="GL Djibouti — Clearance"
subtitle="Customs contracts and shipment bookings handed off to Djibouti GL."
subtitle="Customs shipments handed off to Djibouti GL — every clearance step lives on the shipment."
action={
<ActionIcon
variant="default"
@@ -769,7 +537,7 @@ export default function GlDjiboutiClearanceListPage() {
/>
<KpiStrip
loading={contractsLoading || bookingsLoading}
loading={bookingsLoading}
items={[
{
label: "Shipments in queue",
@@ -777,12 +545,6 @@ export default function GlDjiboutiClearanceListPage() {
icon: PackageCheck,
color: "blue",
},
{
label: "Contracts in queue",
value: metrics.contracts,
icon: FileSignature,
color: "edr-green",
},
{
label: "Imports — collect DO",
value: metrics.collectDo,
@@ -806,46 +568,6 @@ export default function GlDjiboutiClearanceListPage() {
<Card p={0} withBorder shadow="sm" radius="lg">
<Stack gap={0}>
<Box px="md" pt="md">
<SegmentedControl
size="sm"
value={tab}
onChange={(v) => {
setTab(v as QueueTab);
setStatus(null);
setAction(null);
resetPage();
}}
radius="md"
data={[
{
value: "shipments",
label: (
<Group gap={6} wrap="nowrap">
<PackageCheck size={15} />
<Box visibleFrom="sm">Shipments</Box>
<Badge size="sm" radius="sm" variant="light" color="edr-green">
{allShipmentRows.length}
</Badge>
</Group>
),
},
{
value: "contracts",
label: (
<Group gap={6} wrap="nowrap">
<FileSignature size={15} />
<Box visibleFrom="sm">Contracts</Box>
<Badge size="sm" radius="sm" variant="light" color="gray">
{allContractRows.length}
</Badge>
</Group>
),
},
]}
/>
</Box>
<Box px="md" pt="md" pb="sm">
<Group gap="sm" wrap="wrap">
<TextInput
@@ -917,20 +639,18 @@ export default function GlDjiboutiClearanceListPage() {
radius="lg"
w={190}
/>
{tab === "shipments" ? (
<Select
placeholder="DJ action"
data={DJ_ACTION_OPTIONS}
value={action}
onChange={(v) => {
setAction(v);
resetPage();
}}
clearable
radius="lg"
w={180}
/>
) : null}
<Select
placeholder="DJ action"
data={DJ_ACTION_OPTIONS}
value={action}
onChange={(v) => {
setAction(v);
resetPage();
}}
clearable
radius="lg"
w={180}
/>
{hasFilters ? (
<Button
variant="subtle"
@@ -957,9 +677,7 @@ export default function GlDjiboutiClearanceListPage() {
<Text c="dimmed">
{hasFilters
? "No records match these filters."
: tab === "contracts"
? "No Djibouti customs contracts yet."
: "No shipment bookings awaiting a Djibouti action."}
: "No shipments awaiting a Djibouti action."}
</Text>
{hasFilters ? (
<Button
@@ -975,49 +693,26 @@ export default function GlDjiboutiClearanceListPage() {
</Stack>
) : (
<Box style={{ overflowX: "auto" }} px="xs" pb="xs">
{tab === "shipments" ? (
<DataTable<ShipmentRow, unknown>
columns={shipmentColumns}
data={pagedShipmentRows}
status={isLoading ? "loading" : isError ? "error" : "success"}
onRowClick={(row) => openDetail(row.id)}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>
) : (
<DataTable<ContractRow, unknown>
columns={contractColumns}
data={pagedContractRows}
status={isLoading ? "loading" : isError ? "error" : "success"}
onRowClick={(row) => openDetail(row.id)}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>
)}
<DataTable<ShipmentRow, unknown>
columns={shipmentColumns}
data={pagedShipmentRows}
status={isLoading ? "loading" : isError ? "error" : "success"}
onRowClick={(row) => openDetail(row.id)}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>
</Box>
)}
</Stack>

View File

@@ -26,6 +26,7 @@ import {
Train as TrainIcon,
TrainFront,
Weight,
Wrench,
} from "lucide-react";
import { useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
@@ -48,6 +49,7 @@ import { api } from "@/services/api";
import { useAuth } from "@/auth/useAuth";
import { canFleetAction, FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { useToast } from "@/hooks/use-toast";
import type { TrainCompositionWagon } from "@/services/trainBuilder.service";
const parseError = (error: unknown, fallback: string) => {
if (isAxiosError(error)) {
@@ -78,6 +80,8 @@ export default function TrainBuilderDetailPage() {
const [yardModalOpen, setYardModalOpen] = useState(false);
const [disbandOpen, setDisbandOpen] = useState(false);
const [deactivateOpen, setDeactivateOpen] = useState(false);
const [maintenanceTarget, setMaintenanceTarget] =
useState<TrainCompositionWagon | null>(null);
const { user } = useAuth();
const canUpdate = canFleetAction(user, "trains", "update");
const canDelete = canFleetAction(user, "trains", "delete");
@@ -396,12 +400,7 @@ export default function TrainBuilderDetailPage() {
"Could not detach wagon",
)
}
onMaintenance={(wagonId) =>
void withToast(
() => maintenanceWagon.mutateAsync({ id: composition.id, wagonId }),
"Could not send wagon to maintenance",
)
}
onMaintenance={(wagon) => setMaintenanceTarget(wagon)}
/>
</Stack>
</Card>
@@ -460,6 +459,54 @@ export default function TrainBuilderDetailPage() {
onClose={() => setYardModalOpen(false)}
/>
<Modal
opened={Boolean(maintenanceTarget)}
onClose={() => setMaintenanceTarget(null)}
title={<Text fw={600}>Send wagon to maintenance?</Text>}
radius="lg"
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Wagon{" "}
<Text span fw={700} ff="monospace" c="dark">
{maintenanceTarget?.wagonNumber}
</Text>{" "}
is detached from train{" "}
<Text span fw={700} c="dark">
{composition.code}
</Text>{" "}
and set to MAINTENANCE it stays out of the available pool until it
clears. The detach is stamped with the time and this train number in
the wagon's history.
</Text>
<Group justify="flex-end">
<Button variant="default" onClick={() => setMaintenanceTarget(null)}>
Keep in consist
</Button>
<Button
color="orange"
leftSection={<Wrench size={16} />}
loading={maintenanceWagon.isPending}
onClick={() =>
void withToast(async () => {
await maintenanceWagon.mutateAsync({
id: composition.id,
wagonId: maintenanceTarget!.id,
});
toast({
title: `Wagon ${maintenanceTarget!.wagonNumber} sent to maintenance`,
});
setMaintenanceTarget(null);
}, "Could not send wagon to maintenance")
}
>
Send to maintenance
</Button>
</Group>
</Stack>
</Modal>
<Modal
opened={deactivateOpen}
onClose={() => setDeactivateOpen(false)}

View File

@@ -245,6 +245,14 @@ export const contractsService = {
reject: (id: string, reason: string) =>
postContract<Freight.IContract>(C.STAFF_REJECT(id), { reason }),
/** Freeze a signed contract. Reversible — see {@link resume}. */
suspend: (id: string, reason: string) =>
postContract<Freight.IContract>(C.SUSPEND(id), { reason }),
/** Lift a suspension; the contract returns to the status it was frozen at. */
resume: (id: string, note?: string) =>
postContract<Freight.IContract>(C.RESUME(id), { note }),
/**
* Approve the next pending step. The server resolves the step's required role
* and authorizes against it — the client never declares its own role.
@@ -377,22 +385,29 @@ export const contractsService = {
finalizeClearance: (id: string) =>
postContract<Freight.IContract>(C.CLEARANCE_FINALIZE(id)),
getEtClearanceQueue: async (): Promise<PaginatedContracts> => {
const response = await client.get<PaginatedContracts>(C.CLEARANCE_ET_QUEUE);
/**
* GL worklist: executed one-time customs contracts with no shipment instance
* yet. GL initiates the booking; the customer then uploads his clearance
* documents on it.
*/
getAwaitingShipmentContracts: async (): Promise<Freight.IContract[]> => {
const response = await client.get(C.AWAITING_SHIPMENT);
const data = unwrap(response.data);
return {
items: (data.items ?? []) as Freight.IContract[],
total: data.total ?? 0,
};
return (Array.isArray(data) ? data : (data?.items ?? [])) as Freight.IContract[];
},
getDjClearanceQueue: async (): Promise<PaginatedContracts> => {
const response = await client.get<PaginatedContracts>(C.CLEARANCE_DJ_QUEUE);
const data = unwrap(response.data);
return {
items: (data.items ?? []) as Freight.IContract[],
total: data.total ?? 0,
};
/** Open a bare shipment instance under a contract (no cargo, no day). */
initiateBookingUnderContract: async (
id: string,
contractRouteId?: string,
): Promise<{ id: string; reference: string }> => {
const result = await postContract<{
booking?: { id: string; reference: string };
id?: string;
reference?: string;
}>(C.BOOKINGS_INITIATE(id), contractRouteId ? { contractRouteId } : {});
const booking = result.booking ?? result;
return { id: booking.id ?? "", reference: booking.reference ?? "" };
},
uploadDeclaration: async (
@@ -575,25 +590,6 @@ export const contractsService = {
return unwrap(response.data) as { advised: boolean; skipped: boolean };
},
// ── Path A self-clearance (Operations review) ──
getOpsClearanceQueue: async (filter?: {
page?: number;
pageSize?: number;
search?: string;
/** Comma-separated ops-clearance lifecycle statuses; omitted → under-review queue. */
statuses?: string;
}): Promise<PaginatedContracts> => {
const response = await client.get<PaginatedContracts>(
C.OPS_CLEARANCE_QUEUE,
{ params: filter },
);
const data = unwrap(response.data);
return {
items: (data.items ?? []) as Freight.IContract[],
total: data.total ?? 0,
};
},
getClearanceHistory: async (): Promise<PaginatedContracts> => {
const response = await client.get<PaginatedContracts>(C.CLEARANCE_HISTORY);
const data = unwrap(response.data);