Merge pull request #982 from Tria-plc/freight_feature/usermanagement

Freight feature/usermanagement
This commit is contained in:
marshal
2026-07-28 08:03:48 +03:00
committed by GitHub
90 changed files with 3132 additions and 3773 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

@@ -155,9 +155,15 @@ const FleetResourcePage = () => {
const { data: cargoTypes = [], isLoading: cargoTypesLoading } = useQuery(
api.cargoTypes.list.queryOptions({ staleTime: Infinity }),
);
const { data: wagons = [], isLoading: wagonsLoading } = useQuery(
api.wagons.list.queryOptions({ input: {} }),
// Whole-fleet list for the "Wagon" form select — page-walked, so only fetch it
// where a form actually offers that select (containers), not on every slug.
const needsWagonOptions = Boolean(
config?.formFields.some((field) => field.dynamicOptions === "wagons"),
);
const { data: wagons = [], isLoading: wagonsLoading } = useQuery({
...api.wagons.list.queryOptions({ input: {} }),
enabled: needsWagonOptions,
});
const { data: containers = [], isLoading: containersLoading } = useQuery(
api.containers.list.queryOptions(),
);

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

@@ -1632,17 +1632,25 @@ export const api = {
},
wagons: {
/** Every match, page-walked — for pickers and yard views. Lists use `listPaged`. */
list: endpoint<{ filters?: WagonListFilters }, Wagon[]>(
"wagons",
"list",
({ filters }) => wagonService.getAll(filters ?? {}).then((r) => r.data),
({ filters }) => wagonService.listAll(filters ?? {}),
({ filters }) => ["wagons", "list", filters ?? {}],
),
listPaged: endpoint<{ filters?: WagonListFilters }, PaginatedResponse<Wagon>>(
"wagons",
"listPaged",
({ filters }) => wagonService.getAll(filters ?? {}).then((r) => r.data),
({ filters }) => ["wagons", "listPaged", filters ?? {}],
),
listByTrain: endpoint<{ trainId: string }, Wagon[]>(
"wagons",
"listByTrain",
({ trainId }) => wagonService.getByTrain(trainId).then((r) => r.data),
({ trainId }) => wagonService.getByTrain(trainId),
({ trainId }) => ["wagons", "train", trainId],
),

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);

View File

@@ -23,7 +23,7 @@ const listHandlers: Record<
> = {
locomotives: (filters) => locomotivesService.getAll(filters ?? {}).then((r) => r.data),
trains: () => trainService.getAll().then((r) => r.data),
wagons: (filters) => wagonService.getAll(filters ?? {}).then((r) => r.data),
wagons: (filters) => wagonService.listAll(filters ?? {}),
containers: () => containerService.getAll().then((r) => r.data),
cargoes: () => cargoService.getAll().then((r) => r.data),
vehicles: (filters) => vehiclesService.getAll(filters ?? {}).then((r) => r.data),
@@ -38,7 +38,7 @@ const pagedHandlers: Partial<
Record<FleetResourceSlug, (filters: FleetListFilters) => Promise<PaginatedResponse<FleetRecord>>>
> = {
locomotives: (filters) => locomotivesService.getPaged(filters).then((r) => r.data),
wagons: (filters) => wagonService.getPaged(filters).then((r) => r.data),
wagons: (filters) => wagonService.getAll(filters).then((r) => r.data),
};
export const isFleetServerPaginated = (slug: FleetResourceSlug): boolean =>

View File

@@ -95,15 +95,28 @@ export interface WagonMovementRecord {
}
export const wagonService = {
/** One page ({items, meta}); 10 rows unless `pageSize` says otherwise. */
getAll: (filters: WagonListFilters = {}) =>
apiClient.get<Wagon[]>(`/wagons${wagonListQuery(filters)}`),
/** Same filters as `getAll`, server-paginated ({items, meta}). */
getPaged: (filters: WagonListFilters = {}) =>
apiClient.get<PaginatedResponse<Wagon>>(`/wagons/paged${wagonListQuery(filters)}`),
apiClient.get<PaginatedResponse<Wagon>>(`/wagons${wagonListQuery(filters)}`),
/**
* Every matching wagon, page-walked at the API's 100-row cap. For the pickers
* and yard views that filter the whole fleet in the browser — a list page
* should use `getAll` and show the real page controls instead.
*/
listAll: async (filters: WagonListFilters = {}): Promise<Wagon[]> => {
const pageSize = 100;
const first = await wagonService.getAll({ ...filters, page: 1, pageSize });
const items = [...first.data.items];
for (let page = 2; page <= (first.data.meta.totalPages ?? 1); page += 1) {
const next = await wagonService.getAll({ ...filters, page, pageSize });
items.push(...next.data.items);
}
return items;
},
getById: (id: string) => apiClient.get<Wagon>(`/wagons/${id}`),
getMovements: (id: string) =>
apiClient.get<WagonMovementRecord[]>(`/wagons/${id}/movements`),
getByTrain: (trainId: string) => apiClient.get<Wagon[]>(`/wagons?trainId=${trainId}`),
getByTrain: (trainId: string) => wagonService.listAll({ trainId }),
assignToTrain: (wagonId: string, trainId: string, sequenceNumber?: number) =>
apiClient.post(`/wagons/${wagonId}/assign-train`, { trainId, sequenceNumber }),
unassign: (wagonId: string) => apiClient.post(`/wagons/${wagonId}/unassign-train`),

View File

@@ -46,7 +46,6 @@ import BookingContractPage from "./pages/bookings/BookingContractPage";
import BookingDetailPage from "./pages/bookings/BookingDetailPage";
import BookingsListPage from "./pages/bookings/BookingsListPage";
import EditBookingPage from "./pages/bookings/EditBookingPage";
import ContractClearanceFlow from "./pages/contracts/ContractClearanceFlow";
import ContractDetailPage from "./pages/contracts/ContractDetailPage";
import ContractViewPage from "./pages/contracts/ContractViewPage";
import ContractsList from "./pages/contracts/ContractsList";
@@ -336,10 +335,6 @@ const App = () => {
path="/contracts/:id/bookings/:bookingId/complete"
element={<NewShipmentPage />}
/>
<Route
path="/contracts/:id/clearance"
element={<ContractClearanceFlow />}
/>
<Route
path="/contracts/:id/view"
element={<ContractViewPage />}

View File

@@ -1,90 +0,0 @@
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { Button, Modal, Text, type ButtonProps } from "@mantine/core";
import { AlertCircle, Upload, type LucideIcon } from "lucide-react";
import { api } from "@/services/api";
import { ContractClearancePanel } from "@/pages/contracts/ContractClearancePanel";
import { ModalSafeWrapper } from "./ModalSafeWrapper";
import { useDisclosure } from "@mantine/hooks";
interface ContractClearanceActionProps {
contractId: string;
label?: string;
size?: ButtonProps["size"];
urgent?: boolean;
/** GL's turn — render as a calm status button, not a call to action. */
waiting?: boolean;
/** Icon override from the phase-aware action derivation. */
icon?: LucideIcon;
}
export function ContractClearanceAction({
contractId,
label: labelProp,
size = "xs",
urgent = false,
waiting = false,
icon: iconProp,
}: ContractClearanceActionProps) {
const [opened, { open, close }] = useDisclosure(false);
const { data: clearance } = useQuery({
...api.contracts.getClearance.queryOptions({ input: { id: contractId } }),
enabled: opened,
});
const label = useMemo(() => {
if (labelProp) return labelProp;
const docs = clearance?.documents ?? [];
const queried = docs.filter(
(d) => d.uploadedBy === "customer" && d.reviewStatus === "QUERIED",
).length;
if (queried > 0) return "Update clearance";
return urgent ? "Upload clearance" : "Manage clearance";
}, [labelProp, clearance, urgent]);
const Icon =
iconProp ?? (urgent || label.includes("Update") ? AlertCircle : Upload);
// Urgent (customer's turn) = filled orange so it stands out among the green
// actions; waiting (GL's turn) = calm subtle gray; default = brand green.
const color = urgent ? "orange" : waiting ? "gray" : "edr-green";
const variant = waiting ? "light" : "filled";
return (
<ModalSafeWrapper>
<Button
size={size}
radius="md"
fw={700}
fz={13}
color={color}
variant={variant}
leftSection={<Icon size={14} />}
onClick={(e) => {
e.stopPropagation();
open();
}}
>
{label}
</Button>
<Modal
opened={opened}
onClose={close}
title={
<Text fw={700} fz={16}>
Clearance documents
</Text>
}
size="xl"
radius="md"
centered
overlayProps={{ blur: 2, backgroundOpacity: 0.55 }}
>
<ContractClearancePanel contractId={contractId} bare onSubmitted={close} />
</Modal>
</ModalSafeWrapper>
);
}

View File

@@ -16,7 +16,6 @@ import type { Freight } from "@edr/types";
import { PayNowButton } from "@/pages/bookings/payments/PayNowButton";
import { api } from "@/services/api";
import { ContractClearanceAction } from "./ContractClearanceAction";
import { deriveContractCustomerAction } from "./deriveContractCustomerAction";
interface ContractCustomerActionProps {
@@ -50,19 +49,6 @@ export function ContractCustomerAction({
}
: undefined;
if (action.type === "clearance") {
return (
<ContractClearanceAction
contractId={action.contractId}
label={action.label}
size={size}
urgent={action.urgent}
waiting={action.waiting}
icon={action.icon}
/>
);
}
if (action.type === "pay") {
return (
<PayNowButton booking={action.booking} label={action.label} size={size} />
@@ -106,9 +92,9 @@ export function ContractCustomerAction({
}
/**
* One-click bare booking instance under a GENERAL non-customs contract. No
* form, no date, no window gate the new instance lands in per-booking
* clearance (AWAITING_DOCUMENTS) and the customer is taken straight to it.
* One-click bare booking instance under a self-clearance import/export contract
* — ONE_TIME or GENERAL. No form, no date, no window gate: the new instance
* lands in per-booking clearance (AWAITING_DOCUMENTS) with the customer on it.
*/
export function InitiateBookingButton({
contract,

View File

@@ -4,43 +4,13 @@ import {
CreditCard,
Eye,
FileSignature,
Hourglass,
PackagePlus,
PencilLine,
Receipt,
RotateCcw,
Upload,
} from "lucide-react";
import { getContractBookingAction } from "@/pages/contracts/contract-booking-action";
const CLEARANCE_IN_PROGRESS_STATUSES = [
"AWAITING_CLEARANCE_DOCUMENTS",
"CLEARANCE_UNDER_REVIEW",
];
export function contractNeedsClearanceAction(c: Freight.IContract): {
show: boolean;
urgent: boolean;
} {
const clearance = (c as { clearanceStatus?: string }).clearanceStatus;
const ready =
clearance === "CLEARANCE_READY_FOR_BOOKING" ||
clearance === "SELF_CLEARED" ||
clearance === "ACTIVE_SHIPMENT_IN_PROGRESS";
if (ready) return { show: false, urgent: false };
const awaiting =
c.status === "AWAITING_CLEARANCE_DOCUMENTS" ||
clearance === "AWAITING_DOCUMENTS";
const inProgress =
CLEARANCE_IN_PROGRESS_STATUSES.includes(c.status) ||
clearance === "AWAITING_DOCUMENTS" ||
clearance === "DOCUMENTS_UNDER_REVIEW";
return { show: inProgress, urgent: awaiting };
}
export type ContractCustomerAction =
| {
type: "sign";
@@ -56,16 +26,6 @@ export type ContractCustomerAction =
primary: boolean;
icon: LucideIcon;
}
| {
type: "clearance";
contractId: string;
label: string;
primary: boolean;
icon: LucideIcon;
urgent: boolean;
/** True when it's GL's turn — render calm/informational, not a call to action. */
waiting?: boolean;
}
| {
type: "pay";
booking: Freight.IBooking;
@@ -148,60 +108,6 @@ export function deriveContractCustomerAction(
};
}
const clr = contractNeedsClearanceAction(contract);
if (clr.show) {
// Refine the generic clearance action by the persisted clearance phase so
// the button says what the customer actually has to do right now (e.g.
// "Pay duty & upload slip" during CUSTOMER_DUTY, not "Update clearance").
const phase = contract.clearancePhase ?? null;
switch (phase) {
case "CUSTOMER_INTAKE":
return {
type: "clearance",
contractId: id,
label: "Upload clearance documents",
primary: true,
icon: Upload,
urgent: true,
};
case "CUSTOMER_DUTY":
return {
type: "clearance",
contractId: id,
label: "Pay duty & upload slip",
primary: true,
icon: Receipt,
urgent: true,
};
case "GL_ET_REVIEW":
case "GL_DJ_COLLECTION":
case "GL_ET_OUTPUT":
case "GL_ET_POST_CLEARANCE":
case "GL_DJ_LOADING":
case "POST_TRANSIT":
// GL's turn — nothing for the customer to do; show a calm status.
return {
type: "clearance",
contractId: id,
label: "Clearance in progress",
primary: false,
icon: Hourglass,
urgent: false,
waiting: true,
};
default:
// No persisted phase (legacy / early cycles) — keep the status-derived label.
return {
type: "clearance",
contractId: id,
label: clr.urgent ? "Upload clearance" : "Update clearance",
primary: true,
icon: Upload,
urgent: clr.urgent,
};
}
}
if (
contract.customsClearingEnabled &&
["CLEARANCE_READY_FOR_BOOKING", "ACTIVE_SHIPMENT_IN_PROGRESS"].includes(

View File

@@ -149,6 +149,7 @@ export const URL_CONSTANTS = {
CONTRACT_SEND_SIGNING_OTP: (id: string) =>
`/api/contracts/${id}/contract/send-signing-otp`,
RENEW: (id: string) => `/api/contracts/${id}/renew`,
CANCEL: (id: string) => `/api/contracts/${id}/cancel`,
CLEARANCE: (id: string) => `/api/contracts/${id}/clearance`,
CLEARANCE_DOCUMENTS: (id: string) =>
`/api/contracts/${id}/clearance/documents`,

View File

@@ -1,27 +1,24 @@
import type { Freight } from "@edr/types";
import { contractNeedsClearanceAction } from "@/components/customer-actions/deriveContractCustomerAction";
/** A pending customer action surfaced on the home "needs attention" card. */
export interface ActionItem {
id: string;
/** What the customer must do — drives the icon, label and modal. */
kind: "clearance" | "duty" | "sign" | "book" | "pay";
kind: "sign" | "book" | "pay";
/** The contract/booking reference for display. */
reference: string;
/** Short human description of the action. */
description: string;
/** Contract id (clearance/sign/book) or booking id (pay). */
/** Contract id (sign/book) or booking id (pay). */
targetId: string;
/** True for queried clearance (a document was sent back for correction). */
/** Highlighted as action-required in the home card. */
urgent?: boolean;
}
/**
* Derive the list of pending customer actions from the customer's contracts and
* bookings. A contract in AWAITING_CLEARANCE_DOCUMENTS (initial upload or a
* re-upload after a query) is flagged urgent so the home card shows an upload
* button. See {@link contractNeedsClearanceAction}.
* bookings. Clearance never appears here any more: documents live on the
* shipment booking, so a pending upload surfaces as a booking action.
*/
export function deriveActionItems(
contracts: Freight.IContract[],
@@ -40,20 +37,6 @@ export function deriveActionItems(
});
continue;
}
const clr = contractNeedsClearanceAction(c);
if (clr.show) {
items.push({
id: `clearance-${c.id}`,
kind: "clearance",
reference: c.reference,
description: clr.urgent
? "Clearance documents need your action"
: "Clearance under review",
targetId: c.id,
urgent: clr.urgent,
});
continue;
}
// Path A transport-only: customer may create the shipment booking.
if (
!c.customsClearingEnabled &&

View File

@@ -1,140 +1,53 @@
import { useMemo, useState } from "react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useMutation, useQueries, useQuery } from "@tanstack/react-query";
import { Badge, Box, Button, Group, Modal, Stack, Text } from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Badge, Box, Button, Group, Stack, Text } from "@mantine/core";
import {
AlertTriangle,
CreditCard,
FilePlus2,
FileSignature,
PackagePlus,
Receipt,
Upload,
} from "lucide-react";
import type { Freight } from "@edr/types";
import { api } from "@/services/api";
import { ModalSafeWrapper } from "@/components/customer-actions/ModalSafeWrapper";
import { paymentsService, type PaymentMethod } from "@/services/payments.service";
import { invoicesService } from "@/services/invoices.service";
import { isPayable } from "@/pages/billing/invoice-ui";
import { ContractClearancePanel } from "@/pages/contracts/ContractClearancePanel";
import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/components/PaymentMethodModal";
import { Card } from "./Card";
import type { ActionItem } from "../actions";
// Contracts whose clearance is still in progress — candidates for a real
// per-document query check (small set; only contracts awaiting/under review).
const CLEARANCE_CANDIDATE_STATUSES = [
"AWAITING_CLEARANCE_DOCUMENTS",
"CLEARANCE_UNDER_REVIEW",
];
const KIND_META: Record<
ActionItem["kind"],
{ icon: typeof Upload; label: string; color: string }
{ icon: typeof CreditCard; label: string; color: string }
> = {
clearance: { icon: Upload, label: "Clearance", color: "edr-green" },
duty: { icon: Receipt, label: "Duty / tax", color: "orange" },
sign: { icon: FileSignature, label: "Sign", color: "blue" },
book: { icon: PackagePlus, label: "Book", color: "violet" },
pay: { icon: CreditCard, label: "Payment", color: "orange" },
};
export interface ActionNeededSectionProps {
/** Non-clearance actions (sign / book / pay) derived from status. */
/** Pending actions (sign / book / pay) derived from status. */
items: ActionItem[];
/** All of the customer's contracts — used to detect real clearance queries. */
contracts: Freight.IContract[];
}
/**
* Home "needs your attention" card. Lists pending customer actions across
* contracts and bookings. Clearance items are derived from the ACTUAL clearance
* documents (so an open query always surfaces an upload button), payment + the
* clearance upload open in a modal right here; sign and book navigate.
* contracts and bookings: payment opens a modal right here; sign and book
* navigate. Clearance is a per-booking action and lives on the booking page.
*/
export function ActionNeededSection({
items: baseItems,
contracts,
}: ActionNeededSectionProps) {
export function ActionNeededSection({ items: base }: ActionNeededSectionProps) {
const navigate = useNavigate();
const [clearanceId, setClearanceId] = useState<string | null>(null);
const [payItem, setPayItem] = useState<ActionItem | null>(null);
// Fetch the clearance view for every contract still in a clearance phase, so we
// can detect a queried document precisely (status alone can be ambiguous).
const candidates = useMemo(
() =>
contracts.filter((c) =>
CLEARANCE_CANDIDATE_STATUSES.includes(c.status),
),
[contracts],
// Urgent first; clearance never appears here — those documents live on the
// shipment booking, and its own detail page drives that upload.
const items = [...base].sort(
(a, b) => Number(b.urgent ?? 0) - Number(a.urgent ?? 0),
);
const clearanceQueries = useQueries({
queries: candidates.map((c) =>
api.contracts.getClearance.queryOptions({ input: { id: c.id } }),
),
});
// Build clearance action items from the fetched views: show whenever the
// customer can still upload (not yet ready for booking), flag urgent + show the
// query count when any document was sent back for correction.
const clearanceItems = useMemo<ActionItem[]>(() => {
const out: ActionItem[] = [];
candidates.forEach((c, i) => {
const view = clearanceQueries[i]?.data;
const docs = view?.documents ?? [];
const customerDocs = docs.filter((d) => d.uploadedBy === "customer");
const queried = customerDocs.filter(
(d) => d.reviewStatus === "QUERIED",
).length;
const ready =
view?.clearanceStatus === "CLEARANCE_READY_FOR_BOOKING" ||
view?.clearanceStatus === "SELF_CLEARED" ||
view?.clearanceStatus === "ACTIVE_SHIPMENT_IN_PROGRESS";
if (ready) return;
const awaiting =
c.status === "AWAITING_CLEARANCE_DOCUMENTS" ||
view?.clearanceStatus === "AWAITING_DOCUMENTS";
// Duty phase: the customer's task is paying duty/tax and uploading the
// slip — a distinct, money action, not a generic document upload.
if (view?.phase === "CUSTOMER_DUTY") {
out.push({
id: `duty-${c.id}`,
kind: "duty",
reference: c.reference,
description: "Duty / tax payment due — pay and upload the slip",
targetId: c.id,
urgent: true,
});
return;
}
// Only surface when there's something the customer can do: a query, or the
// contract is awaiting their (re)upload.
if (queried === 0 && !awaiting) return;
out.push({
id: `clearance-${c.id}`,
kind: "clearance",
reference: c.reference,
description:
queried > 0
? `${queried} document${queried > 1 ? "s" : ""} need correction`
: "Clearance documents needed",
targetId: c.id,
urgent: queried > 0 || awaiting,
});
});
return out;
}, [candidates, clearanceQueries]);
// Merge: clearance items (from real docs) + the status-derived sign/book/pay.
const items = useMemo(
() => [...clearanceItems, ...baseItems.filter((i) => i.kind !== "clearance")],
[clearanceItems, baseItems],
).sort((a, b) => Number(b.urgent ?? 0) - Number(a.urgent ?? 0));
// Billing is invoice-centric — resolve the booking's currently payable
// invoice before paying it (mirrors ReadonlyBookingView).
const { data: payItemInvoices = [] } = useQuery({
@@ -175,13 +88,6 @@ export function ActionNeededSection({
const handleClick = (item: ActionItem) => {
switch (item.kind) {
case "clearance":
setClearanceId(item.targetId);
break;
case "duty":
// Duty advice + payment-slip upload live on the contract detail page.
navigate(`/contracts/${item.targetId}`);
break;
case "pay":
setPayItem(item);
break;
@@ -266,27 +172,13 @@ export function ActionNeededSection({
variant={item.urgent ? "filled" : "light"}
color={meta.color}
radius="md"
leftSection={
item.kind === "clearance" ? (
<Upload size={14} />
) : item.kind === "duty" ? (
<Receipt size={14} />
) : (
<FilePlus2 size={14} />
)
}
leftSection={<FilePlus2 size={14} />}
>
{item.kind === "pay"
? "Pay now"
: item.kind === "duty"
? "Pay duty & upload slip"
: item.kind === "sign"
? "Sign"
: item.kind === "book"
? "Book"
: item.urgent
? "Upload documents"
: "Upload"}
: item.kind === "sign"
? "Sign"
: "Book"}
</Button>
</Group>
);
@@ -294,24 +186,6 @@ export function ActionNeededSection({
</Stack>
<ModalSafeWrapper>
<Modal
opened={clearanceId !== null}
onClose={() => setClearanceId(null)}
title={
<Text fw={700} fz={16}>
Clearance documents
</Text>
}
size="xl"
radius="md"
centered
overlayProps={{ blur: 2, backgroundOpacity: 0.55 }}
>
{clearanceId && (
<ContractClearancePanel contractId={clearanceId} bare />
)}
</Modal>
<PaymentMethodModal
opened={payItem !== null}
onClose={() => {

View File

@@ -521,9 +521,9 @@ export const STATUS_CONFIG: Record<string, StageConfig> = {
icon: CheckCircle2,
iconColor: "edr-slate",
tile: "edr-slate-soft2",
hint: "Contract closed · quantity used or window elapsed",
hint: "Contract completed · shipment delivered, quantity used or window elapsed",
step: "edr-step",
badgeLabel: "Closed",
badgeLabel: "Completed",
badgeBg: "edr-slate-soft2",
badgeText: "edr-slate",
badgeDot: "edr-step",

View File

@@ -1,29 +1,71 @@
import { useState } from "react";
import { Alert, Anchor, Button, FileInput, Group, Paper, Stack, Text } from "@mantine/core";
import { AlertTriangle, Download, Receipt, Upload } from "lucide-react";
import {
Alert,
Anchor,
Badge,
Box,
Button,
FileInput,
Group,
Paper,
Stack,
Text,
} from "@mantine/core";
import {
AlertTriangle,
Download,
Eye,
FileBadge,
Receipt,
Upload,
} from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import toast from "react-hot-toast";
import type { Freight } from "@edr/types";
import { bookingsService } from "@/services/bookings.service";
import { contractsService } from "@/services/contracts.service";
import { downloadStoredFile } from "@/services/files.service";
import { ClearancePhaseStepper } from "../contracts/ClearancePhaseStepper";
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
import { useFileViewer } from "@/hooks/useFileViewer";
import { GREEN, INK } from "../contracts/contract-ui";
const BORDER = "#E6ECF2";
// Customer-facing labels for an invoice status (Freight.InvoiceStatus).
const INVOICE_STATUS_LABELS: Record<string, string> = {
DRAFT: "Draft",
ISSUED: "Issued",
PENDING: "Due",
PARTIALLY_PAID: "Partially paid",
PAID: "Paid",
OVERDUE: "Overdue",
CANCELLED: "Cancelled",
REFUNDED: "Refunded",
EXPIRED: "Expired",
};
function invoiceStatusLabel(status: string): string {
return (
INVOICE_STATUS_LABELS[status] ??
status
.replace(/_/g, " ")
.toLowerCase()
.replace(/\b\w/g, (m) => m.toUpperCase())
);
}
export function BookingClearanceWorkflowBanner({
booking,
}: {
booking: Freight.IBooking;
}) {
// Drawdown bookings under a GENERAL contract keep bookingType = ONE_TIME —
// the denormalized contractKind is what marks them as phased (Path B).
const isPhased =
booking.customsClearingEnabled &&
(booking.bookingType === "GENERAL_CONTRACT" ||
booking.contractKind === "GENERAL");
// Every contract booking whose service bundles customs runs the phased GL
// workflow now — ONE_TIME and GENERAL alike.
const isPhased = Boolean(booking.customsClearingEnabled && booking.contractId);
const { view, viewer } = useFileViewer();
@@ -75,6 +117,45 @@ export function BookingClearanceWorkflowBanner({
/>
) : null}
{clearance.riskLevel ? (
<Group gap={10} align="center">
<Text fw={700} fz={14} c={INK}>
Customs risk level
</Text>
<Badge
color={CUSTOMS_RISK_COLOR[clearance.riskLevel] ?? "gray"}
variant="filled"
radius="sm"
>
{clearance.riskLevel.charAt(0) +
clearance.riskLevel.slice(1).toLowerCase()}
</Badge>
{clearance.riskAssignedAt ? (
<Text fz={12} c="dimmed">
assigned {new Date(clearance.riskAssignedAt).toLocaleString()}
</Text>
) : null}
</Group>
) : null}
{clearance.secondDuty?.advised ? (
<SecondDutyDueCard
duty={clearance.secondDuty}
bookingId={booking.id}
onView={(f) => view(f)}
onChanged={() => void refetch()}
/>
) : null}
{clearance.finalInvoice ? (
<FinalInvoiceDueCard
invoice={clearance.finalInvoice}
bookingId={booking.id}
onView={(f) => view(f)}
onChanged={() => void refetch()}
/>
) : null}
{clearance.operationReady ? (
<Alert color="green" variant="light">
Clearance is complete. You may proceed to request your operation date.
@@ -173,3 +254,295 @@ function GroupLabel({ icon: Icon, text }: { icon: typeof Receipt; text: string }
</div>
);
}
/**
* Post-offload final invoice from GL Djibouti (export): shows the due amount +
* invoice document; the customer pays offline and attaches the payment slip
* here, then GL confirms and the badge flips to PAID.
*/
function FinalInvoiceDueCard({
invoice,
bookingId,
onView,
onChanged,
}: {
invoice: NonNullable<Freight.ClearanceView["finalInvoice"]>;
bookingId: string;
onView: (file: { name: string; url: string }) => void;
onChanged: () => void;
}) {
const [slip, setSlip] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const paid = invoice.status === "PAID";
return (
<Paper
withBorder
radius="lg"
p="lg"
style={{
borderColor: paid ? "#CDEBDD" : "#F2D9A6",
background: paid ? "#F6FBF8" : "#FFFBF2",
position: "relative",
overflow: "hidden",
}}
>
<Box
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: 3,
background: paid ? GREEN : "#E3A93C",
}}
/>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<div>
<Group gap={8} align="center">
<FileBadge size={16} color={paid ? GREEN : "#B07C1F"} />
<Text fw={700} fz={15} c={INK}>
{paid ? "Final invoice paid" : "Final invoice due"} {" "}
{invoice.invoiceNumber}
</Text>
<Badge color={paid ? "edr-green" : "yellow"} variant="light" radius="sm">
{invoiceStatusLabel(invoice.status)}
</Badge>
</Group>
<Text fz={20} fw={800} mt={6} c={INK}>
{invoice.totalAmount.toLocaleString()} {invoice.currency}
</Text>
{invoice.description ? (
<Text fz={13} c="dimmed" mt={2}>
{invoice.description}
</Text>
) : null}
{!paid ? (
<Text fz={13} c="#9A6B1F" mt={6}>
Pay the amount above and attach your payment slip Global
Logistics will confirm the payment.
</Text>
) : null}
</div>
<Stack gap="xs" miw={260}>
{invoice.invoiceFile ? (
<Button
variant="default"
radius="md"
size="sm"
leftSection={<Eye size={15} />}
onClick={() =>
onView({
name: invoice.invoiceFile!.name,
url: invoice.invoiceFile!.url,
})
}
>
View invoice
</Button>
) : null}
{invoice.slipFile ? (
<Button
variant="default"
radius="md"
size="sm"
leftSection={<Eye size={15} />}
onClick={() =>
onView({
name: invoice.slipFile!.name,
url: invoice.slipFile!.url,
})
}
>
View payment slip
</Button>
) : null}
{!paid ? (
<>
<FileInput
placeholder={
invoice.slipFile ? "Replace payment slip" : "Attach payment slip"
}
value={slip}
onChange={setSlip}
size="sm"
radius="md"
/>
<Button
color="edr-green"
radius="md"
size="sm"
loading={uploading}
disabled={!slip}
leftSection={<Upload size={15} />}
onClick={async () => {
if (!slip) return;
setUploading(true);
try {
await contractsService.uploadFinalInvoiceSlip(bookingId, slip);
setSlip(null);
toast.success("Payment slip attached");
onChanged();
} catch (e) {
toast.error(
e instanceof Error ? e.message : "Upload failed",
);
} finally {
setUploading(false);
}
}}
>
{invoice.slipFile ? "Replace slip" : "Submit payment slip"}
</Button>
</>
) : null}
</Stack>
</Group>
</Paper>
);
}
const CUSTOMS_RISK_COLOR: Record<string, string> = {
GREEN: "green",
YELLOW: "yellow",
RED: "red",
};
/**
* Post-arrival additional duty/tax round (import): GL advises an extra amount
* with a notice; the customer pays offline and attaches another slip here.
*/
function SecondDutyDueCard({
duty,
bookingId,
onView,
onChanged,
}: {
duty: NonNullable<Freight.ClearanceView["secondDuty"]>;
bookingId: string;
onView: (file: { name: string; url: string }) => void;
onChanged: () => void;
}) {
const [slip, setSlip] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const paid = duty.paid;
return (
<Paper
withBorder
radius="lg"
p="lg"
style={{
borderColor: paid ? "#CDEBDD" : "#F2D9A6",
background: paid ? "#F6FBF8" : "#FFFBF2",
position: "relative",
overflow: "hidden",
}}
>
<Box
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: 3,
background: paid ? GREEN : "#E3A93C",
}}
/>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<div>
<Group gap={8} align="center">
<FileBadge size={16} color={paid ? GREEN : "#B07C1F"} />
<Text fw={700} fz={15} c={INK}>
{paid ? "Additional duty & tax paid" : "Additional duty & tax due"}
</Text>
<Badge color={paid ? "edr-green" : "yellow"} variant="light" radius="sm">
{paid ? "PAID" : "DUE"}
</Badge>
</Group>
<Text fz={20} fw={800} mt={6} c={INK}>
{(duty.amount ?? 0).toLocaleString()} {duty.currency ?? ""}
</Text>
{duty.declarationSerial ? (
<Text fz={13} c="dimmed" mt={2}>
Payment code: {duty.declarationSerial}
</Text>
) : null}
{!paid ? (
<Text fz={13} c="#9A6B1F" mt={6}>
Customs advised additional duty/tax after arrival. Pay the amount
above and attach your payment slip.
</Text>
) : null}
</div>
<Stack gap="xs" miw={260}>
{duty.noticeFile ? (
<Button
variant="default"
radius="md"
size="sm"
leftSection={<Eye size={15} />}
onClick={() =>
onView({ name: duty.noticeFile!.name, url: duty.noticeFile!.url })
}
>
View duty notice
</Button>
) : null}
{duty.slipFile ? (
<Button
variant="default"
radius="md"
size="sm"
leftSection={<Eye size={15} />}
onClick={() =>
onView({ name: duty.slipFile!.name, url: duty.slipFile!.url })
}
>
View payment slip
</Button>
) : null}
{!paid ? (
<>
<FileInput
placeholder={duty.slipFile ? "Replace payment slip" : "Attach payment slip"}
value={slip}
onChange={setSlip}
size="sm"
radius="md"
/>
<Button
color="edr-green"
radius="md"
size="sm"
loading={uploading}
disabled={!slip}
leftSection={<Upload size={15} />}
onClick={async () => {
if (!slip) return;
setUploading(true);
try {
await contractsService.uploadSecondDutySlip(bookingId, slip);
setSlip(null);
toast.success("Payment slip attached");
onChanged();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Upload failed");
} finally {
setUploading(false);
}
}}
>
{duty.slipFile ? "Replace slip" : "Submit payment slip"}
</Button>
</>
) : null}
</Stack>
</Group>
</Paper>
);
}

View File

@@ -14,6 +14,7 @@ import type { Freight } from "@edr/types";
import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton";
import { ActivityCard } from "./components/ActivityCard";
import { ClearanceCard } from "./components/ClearanceCard";
import { BookingClearanceWorkflowBanner } from "@/pages/bookings/BookingClearanceWorkflowBanner";
import { DocumentsTab } from "./components/DocumentsTab";
import { CompanyInfoCard } from "./components/CompanyInfoCard";
import { ContainersCard } from "./components/ContainersCard";
@@ -242,6 +243,10 @@ export function ReadonlyBookingView({
{isClearance && <ClearanceCard booking={booking} />}
{/* Customs (Path B) shipments: GL's phased progress, the duty /
additional-duty payments and the final invoice — all per booking. */}
<BookingClearanceWorkflowBanner booking={booking} />
<BodyGrid
left={
<>

View File

@@ -1,81 +0,0 @@
import { useNavigate, useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
Box,
Button,
Group,
Stack,
Text,
ThemeIcon,
Title,
} from "@mantine/core";
import { ArrowLeft, Upload } from "lucide-react";
import { api } from "@/services/api";
import { ContractStatusBadge, INK } from "./contract-ui";
import { ContractClearancePanel } from "./ContractClearancePanel";
/**
* Customer clearance upload page for a CONTRACT (doc §8.3). The document grid +
* upload logic live in {@link ContractClearancePanel} so the same workspace can
* render here (full page) or inside the contract action modal.
*/
export default function ContractClearanceFlow() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: contract } = useQuery(
api.contracts.get.queryOptions({ input: { id: id! }, enabled: !!id }),
);
const { data: clearance } = useQuery(
api.contracts.getClearance.queryOptions({
input: { id: id! },
enabled: !!id,
}),
);
return (
<Box style={{ padding: "28px 32px 40px" }}>
<Stack gap="lg">
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Group gap="md" align="center" wrap="nowrap">
<Button
variant="subtle"
color="gray"
radius="md"
px={8}
onClick={() => navigate(`/contracts/${id}`)}
>
<ArrowLeft size={18} />
</Button>
<Group gap={12} wrap="nowrap" align="center">
<ThemeIcon size={48} radius="lg" variant="light" color="edr-green">
<Upload size={23} />
</ThemeIcon>
<div>
<Group gap={10} align="center">
<Title order={2} fw={800} fz={22} style={{ color: INK }}>
Clearance documents
</Title>
{contract && <ContractStatusBadge status={contract.status} />}
</Group>
<Text size="sm" c="dimmed" mt={2}>
{contract?.reference ?? ""} · Cycle{" "}
{clearance?.cycleNumber ?? 1}
</Text>
</div>
</Group>
</Group>
</Group>
{id && (
<ContractClearancePanel
contractId={id}
tradeDirection={contract?.tradeDirection ?? "IMPORT"}
/>
)}
</Stack>
</Box>
);
}

View File

@@ -1,355 +0,0 @@
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Alert,
Box,
Button,
Center,
Group,
Loader,
Paper,
Stack,
Text,
} from "@mantine/core";
import {
AlertCircle,
CheckCircle2,
Clock,
Download,
Eye,
Upload,
} from "lucide-react";
import { isViewable } from "@edr/ui-common";
import { api } from "@/services/api";
import { fetchViewableFile, downloadStoredFile } from "@/services/files.service";
import {
ClearanceAdHocUploadSection,
type AdHocDoc,
} from "@/components/contracts/ClearanceAdHocUploadSection";
import { ClearanceDocumentUploadCard } from "@/components/contracts/ClearanceDocumentUploadCard";
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
import { useFileViewer } from "@/hooks/useFileViewer";
import { IconSquare } from "@/pages/bookings/BookingDetailPage/components/Documents";
const BORDER = "#E6ECF2";
export interface ContractClearancePanelProps {
contractId: string;
tradeDirection?: string;
/** Show the loading state without the surrounding Paper (e.g. inside a modal). */
bare?: boolean;
/** Called after a successful document submission (e.g. to close the host modal). */
onSubmitted?: () => void;
}
/**
* The clearance document workspace for a contract: shows the customer-input doc
* grid + GL output docs, lets the customer upload / re-upload (only queried docs
* after first submission), and surfaces query notes. Rendered both on the
* standalone clearance page and inside the contract action modal. See
* docs/new-doc.md §8.3.
*/
export function ContractClearancePanel({
contractId,
tradeDirection = "IMPORT",
bare,
onSubmitted,
}: ContractClearancePanelProps) {
const queryClient = useQueryClient();
const [pending, setPending] = useState<Record<string, File>>({});
const [adHoc, setAdHoc] = useState<AdHocDoc[]>([]);
const { view, viewer } = useFileViewer();
const clearanceQuery = useQuery(
api.contracts.getClearance.queryOptions({
input: { id: contractId },
enabled: !!contractId,
}),
);
const clearance = clearanceQuery.data;
const uploadMutation = useMutation({
...api.contracts.uploadClearanceDocuments.mutationOptions(),
onSuccess: () => {
setPending({});
setAdHoc([]);
queryClient.invalidateQueries({
queryKey: api.contracts.getClearance.queryKey({ id: contractId }),
});
queryClient.invalidateQueries({
queryKey: api.contracts.get.queryKey({ id: contractId }),
});
// Submitting moves the contract to CLEARANCE_UNDER_REVIEW — refresh every
// contracts-list query (prefix key) so the /contracts table row and its
// action button update without a page reload.
queryClient.invalidateQueries({
queryKey: api.contracts.list.queryKey(),
});
onSubmitted?.();
},
});
const customerDocs = useMemo(() => {
const docs = (clearance?.documents ?? []).filter(
(d) => d.uploadedBy === "customer",
);
// Surface queried documents (the ones needing correction) first.
const rank = (s: string | null) =>
s === "QUERIED" ? 0 : s === "APPROVED" ? 2 : 1;
return [...docs].sort(
(a, b) => rank(a.reviewStatus) - rank(b.reviewStatus),
);
}, [clearance]);
const queriedCount = useMemo(
() => customerDocs.filter((d) => d.reviewStatus === "QUERIED").length,
[customerDocs],
);
const glDocs = useMemo(
() => (clearance?.documents ?? []).filter((d) => d.uploadedBy !== "customer"),
[clearance],
);
const status = clearance?.clearanceStatus ?? "AWAITING_DOCUMENTS";
const isUnderReview = status === "DOCUMENTS_UNDER_REVIEW";
const isReady =
status === "CLEARANCE_READY_FOR_BOOKING" ||
status === "SELF_CLEARED" ||
status === "ACTIVE_SHIPMENT_IN_PROGRESS";
const canUpload = status === "AWAITING_DOCUMENTS" || isUnderReview;
const isInitialUpload = status === "AWAITING_DOCUMENTS";
const customsPath = clearance?.includesCustoms ?? true;
const reviewer = customsPath ? "Global Logistics" : "the Operations team";
const missingRequired = useMemo(
() => customerDocs.filter((d) => d.required && !d.file && !pending[d.fileKey]),
[customerDocs, pending],
);
const hasStagedFiles =
Object.keys(pending).length > 0 || adHoc.some((r) => r.file);
const canSubmit = isInitialUpload
? hasStagedFiles && missingRequired.length === 0
: hasStagedFiles;
const stagePending = (fileKey: string, file: File | null) =>
setPending((p) => {
if (file) return { ...p, [fileKey]: file };
const next = { ...p };
delete next[fileKey];
return next;
});
const submitDocuments = () => {
const files: Record<string, File | null> = { ...pending };
adHoc.forEach((row, i) => {
if (row.file) files[`custom_${Date.now()}_${i}`] = row.file;
});
if (Object.keys(files).length === 0) return;
uploadMutation.mutate({ id: contractId, files });
};
if (clearanceQuery.isLoading) {
return (
<Center mih={bare ? 200 : 400} p="xl">
<Loader color="edr-green" />
</Center>
);
}
const body = (
<Stack gap={0}>
{queriedCount > 0 && (
<Alert
color="red"
radius="md"
icon={<AlertCircle size={18} />}
mb="md"
title={`${queriedCount} document${queriedCount > 1 ? "s" : ""} need correction`}
>
Re-upload the highlighted document{queriedCount > 1 ? "s" : ""} below to
continue. The reviewer's note explains what to fix.
</Alert>
)}
{isReady ? (
<Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />} mb="md">
{customsPath
? "Your clearance documents are approved. Global Logistics will create your booking on your behalf — you will be notified when payment is due."
: "Your clearance documents are approved. You can now create a shipment booking under this contract."}
</Alert>
) : isUnderReview ? (
<Alert color="blue" radius="md" icon={<Clock size={18} />} mb="md">
{reviewer.charAt(0).toUpperCase() + reviewer.slice(1)} is reviewing
your documents. Only re-upload the documents flagged with a query
below approved documents stay as they are.
</Alert>
) : (
<Alert color="yellow" radius="md" icon={<AlertCircle size={18} />} mb="md">
{customsPath
? "Upload every required clearance document (marked *) below to start the review. Global Logistics will clear your shipment and create the booking for you."
: "This service does not include EDR customs clearance — clear the cargo yourself and upload every required clearance document (marked *) below. The Operations team will review them before you can book a shipment."}
</Alert>
)}
{isInitialUpload && missingRequired.length > 0 && (
<Alert color="yellow" variant="light" radius="md" mb="md" p="xs">
<Text fz="12px" c="#9A5B00">
Still required: {missingRequired.map((d) => d.label).join(", ")}
</Text>
</Alert>
)}
{/* Required customer documents */}
<Stack gap="md">
<Box>
<Text fz={13} fw={700} c="#10202F">
Your clearance documents
</Text>
<Text fz={12} c="dimmed" mt={4}>
Upload each required document below. Items marked * are mandatory.
</Text>
</Box>
{customerDocs.map((doc) => (
<ClearanceDocumentUploadCard
key={doc.fileKey}
label={doc.label}
required={doc.required}
reviewStatus={doc.reviewStatus}
note={doc.note}
uploadedFile={doc.file}
stagedFile={pending[doc.fileKey] ?? null}
canUpload={canUpload}
onStageFile={
canUpload && doc.reviewStatus !== "APPROVED"
? (file) => stagePending(doc.fileKey, file)
: undefined
}
onPreview={view}
/>
))}
{customerDocs.length === 0 && (
<Text fz="sm" c="dimmed">
No clearance documents are configured for this contract yet.
</Text>
)}
</Stack>
{/* GL output documents (read-only). */}
{glDocs.length > 0 && (
<>
<Text fz="12.5px" fw={700} c="#10202F" mt="lg" mb={8}>
Customs output documents
</Text>
<Stack gap={8}>
{glDocs.map((doc) => (
<Group
key={doc.fileKey}
justify="space-between"
wrap="nowrap"
className="rounded-xl"
style={{ border: `1px solid ${BORDER}`, padding: 10 }}
>
<Text fz="13px" c="#10202F" truncate>
{doc.label}
</Text>
{doc.file ? (
<Group gap={8} wrap="nowrap">
{isViewable({
name: doc.file.name,
url: "",
}) && (
<IconSquare
icon={<Eye size={15} />}
onClick={() =>
void fetchViewableFile(
doc.file!.id,
doc.file!.name,
).then(view)
}
/>
)}
<IconSquare
icon={<Download size={15} />}
onClick={() =>
void downloadStoredFile(doc.file!.id, doc.file!.name)
}
/>
</Group>
) : (
<Text fz="12px" c="#9AA8B5">
Pending
</Text>
)}
</Group>
))}
</Stack>
</>
)}
<Box mt="lg">
<ClearanceUploadedDocumentsPanel
embedded
tradeDirection={tradeDirection}
files={clearance?.workflowFiles ?? []}
title="Customs workflow documents"
onView={(f) => view(f)}
onDownload={({ id, name }) => void downloadStoredFile(id, name)}
/>
</Box>
{canUpload ? (
<ClearanceAdHocUploadSection
rows={adHoc}
onAdd={() => setAdHoc((r) => [...r, { name: "", file: null }])}
onRemove={(i) => setAdHoc((rows) => rows.filter((_, j) => j !== i))}
onNameChange={(i, name) =>
setAdHoc((rows) => rows.map((r, j) => (j === i ? { ...r, name } : r)))
}
onFileChange={(i, file) =>
setAdHoc((rows) => rows.map((r, j) => (j === i ? { ...r, file } : r)))
}
onPreview={view}
/>
) : null}
{uploadMutation.isError && (
<Alert color="red" radius="md" icon={<AlertCircle size={16} />} mt="md">
{uploadMutation.error instanceof Error
? uploadMutation.error.message
: "Upload failed. Please try again."}
</Alert>
)}
{canUpload && (
<Group justify="flex-end" mt="xl">
<Button
color="edr-green"
radius="md"
size="md"
leftSection={<Upload size={16} />}
disabled={!canSubmit}
loading={uploadMutation.isPending}
onClick={submitDocuments}
>
{isInitialUpload ? "Submit documents" : "Re-upload documents"}
</Button>
</Group>
)}
{viewer}
</Stack>
);
if (bare) return body;
return (
<Paper withBorder radius={20} p="lg" style={{ borderColor: BORDER }}>
{body}
</Paper>
);
}
export default ContractClearancePanel;

View File

@@ -1,347 +0,0 @@
import { useState } from "react";
import { Alert, Box, Button, Group, Paper, Stack, Text, Textarea } from "@mantine/core";
import { AlertTriangle, ArrowRight, Download, MessageSquareWarning, PackageCheck, Receipt, Upload } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import toast from "react-hot-toast";
import type { Freight } from "@edr/types";
import { bookingStatusLabel } from "@/pages/bookings/booking-display";
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
import { PortalFileDropzone } from "@/components/contracts/PortalFileDropzone";
import { contractsService } from "@/services/contracts.service";
import { fetchViewableFile, downloadStoredFile } from "@/services/files.service";
import { useFileViewer } from "@/hooks/useFileViewer";
import { BORDER, INK } from "./contract-ui";
import { ClearancePhaseStepper } from "./ClearancePhaseStepper";
function downloadWorkflowFile({ id, name }: { id: string; name: string }) {
void downloadStoredFile(id, name);
}
export function ContractClearanceWorkflowBanner({
contract,
}: {
contract: Freight.IContract;
}) {
const { view, viewer } = useFileViewer();
const isPhased =
contract.customsClearingEnabled && contract.contractKind === "ONE_TIME";
const { data: clearance, refetch } = useQuery({
queryKey: ["contract-clearance", contract.id],
queryFn: () => contractsService.getClearance(contract.id),
enabled: isPhased,
});
if (!isPhased || !clearance) return null;
const dutyPaid = clearance.milestones?.some(
(m) => m.milestoneCode === "DUTY_TAX_PAID" && m.status === "COMPLETED",
);
const dutyPending =
clearance.dutyRequired &&
clearance.dutyAdvice &&
!dutyPaid;
return (
<>
<Paper withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
<Stack gap="md">
<Text fw={700} size="sm" style={{ color: INK }}>
Clearance progress
</Text>
<ClearancePhaseStepper
clearance={clearance}
tradeDirection={contract.tradeDirection}
compact
/>
{clearance.roHold && clearance.roHoldReason ? (
<Alert color="orange" icon={<AlertTriangle size={16} />} title="Release Order on hold">
{clearance.roHoldReason}
</Alert>
) : null}
{clearance.nextAction?.actor === "CUSTOMER" ? (
<Alert color="blue" variant="light">
{clearance.nextAction.action}
</Alert>
) : null}
{/* Duty is disputed: GL owes a corrected advice, so the pay/upload
panel is replaced by the waiting state until they re-send it. */}
{clearance.dutyDispute ? (
<Alert
color="orange"
variant="light"
icon={<MessageSquareWarning size={16} />}
title="Waiting for a corrected duty amount"
>
<Stack gap={4}>
<Text fz={13}>
You asked GL Ethiopia to review the advised duty & tax. They
will send a corrected notice you will be notified.
</Text>
<Text fz={12} c="dimmed" style={{ whiteSpace: "pre-wrap" }}>
Your message: {clearance.dutyDispute.note}
</Text>
</Stack>
</Alert>
) : dutyPending && clearance.dutyAdvice ? (
<DutyAdvicePanel
dutyAdvice={clearance.dutyAdvice}
contractId={contract.id}
onUploaded={() => void refetch()}
onPreview={view}
/>
) : null}
<ClearanceUploadedDocumentsPanel
embedded
tradeDirection={contract.tradeDirection ?? "IMPORT"}
files={clearance.workflowFiles ?? []}
onView={view}
onDownload={downloadWorkflowFile}
/>
{clearance.linkedBookingId ? (
<Alert color="green" variant="light" icon={<PackageCheck size={16} />}>
<Stack gap={6}>
<Text fz={13} fw={600} style={{ color: INK }}>
Shipment booking created
{clearance.linkedBookingReference
? ` · ${clearance.linkedBookingReference}`
: ""}
</Text>
<Text fz={12} c="dimmed">
Global Logistics has created your shipment booking
{clearance.linkedBookingStatus
? ` (${bookingStatusLabel(clearance.linkedBookingStatus).toLowerCase()})`
: ""}
. Track its progress from the booking.
</Text>
<Button
size="compact-sm"
variant="light"
color="green"
leftSection={<ArrowRight size={14} />}
component="a"
href={`/bookings/${clearance.linkedBookingId}`}
style={{ alignSelf: "flex-start" }}
>
View shipment booking
</Button>
</Stack>
</Alert>
) : clearance.bookingReady ? (
<Alert color="green" variant="light">
Clearance is complete. Global Logistics will create your shipment booking shortly.
</Alert>
) : null}
</Stack>
</Paper>
{viewer}
</>
);
}
function DutyAdvicePanel({
dutyAdvice,
contractId,
onUploaded,
onPreview,
}: {
dutyAdvice: NonNullable<Freight.ContractClearanceView["dutyAdvice"]>;
contractId: string;
onUploaded: () => void;
onPreview: (file: { name: string; url: string; mimeType?: string | null }) => void;
}) {
const [file, setFile] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
// Accept → pay and upload the slip. Query → say what is wrong and send it
// back to GL Ethiopia for a corrected notice.
const [disputing, setDisputing] = useState(false);
const [disputeNote, setDisputeNote] = useState("");
const [sendingDispute, setSendingDispute] = useState(false);
return (
<Paper
withBorder
radius="lg"
p="md"
style={{
borderColor: "#F5D9A8",
background: "linear-gradient(160deg, #FFFBF0 0%, #FFFFFF 70%)",
}}
>
<Stack gap="md">
<Group gap={8} wrap="nowrap">
<Box
style={{
width: 32,
height: 32,
borderRadius: 8,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#FFF3D6",
color: "#C77F09",
}}
>
<Receipt size={16} />
</Box>
<Text fw={700} fz={14} style={{ color: INK }}>
Duty / tax payment
</Text>
</Group>
<Paper withBorder radius="md" p="sm" bg="#fff">
<Text fz={13} style={{ color: INK }}>
Amount due:{" "}
<strong>
{dutyAdvice.amount.toLocaleString()} {dutyAdvice.currency}
</strong>
{dutyAdvice.declarationSerial
? ` · Payment code: ${dutyAdvice.declarationSerial}`
: null}
</Text>
{dutyAdvice.noticeFile ? (
<Group gap={8} mt={8}>
<Button
size="compact-xs"
variant="light"
color="orange"
leftSection={<Download size={14} />}
component="button"
type="button"
onClick={() =>
void downloadStoredFile(
dutyAdvice.noticeFile!.id,
dutyAdvice.noticeFile!.name,
)
}
>
Download duty notice
</Button>
<Button
size="compact-xs"
variant="subtle"
color="gray"
onClick={() =>
void fetchViewableFile(
dutyAdvice.noticeFile!.id,
dutyAdvice.noticeFile!.name,
).then(onPreview)
}
>
Preview notice
</Button>
</Group>
) : null}
</Paper>
{disputing ? (
<Stack gap="sm">
<Text fz={13} c="dimmed">
Tell GL Ethiopia what is wrong with this amount. They will review
and send a corrected notice.
</Text>
<Textarea
label="What needs correcting?"
placeholder="e.g. the declared value is wrong — the invoice total is 412,000 ETB"
value={disputeNote}
onChange={(e) => setDisputeNote(e.currentTarget.value)}
autosize
minRows={3}
/>
<Group gap="sm" grow>
<Button
variant="default"
onClick={() => {
setDisputing(false);
setDisputeNote("");
}}
>
Back
</Button>
<Button
color="orange"
loading={sendingDispute}
disabled={!disputeNote.trim()}
onClick={async () => {
setSendingDispute(true);
try {
await contractsService.disputeContractDuty(
contractId,
disputeNote.trim(),
);
toast.success("Sent back to GL Ethiopia for correction");
setDisputing(false);
setDisputeNote("");
onUploaded();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Could not send");
} finally {
setSendingDispute(false);
}
}}
>
Send for correction
</Button>
</Group>
</Stack>
) : (
<>
<Text fz={13} c="dimmed">
Pay the amount above, then upload your payment slip so clearance can
continue. If the amount looks wrong, ask GL Ethiopia to correct it
before paying.
</Text>
<PortalFileDropzone
label="Payment slip"
description="Upload proof of duty/tax payment (PDF or image)."
value={file}
onChange={setFile}
onPreview={onPreview}
/>
<Button
color="orange"
loading={loading}
disabled={!file}
leftSection={<Upload size={16} />}
fullWidth
onClick={async () => {
if (!file) return;
setLoading(true);
try {
await contractsService.uploadContractDutySlip(contractId, file);
toast.success("Payment slip uploaded");
setFile(null);
onUploaded();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Upload failed");
} finally {
setLoading(false);
}
}}
>
Accept & submit payment slip
</Button>
<Button
variant="subtle"
color="orange"
leftSection={<MessageSquareWarning size={15} />}
fullWidth
onClick={() => setDisputing(true)}
>
Request a change to this amount
</Button>
</>
)}
</Stack>
</Paper>
);
}

View File

@@ -1,11 +1,6 @@
import { useEffect, useMemo, useState } from "react";
import {
Navigate,
useNavigate,
useParams,
useSearchParams,
} from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import { Navigate, useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Alert,
Badge,
@@ -13,9 +8,9 @@ import {
Button,
Card,
Center,
FileInput,
Group,
Loader,
Modal,
Paper,
Progress,
RingProgress,
@@ -24,10 +19,10 @@ import {
Table,
Tabs,
Text,
Textarea,
Title,
} from "@mantine/core";
import {
AlertTriangle,
ArrowLeft,
CalendarClock,
CheckCircle2,
@@ -49,9 +44,8 @@ import {
Split,
Upload,
Weight,
XCircle,
} from "lucide-react";
import { Modal } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { isViewable, type ViewableFile } from "@edr/ui-common";
import type { Freight } from "@edr/types";
import { clearanceWorkflowFileLabel } from "@edr/types";
@@ -68,9 +62,6 @@ import {
PaymentBadge,
SchedulingCell,
} from "@/pages/bookings/booking-display";
import { ContractClearancePanel } from "./ContractClearancePanel";
import { ContractClearanceWorkflowBanner } from "./ContractClearanceWorkflowBanner";
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
import { InitiateBookingButton } from "@/components/customer-actions/ContractCustomerAction";
import { formatRateUnit } from "./new-contract-form/unit-rates";
import { getContractBookingAction } from "./contract-booking-action";
@@ -85,15 +76,6 @@ import {
MUTED,
} from "./contract-ui";
// Statuses where the customer uploads clearance documents on the contract. Used
// by both paths: Path B (customs, GL-reviewed) and Path A self-clearance
// (non-customs IMPORT/EXPORT, Operations-reviewed).
const CLEARANCE_UPLOAD_STATUSES = [
"AWAITING_CLEARANCE_DOCUMENTS",
"CLEARANCE_UNDER_REVIEW",
"CLEARANCE_READY_FOR_BOOKING",
];
type ContractFile = NonNullable<Freight.IContract["files"]>[number];
// Customer-facing labels for a shipment-request status (BOOKING_REQUEST_STATUSES).
@@ -104,29 +86,6 @@ const BOOKING_REQUEST_STATUS_LABELS: Record<string, string> = {
CANCELLED: "Cancelled",
};
// Customer-facing labels for an invoice status (Freight.InvoiceStatus).
const INVOICE_STATUS_LABELS: Record<string, string> = {
DRAFT: "Draft",
ISSUED: "Issued",
PENDING: "Due",
PARTIALLY_PAID: "Partially paid",
PAID: "Paid",
OVERDUE: "Overdue",
CANCELLED: "Cancelled",
REFUNDED: "Refunded",
EXPIRED: "Expired",
};
function invoiceStatusLabel(status: string): string {
return (
INVOICE_STATUS_LABELS[status] ??
status
.replace(/_/g, " ")
.toLowerCase()
.replace(/\b\w/g, (m) => m.toUpperCase())
);
}
// Business-license document codes — surfaced as their own section so they stand
// out from the rest of the onboarding/profile set.
const BUSINESS_LICENSE_DOC_CODES = new Set([
@@ -215,24 +174,45 @@ function groupContractDocuments(
].filter((g) => g.files.length > 0);
}
/**
* Statuses where the customer's own cancel is pointless or not theirs to make:
* already over, or frozen by EDR (only staff can lift that).
*/
const CANCEL_BLOCKED_STATUSES = [
"REJECTED",
"CANCELLED",
"CONTRACT_CLOSED",
"ARCHIVED",
"EXPIRED",
"SUSPENDED",
];
export default function ContractDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [tab, setTab] = useState<string>("details");
const [cancelOpen, setCancelOpen] = useState(false);
const [cancelReason, setCancelReason] = useState("");
const queryClient = useQueryClient();
const { view, viewer } = useFileViewer();
const [clearanceOpen, clearanceModal] = useDisclosure(false);
const [searchParams, setSearchParams] = useSearchParams();
// Deep link from the home "needs attention" card: /contracts/:id?action=clearance
// opens the clearance step modal directly.
useEffect(() => {
if (searchParams.get("action") === "clearance") {
clearanceModal.open();
searchParams.delete("action");
setSearchParams(searchParams, { replace: true });
}
}, [searchParams, clearanceModal, setSearchParams]);
// Declared up here with the other hooks — the page early-returns further down
// (loading / error / redirect-to-edit), so a hook below those would be
// conditional.
const cancelContract = useMutation({
mutationFn: (reason: string | undefined) =>
contractsService.cancel(id!, reason),
onSuccess: () => {
toast.success("Contract cancelled");
void queryClient.invalidateQueries({ queryKey: ["contracts"] });
navigate("/contracts");
},
onError: (error: unknown) => {
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Could not cancel this contract.";
toast.error(message);
},
});
const {
data: contract,
isLoading,
@@ -249,22 +229,6 @@ export default function ContractDetailPage() {
enabled: !!id,
});
// Clearance view — drives queries alert, workflow documents, and duty panels.
const inClearance =
!!contract && CLEARANCE_UPLOAD_STATUSES.includes(contract.status);
const isPhasedCustomsClearance =
!!contract &&
contract.customsClearingEnabled &&
contract.contractKind === "ONE_TIME";
const { data: clearanceView, refetch: refetchClearance } = useQuery({
...api.contracts.getClearance.queryOptions({ input: { id: id! } }),
enabled: !!id && (inClearance || isPhasedCustomsClearance),
});
const workflowFiles = clearanceView?.workflowFiles ?? [];
const workflowFileCount = workflowFiles.filter((f) => f.file).length;
const queriedCount = (clearanceView?.documents ?? []).filter(
(d) => d.uploadedBy === "customer" && d.reviewStatus === "QUERIED",
).length;
const showShipmentRequests =
!!contract &&
@@ -375,16 +339,9 @@ export default function ContractDetailPage() {
const routes = contract.routes ?? [];
const pricing = contract.pricingBreakdown;
const files = contract.files ?? [];
// GENERAL contracts clear per booking — clearance documents live on each
// booking's detail page, so this tab keeps only profile/licence documents.
// Clearance upload field keys (when the clearance view is loaded) let the
// grouping tell real clearance docs apart from other attachments.
const docGroups = groupContractDocuments(files, {
includeClearance: contract.contractKind !== "GENERAL",
clearanceKeys: new Set(
(clearanceView?.documents ?? []).map((d) => d.fileKey),
),
});
// Clearance documents live on each booking now, so the contract's Documents
// tab keeps only its own attachments (profile, licence, signed contract).
const docGroups = groupContractDocuments(files, { includeClearance: false });
const docCount = docGroups.reduce((sum, g) => sum + g.files.length, 0);
// The generated contract PDF — surfaced via a dedicated "View contract" button
// in the header (it's excluded from the Documents tab groups).
@@ -407,18 +364,20 @@ export default function ContractDetailPage() {
};
const canSign = contract.status === "CONTRACT_READY";
// The customer may drop their own contract at any live stage so they can
// request a different one on the same lane — but not while a shipment under
// it is still running, and not while EDR has it suspended. The API enforces
// both; this only decides whether the button is offered.
const activeShipments = contract.activeBookingCount ?? 0;
const cancelStatusOk = !CANCEL_BLOCKED_STATUSES.includes(contract.status);
const cancellable = cancelStatusOk && activeShipments === 0;
const customsPath = contract.customsClearingEnabled;
// Intercity (DOMESTIC) has no customs — the document gate collects the
// admin-configured intercity set, reviewed by Operations.
const isIntercity = contract.tradeDirection === "DOMESTIC";
const docNoun = isIntercity ? "intercity documents" : "clearance documents";
// Only the NON-customs (Path A) customer books himself — once the contract is
// executed after self-clearance. Customs (Path B) bookings are created by
// Global Logistics on the customer's behalf, so the customer gets no booking
// button on a customs contract.
const clearanceFinalized =
contract.clearanceStatus === "CLEARANCE_READY_FOR_BOOKING" ||
contract.status === "CLEARANCE_READY_FOR_BOOKING";
const bookingAction = getContractBookingAction(contract, contractBookings);
// Whether the customer may open a new self-service booking. Derived from the
// shared booking-action helper so it honours the ONE_TIME single-slot rule:
@@ -427,16 +386,9 @@ export default function ContractDetailPage() {
const canBookShipment =
bookingAction.kind === "book" || bookingAction.kind === "rebook";
const canRequestShipment = bookingAction.kind === "request";
// GENERAL non-customs import/export: one-click bare booking instance — the
// per-booking clearance runs first, so no window gate applies here.
// Self-clearance import/export (ONE_TIME or GENERAL): one-click bare booking
// instance — the per-booking clearance runs first, so no window gate here.
const canInitiateBooking = bookingAction.kind === "initiate";
// Customs + clearance finalized: GL is preparing the booking — surface a
// status notice instead of any action.
const glPreparingBooking = customsPath && clearanceFinalized;
// The customer uploads clearance documents while in a clearance status, until
// clearance is finalized.
const canUploadClearance =
CLEARANCE_UPLOAD_STATUSES.includes(contract.status) && !clearanceFinalized;
return (
<Box style={{ padding: "28px 32px 40px" }}>
@@ -559,33 +511,37 @@ export default function ContractDetailPage() {
</Group>
</Paper>
))}
{glPreparingBooking && (
<Badge
size="lg"
radius="md"
variant="light"
color="teal"
leftSection={<CheckCircle2 size={14} />}
>
Global Logistics is creating your booking
</Badge>
)}
{canUploadClearance && (
{cancelStatusOk && (
<Button
color="edr-green"
variant="light"
color="red"
radius="md"
size="md"
leftSection={<Upload size={16} />}
onClick={clearanceModal.open}
leftSection={<XCircle size={16} />}
disabled={!cancellable}
title={
cancellable
? undefined
: `${activeShipments} shipment(s) still running on this contract`
}
onClick={() => setCancelOpen(true)}
>
{contract.status === "CLEARANCE_UNDER_REVIEW"
? `Manage ${docNoun}`
: `Upload ${docNoun}`}
Cancel contract
</Button>
)}
</Group>
</Group>
{contract.status === "SUSPENDED" && (
<Alert color="orange" radius="md" title="Contract suspended by EDR">
{contract.latestSuspensionNote
? `Reason: ${contract.latestSuspensionNote}`
: "This contract is on hold."}{" "}
No new shipments can be booked and shipments already under it are
paused until EDR lifts the suspension.
</Alert>
)}
{/* Key facts — one premium gradient card */}
<Paper
radius={18}
@@ -680,7 +636,7 @@ export default function ContractDetailPage() {
active={tab === "documents"}
icon={<Download size={16} />}
label="Documents"
count={docCount + (isPhasedCustomsClearance ? workflowFileCount : 0)}
count={docCount}
/>
<DetailTab
value="bookings"
@@ -717,174 +673,6 @@ export default function ContractDetailPage() {
</Paper>
{/* Clearance notice (both paths) */}
{queriedCount > 0 && (
<Paper
withBorder
radius="lg"
p="lg"
style={{
borderColor: "#F0B4B4",
background: "#FDF4F4",
position: "relative",
overflow: "hidden",
}}
>
<Box
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: 3,
background: "#D64545",
}}
/>
<Group justify="space-between" align="center" wrap="wrap" gap="sm">
<Group gap={10} align="flex-start" wrap="nowrap">
<AlertTriangle size={18} color="#D64545" style={{ marginTop: 2 }} />
<div>
<Text fw={700} fz={15} c="#7A1F1F">
{queriedCount} document{queriedCount > 1 ? "s" : ""} need
correction
</Text>
<Text fz={13} c="#9A4A4A" mt={2}>
A reviewer sent back document
{queriedCount > 1 ? "s" : ""} with a query. Re-upload the
corrected file{queriedCount > 1 ? "s" : ""} to continue.
</Text>
</div>
</Group>
<Button
color="red"
radius="md"
size="sm"
leftSection={<Upload size={15} />}
onClick={clearanceModal.open}
>
Upload corrected documents
</Button>
</Group>
</Paper>
)}
{customsPath && contract.contractKind === "ONE_TIME" ? (
<ContractClearanceWorkflowBanner contract={contract} />
) : null}
{clearanceView?.riskLevel ? (
<Paper
withBorder
radius="lg"
p="md"
style={{ borderColor: BORDER, background: "#FBFDFC" }}
>
<Group gap={10} align="center">
<Text fw={700} fz={14} c={INK}>
Customs risk level
</Text>
<Badge
color={CUSTOMS_RISK_COLOR[clearanceView.riskLevel] ?? "gray"}
variant="filled"
radius="sm"
>
{clearanceView.riskLevel.charAt(0) +
clearanceView.riskLevel.slice(1).toLowerCase()}
</Badge>
{clearanceView.riskAssignedAt ? (
<Text fz={12} c="dimmed">
assigned {new Date(clearanceView.riskAssignedAt).toLocaleString()}
</Text>
) : null}
</Group>
</Paper>
) : null}
{clearanceView?.secondDuty?.advised && clearanceView?.linkedBookingId ? (
<SecondDutyDueCard
duty={clearanceView.secondDuty}
bookingId={clearanceView.linkedBookingId}
onView={view}
onChanged={() => void refetchClearance()}
/>
) : null}
{clearanceView?.finalInvoice && clearanceView?.linkedBookingId ? (
<FinalInvoiceDueCard
invoice={clearanceView.finalInvoice}
bookingId={clearanceView.linkedBookingId}
onView={view}
onChanged={() => void refetchClearance()}
/>
) : null}
{canUploadClearance && (
<Paper
withBorder
radius="lg"
p="lg"
style={{
borderColor: "#CDEBDD",
background: "#F6FBF8",
position: "relative",
overflow: "hidden",
}}
>
<Box
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: 3,
background: GREEN,
}}
/>
<Group gap={10} align="center" mb={6}>
<Upload size={16} color={GREEN} />
<Text fw={700} fz={15} c={INK}>
{customsPath
? "Customs clearance shipment"
: isIntercity
? "Intercity documents required"
: "Customs clearance required"}
</Text>
</Group>
<Text fz={13} c="dimmed">
{customsPath
? contract.status === "AWAITING_CLEARANCE_DOCUMENTS"
? "Upload your clearance documents so Global Logistics can review them. Once they finalize the clearance you can create your shipment booking."
: contract.status === "CLEARANCE_UNDER_REVIEW"
? "Global Logistics is reviewing your clearance documents. Re-upload any queried documents to proceed."
: "Your documents are cleared. You can now create a shipment booking under this contract."
: isIntercity
? contract.status === "AWAITING_CLEARANCE_DOCUMENTS"
? "Upload the required intercity documents so the Operations team can review them before you book a shipment."
: contract.status === "CLEARANCE_UNDER_REVIEW"
? "The Operations team is reviewing your intercity documents. Re-upload any queried documents to proceed."
: "Your intercity documents are approved. You can now create a shipment booking under this contract."
: contract.status === "AWAITING_CLEARANCE_DOCUMENTS"
? "This service does not include EDR customs clearance. Clear the cargo yourself and upload your clearance documents so the Operations team can review them before you book a shipment."
: contract.status === "CLEARANCE_UNDER_REVIEW"
? "The Operations team is reviewing your clearance documents. Re-upload any queried documents to proceed."
: "Your clearance documents are approved. You can now create a shipment booking under this contract."}
</Text>
{contract.status !== "CLEARANCE_READY_FOR_BOOKING" && (
<Button
mt="md"
color="edr-green"
radius="md"
size="sm"
leftSection={<Upload size={15} />}
onClick={clearanceModal.open}
>
{contract.status === "AWAITING_CLEARANCE_DOCUMENTS"
? "Upload documents"
: "Manage documents"}
</Button>
)}
</Paper>
)}
{/* Unit-rate schedule */}
<Card
@@ -1213,21 +1001,6 @@ export default function ContractDetailPage() {
{/* ── Documents tab ─────────────────────────────────────────── */}
<Tabs.Panel value="documents">
<Stack gap="lg">
{isPhasedCustomsClearance ? (
<ClearanceUploadedDocumentsPanel
files={workflowFiles}
tradeDirection={contract.tradeDirection ?? "IMPORT"}
onView={view}
onDownload={async (f) => {
try {
await downloadStoredFile(f.id, f.name);
} catch {
toast.error("Could not download file.");
}
}}
/>
) : null}
{docGroups.length === 0 ? (
<Card
withBorder
@@ -1255,7 +1028,7 @@ export default function ContractDetailPage() {
<Text fz={13} c="dimmed" ta="center" maw={420}>
{isGeneral
? "Your company profile documents (TIN certificate, business licence, ID) appear here. Clearance documents are managed on each booking."
: "The signed contract and any uploaded clearance documents will appear here."}
: "The signed contract and your company documents appear here. Clearance documents are managed on each booking."}
</Text>
</Stack>
</Card>
@@ -1456,9 +1229,9 @@ export default function ContractDetailPage() {
{canBookShipment
? "No bookings yet. Use “New booking” to ship against this contract."
: customsPath
? "No bookings yet. After your clearance documents are approved, Global Logistics creates the booking on your behalf."
: canUploadClearance
? "No bookings yet. After the Operations team approves your clearance documents, you can create a booking here."
? "No bookings yet. Global Logistics opens the shipment on your behalf — you upload the clearance documents on it."
: canInitiateBooking
? "No shipments yet. Start one with “Initiate booking” — you upload the clearance documents on that shipment."
: "Bookings appear here once the contract is fully executed."}
</Text>
</Stack>
@@ -1578,23 +1351,42 @@ export default function ContractDetailPage() {
{viewer}
<Modal
opened={clearanceOpen}
onClose={clearanceModal.close}
title={
<Text fw={700} fz={16}>
{isIntercity ? "Intercity documents" : "Clearance documents"}
</Text>
}
size="xl"
radius="md"
opened={cancelOpen}
onClose={() => setCancelOpen(false)}
title="Cancel this contract?"
centered
overlayProps={{ blur: 2, backgroundOpacity: 0.55 }}
>
<ContractClearancePanel
contractId={contract.id}
tradeDirection={contract.tradeDirection ?? "IMPORT"}
bare
/>
<Stack gap="md">
<Text size="sm">
Contract <b>{contract.reference}</b> will be cancelled. This cannot
be undone but the lane is freed, so you can request a new contract
for the same route right away.
</Text>
<Textarea
label="Reason (optional)"
placeholder="Why are you cancelling this contract?"
autosize
minRows={2}
value={cancelReason}
onChange={(e) => setCancelReason(e.currentTarget.value)}
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setCancelOpen(false)}>
Keep contract
</Button>
<Button
color="red"
loading={cancelContract.isPending}
onClick={() =>
cancelContract.mutate(cancelReason.trim() || undefined, {
onSuccess: () => setCancelOpen(false),
})
}
>
Cancel contract
</Button>
</Group>
</Stack>
</Modal>
</Box>
);
@@ -1931,292 +1723,3 @@ function FactCell({
);
}
/**
* Post-offload final invoice from GL Djibouti (export): shows the due amount +
* invoice document; the customer pays offline and attaches the payment slip
* here, then GL confirms and the badge flips to PAID.
*/
function FinalInvoiceDueCard({
invoice,
bookingId,
onView,
onChanged,
}: {
invoice: NonNullable<Freight.ContractClearanceView["finalInvoice"]>;
bookingId: string;
onView: (file: { name: string; url: string }) => void;
onChanged: () => void;
}) {
const [slip, setSlip] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const paid = invoice.status === "PAID";
return (
<Paper
withBorder
radius="lg"
p="lg"
style={{
borderColor: paid ? "#CDEBDD" : "#F2D9A6",
background: paid ? "#F6FBF8" : "#FFFBF2",
position: "relative",
overflow: "hidden",
}}
>
<Box
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: 3,
background: paid ? GREEN : "#E3A93C",
}}
/>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<div>
<Group gap={8} align="center">
<FileBadge size={16} color={paid ? GREEN : "#B07C1F"} />
<Text fw={700} fz={15} c={INK}>
{paid ? "Final invoice paid" : "Final invoice due"} {" "}
{invoice.invoiceNumber}
</Text>
<Badge color={paid ? "edr-green" : "yellow"} variant="light" radius="sm">
{invoiceStatusLabel(invoice.status)}
</Badge>
</Group>
<Text fz={20} fw={800} mt={6} c={INK}>
{invoice.totalAmount.toLocaleString()} {invoice.currency}
</Text>
{invoice.description ? (
<Text fz={13} c="dimmed" mt={2}>
{invoice.description}
</Text>
) : null}
{!paid ? (
<Text fz={13} c="#9A6B1F" mt={6}>
Pay the amount above and attach your payment slip Global
Logistics will confirm the payment.
</Text>
) : null}
</div>
<Stack gap="xs" miw={260}>
{invoice.invoiceFile ? (
<Button
variant="default"
radius="md"
size="sm"
leftSection={<Eye size={15} />}
onClick={() =>
onView({
name: invoice.invoiceFile!.name,
url: invoice.invoiceFile!.url,
})
}
>
View invoice
</Button>
) : null}
{invoice.slipFile ? (
<Button
variant="default"
radius="md"
size="sm"
leftSection={<Eye size={15} />}
onClick={() =>
onView({
name: invoice.slipFile!.name,
url: invoice.slipFile!.url,
})
}
>
View payment slip
</Button>
) : null}
{!paid ? (
<>
<FileInput
placeholder={
invoice.slipFile ? "Replace payment slip" : "Attach payment slip"
}
value={slip}
onChange={setSlip}
size="sm"
radius="md"
/>
<Button
color="edr-green"
radius="md"
size="sm"
loading={uploading}
disabled={!slip}
leftSection={<Upload size={15} />}
onClick={async () => {
if (!slip) return;
setUploading(true);
try {
await contractsService.uploadFinalInvoiceSlip(bookingId, slip);
setSlip(null);
toast.success("Payment slip attached");
onChanged();
} catch (e) {
toast.error(
e instanceof Error ? e.message : "Upload failed",
);
} finally {
setUploading(false);
}
}}
>
{invoice.slipFile ? "Replace slip" : "Submit payment slip"}
</Button>
</>
) : null}
</Stack>
</Group>
</Paper>
);
}
const CUSTOMS_RISK_COLOR: Record<string, string> = {
GREEN: "green",
YELLOW: "yellow",
RED: "red",
};
/**
* Post-arrival additional duty/tax round (import): GL advises an extra amount
* with a notice; the customer pays offline and attaches another slip here.
*/
function SecondDutyDueCard({
duty,
bookingId,
onView,
onChanged,
}: {
duty: NonNullable<Freight.ContractClearanceView["secondDuty"]>;
bookingId: string;
onView: (file: { name: string; url: string }) => void;
onChanged: () => void;
}) {
const [slip, setSlip] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const paid = duty.paid;
return (
<Paper
withBorder
radius="lg"
p="lg"
style={{
borderColor: paid ? "#CDEBDD" : "#F2D9A6",
background: paid ? "#F6FBF8" : "#FFFBF2",
position: "relative",
overflow: "hidden",
}}
>
<Box
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: 3,
background: paid ? GREEN : "#E3A93C",
}}
/>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<div>
<Group gap={8} align="center">
<FileBadge size={16} color={paid ? GREEN : "#B07C1F"} />
<Text fw={700} fz={15} c={INK}>
{paid ? "Additional duty & tax paid" : "Additional duty & tax due"}
</Text>
<Badge color={paid ? "edr-green" : "yellow"} variant="light" radius="sm">
{paid ? "PAID" : "DUE"}
</Badge>
</Group>
<Text fz={20} fw={800} mt={6} c={INK}>
{(duty.amount ?? 0).toLocaleString()} {duty.currency ?? ""}
</Text>
{duty.declarationSerial ? (
<Text fz={13} c="dimmed" mt={2}>
Payment code: {duty.declarationSerial}
</Text>
) : null}
{!paid ? (
<Text fz={13} c="#9A6B1F" mt={6}>
Customs advised additional duty/tax after arrival. Pay the amount
above and attach your payment slip.
</Text>
) : null}
</div>
<Stack gap="xs" miw={260}>
{duty.noticeFile ? (
<Button
variant="default"
radius="md"
size="sm"
leftSection={<Eye size={15} />}
onClick={() =>
onView({ name: duty.noticeFile!.name, url: duty.noticeFile!.url })
}
>
View duty notice
</Button>
) : null}
{duty.slipFile ? (
<Button
variant="default"
radius="md"
size="sm"
leftSection={<Eye size={15} />}
onClick={() =>
onView({ name: duty.slipFile!.name, url: duty.slipFile!.url })
}
>
View payment slip
</Button>
) : null}
{!paid ? (
<>
<FileInput
placeholder={duty.slipFile ? "Replace payment slip" : "Attach payment slip"}
value={slip}
onChange={setSlip}
size="sm"
radius="md"
/>
<Button
color="edr-green"
radius="md"
size="sm"
loading={uploading}
disabled={!slip}
leftSection={<Upload size={15} />}
onClick={async () => {
if (!slip) return;
setUploading(true);
try {
await contractsService.uploadSecondDutySlip(bookingId, slip);
setSlip(null);
toast.success("Payment slip attached");
onChanged();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Upload failed");
} finally {
setUploading(false);
}
}}
>
{duty.slipFile ? "Replace slip" : "Submit payment slip"}
</Button>
</>
) : null}
</Stack>
</Group>
</Paper>
);
}

View File

@@ -124,7 +124,11 @@ function resolveStep(status: string): StepState {
return { activeIdx: at("active"), terminal: "EXPIRED", next: "This contract's validity has expired." };
case "CONTRACT_CLOSED":
case "ARCHIVED":
return { activeIdx: STAGES.length - 1, terminal: "CLOSED", next: "This contract is closed." };
return {
activeIdx: STAGES.length - 1,
terminal: "CLOSED",
next: "This contract is completed — its shipment was delivered.",
};
default:
return { activeIdx: at("draft"), terminal: null, next: "" };

View File

@@ -40,8 +40,9 @@ export interface ContractBookingAction {
* The single source of truth for whether a contract row should show a
* Book / Re-book shipment button, and where it should navigate.
*
* - ONE_TIME: book only while there is no active booking. If the previous
* booking expired, offer Re-book. Once a live booking exists, no button.
* - ONE_TIME: one shipment instance at a time. Import/export self-clearance
* initiates a bare instance (clearance first); intercity books directly.
* Customs is initiated by GL, so the customer gets no button.
* - GENERAL: bookable while CONTRACT_ACTIVE (CONTRACT_CLOSED / EXPIRED are
* already excluded by the PATH_A_BOOKABLE gate).
*/
@@ -61,7 +62,8 @@ export function getContractBookingAction(
};
}
// Other customs (ONE_TIME): booked by GL — no customer action.
// Other customs (ONE_TIME): GL initiates the shipment instance and completes
// it — the customer only uploads documents on that booking. No action here.
if (contract.customsClearingEnabled) return { kind: "none", to: "" };
if (!PATH_A_BOOKABLE.includes(contract.status)) return { kind: "none", to: "" };
@@ -76,13 +78,24 @@ export function getContractBookingAction(
const hasLiveSplit = mine.some(
(b) => b.isSplit && !RELEASING_BOOKING_STATUSES.includes(b.status),
);
if (hasLiveSplit) return { kind: "book", to, remainderOnly: true };
const hasActive = mine.some(
(b) => !TERMINAL_BOOKING_STATUSES.includes(b.status),
);
const hasActive =
!hasLiveSplit &&
mine.some((b) => !TERMINAL_BOOKING_STATUSES.includes(b.status));
if (hasActive) return { kind: "none", to: "" };
const hasExpired = mine.some((b) => b.status === "EXPIRED");
return { kind: hasExpired ? "rebook" : "book", to };
// Intercity is booked directly with its cargo (no shipment day to defer to);
// its documents still live on that booking.
if (contract.tradeDirection === "DOMESTIC") {
const hasExpired = mine.some((b) => b.status === "EXPIRED");
return { kind: hasExpired ? "rebook" : "book", to };
}
// Import/export self-clearance: one-click bare instance, exactly like a
// GENERAL contract — clearance documents are uploaded on it, and cargo +
// shipment day come at completion.
return {
kind: "initiate",
to: `/contracts/${contract.id}`,
...(hasLiveSplit ? { remainderOnly: true } : {}),
};
}
// GENERAL non-customs import/export: one-click bare booking instance — the

View File

@@ -132,8 +132,9 @@ export const CONTRACT_STATUS_CONFIG: Record<
CLEARANCE_UNDER_REVIEW: { label: "Clearance Under Review", ...TONE.info },
CLEARANCE_READY_FOR_BOOKING: { label: "Cleared — Awaiting Booking", ...TONE.success },
ACTIVE_SHIPMENT_IN_PROGRESS: { label: "Shipment In Progress", ...TONE.info },
SUSPENDED: { label: "Suspended", ...TONE.warning },
// ── Terminal ──
CONTRACT_CLOSED: { label: "Closed", ...TONE.neutral },
CONTRACT_CLOSED: { label: "Completed", ...TONE.neutral },
EXPIRED: { label: "Expired", ...TONE.danger },
REJECTED: { label: "Rejected", ...TONE.danger },
CANCELLED: { label: "Cancelled", ...TONE.danger },

View File

@@ -285,6 +285,15 @@ export const contractsService = {
return data.data ?? data;
},
/**
* Cancel own contract so a fresh one can be requested on the same lane. The
* API rejects it while any shipment on the contract is still live.
*/
cancel: async (id: string, reason?: string): Promise<Freight.IContract> => {
const { data } = await client.post(C.CANCEL(id), { reason });
return data.data ?? data;
},
// ── Pre-booking clearance (Path B) ──
getClearance: async (id: string): Promise<Freight.ContractClearanceView> => {
const { data } = await client.get(C.CLEARANCE(id));