mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 02:58:11 +00:00
Merge pull request #982 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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],
|
||||
),
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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`),
|
||||
|
||||
Reference in New Issue
Block a user