Merge branch 'dev' into freight/nati-1

This commit is contained in:
Nathnael
2026-07-29 13:39:20 +00:00
269 changed files with 12185 additions and 5087 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

@@ -76,6 +76,10 @@ api.interceptors.request.use((config) => {
config.headers.Authorization = `Bearer ${token}`;
}
// Tells the backend which app is asking, so /auth/login can reject
// cross-audience credentials (EDRFREIGHT-415).
config.headers["X-Client-App"] = "backoffice";
return config;
});

View File

@@ -0,0 +1,301 @@
import { useMemo, useState } from "react";
import { useQueries, useQuery } from "@tanstack/react-query";
import { Badge, Button, Center, Group, Loader, SimpleGrid, Stack, Table, Text } from "@mantine/core";
import { Coins, Truck } from "lucide-react";
import { api } from "@/services/api";
import { warehouseService } from "@/services/warehouse.service";
import { lastMileService } from "@/services/last-mile.service";
import { FeePreviewModal } from "@/components/warehouses/FeePreviewModal";
import { TruckDetentionModal } from "@/components/operations/TruckDetentionModal";
import { SectionCard } from "./SectionCard";
import { MetricTile } from "./MetricTile";
const money = (amount: number, currency: string) =>
`${Number(amount).toLocaleString()} ${currency === "ETB" ? "Birr (ETB)" : currency}`;
const fmt = (iso: string | null | undefined) => (iso ? new Date(iso).toLocaleString() : "—");
function inspectionLabel(status: string | null | undefined): { text: string; color: string } {
if (!status) return { text: "Pending", color: "gray" };
if (status === "PASSED") return { text: "Passed", color: "edr-green" };
if (status === "FAILED") return { text: "Failed", color: "red" };
return { text: status, color: "gray" };
}
interface TruckRow {
key: string;
plate: string;
driver: string | null;
truckType: string | null;
containers: string[];
warehouseArrived: string | null;
warehouseDeparted: string | null;
destinationArrived: string | null;
returned: string | null;
detentionOpen: boolean;
detentionDays: number | null;
detentionAmount: number | null;
hasDetentionRule: boolean;
inspection: { text: string; color: string };
}
/**
* Every truck tied to a booking's last mile — EDR-dispatched or customer
* self-haul (a booking only ever uses one), each with its own warehouse-gate
* and destination-detention clocks, plus the booking's cargo-side cost totals
* (storage/demurrage/double handling — billed per row internally, always
* shown here as one booking-level total). Detention stays EDR-only; customer
* self-haul rows show "—" since EDR only bills detention on its own fleet.
*/
export function BookingTrucksPanel({ bookingId }: { bookingId: string }) {
const [feeModalOpen, setFeeModalOpen] = useState(false);
const [detentionModalOpen, setDetentionModalOpen] = useState(false);
const inventoryQuery = useQuery(
api.warehouses.listInventory.queryOptions({ input: { filter: { bookingId } } }),
);
const inventoryItems = inventoryQuery.data ?? [];
const latestInventory = inventoryItems[0] ?? null;
const edrTrucksQuery = useQuery({
queryKey: ["booking-edr-trucks", bookingId],
queryFn: () => warehouseService.getLastMileTrucks(bookingId),
});
const edrTrucks = edrTrucksQuery.data ?? [];
const customerTrucksQuery = useQuery({
queryKey: ["booking-customer-trucks", bookingId],
queryFn: () => warehouseService.getCustomerTrucks(bookingId),
enabled: edrTrucksQuery.isSuccess && edrTrucks.length === 0,
});
const customerTrucks = customerTrucksQuery.data ?? [];
const mode: "EDR" | "CUSTOMER" | "NONE" =
edrTrucks.length > 0 ? "EDR" : customerTrucks.length > 0 ? "CUSTOMER" : "NONE";
const containerItemsQuery = useQuery({
queryKey: ["booking-container-items-for-trucks", bookingId],
queryFn: () => warehouseService.getContainerItems(bookingId),
});
const inspectionByContainer = new Map(
(containerItemsQuery.data ?? []).map((c) => [c.containerNumber, c.inspectionStatus]),
);
const lastMileId = edrTrucks[0]?.lastMileId ?? null;
const detentionPreviewQuery = useQuery({
queryKey: ["truck-detention-preview-for-trucks-tab", lastMileId],
queryFn: () => lastMileService.truckDetentionPreview(lastMileId as string).then((r) => r.data),
enabled: Boolean(lastMileId),
});
const detentionPreview = detentionPreviewQuery.data;
const detentionByVehicle = new Map(
(detentionPreview?.groups ?? []).map((g) => [g.vehicleId ?? "", g]),
);
const lastMileRecordQuery = useQuery({
queryKey: ["last-mile-record-for-trucks-tab", lastMileId],
queryFn: () => lastMileService.getById(lastMileId as string).then((r) => r.data),
enabled: Boolean(lastMileId),
});
// Booking-level cost strip: same per-row fee preview the accrual dashboard
// and FeePreviewModal already use, summed across every inventory row on
// this booking rather than duplicated per row.
const feeQueries = useQueries({
queries: inventoryItems.map((item) =>
api.warehouses.feePreview.queryOptions({ input: { inventoryId: item.id, billingCurrency: "USD" } }),
),
});
const allFees = feeQueries.flatMap((q) => q.data ?? []);
const feeCurrency = allFees[0]?.currency ?? "USD";
const sumByType = (type: string) =>
allFees.filter((f) => f.ruleType === type).reduce((sum, f) => sum + Number(f.amount || 0), 0);
const rows: TruckRow[] = useMemo(() => {
if (mode === "EDR") {
return edrTrucks.map((t) => {
const g = detentionByVehicle.get(t.vehicleId);
return {
key: t.vehicleId,
plate: [t.truckPlateNumber, t.trailerPlateNumber].filter(Boolean).join(" + ") || "—",
driver: t.driverName,
truckType: t.truckType,
containers: t.containerNumber ? [t.containerNumber] : [],
warehouseArrived: t.arrivedAt,
warehouseDeparted: t.departedAt,
destinationArrived: g?.startDate ?? null,
returned: g?.endIsOpen ? null : g?.endDate ?? null,
detentionOpen: Boolean(g?.endIsOpen),
detentionDays: g?.chargeableDays ?? null,
detentionAmount: g?.amount ?? null,
hasDetentionRule: Boolean(g?.ruleId),
inspection: inspectionLabel(t.containerNumber ? inspectionByContainer.get(t.containerNumber) : undefined),
};
});
}
if (mode === "CUSTOMER") {
return customerTrucks.map((t) => {
const containers = (t.containers ?? []).map((c) => c.containerNumber);
const statuses = new Set(containers.map((cn) => inspectionByContainer.get(cn) ?? null));
const inspection =
containers.length === 0
? inspectionLabel(undefined)
: statuses.size > 1
? { text: "Mixed", color: "yellow" }
: inspectionLabel([...statuses][0]);
return {
key: t.id,
plate: t.plateNumber,
driver: t.driverName,
truckType: t.truckType,
containers,
warehouseArrived: t.arrivedAt ?? null,
warehouseDeparted: t.departedAt ?? null,
destinationArrived: null,
returned: null,
detentionOpen: false,
detentionDays: null,
detentionAmount: null,
hasDetentionRule: false,
inspection,
};
});
}
return [];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode, edrTrucks, customerTrucks, inspectionByContainer, detentionByVehicle]);
if (inventoryQuery.isLoading || edrTrucksQuery.isLoading) {
return (
<Center py={60}>
<Group gap={10}>
<Loader color="edr-green" />
<Text c="dimmed">Loading trucks</Text>
</Group>
</Center>
);
}
return (
<Stack gap="lg">
<SectionCard
icon={Coins}
title="Cargo costs"
subtitle="Storage, demurrage & double handling — booking total"
accent="teal"
extra={
latestInventory && (
<Button size="xs" variant="light" onClick={() => setFeeModalOpen(true)}>
View breakdown
</Button>
)
}
>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="sm">
<MetricTile label="Storage" value={money(sumByType("STORAGE_FEE"), feeCurrency)} />
<MetricTile label="Demurrage" value={money(sumByType("DEMURRAGE_FEE"), feeCurrency)} />
<MetricTile label="Double handling" value={money(sumByType("DOUBLE_HANDLING_FEE"), feeCurrency)} />
</SimpleGrid>
</SectionCard>
<SectionCard
icon={Truck}
title="Trucks"
subtitle={
mode === "EDR" ? "EDR Last Mile" : mode === "CUSTOMER" ? "Customer Self-Haul" : undefined
}
accent="grape"
extra={
mode === "EDR" && (
<Button size="xs" variant="light" onClick={() => setDetentionModalOpen(true)}>
Detention times
</Button>
)
}
>
{rows.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" py="md">
No trucks assigned to this booking's last mile yet.
</Text>
) : (
<Table.ScrollContainer minWidth={1000}>
<Table verticalSpacing="xs" fz="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Plate</Table.Th>
<Table.Th>Driver</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Container(s)</Table.Th>
<Table.Th>Wh. arrived</Table.Th>
<Table.Th>Wh. departed</Table.Th>
<Table.Th>Dest. arrived</Table.Th>
<Table.Th>Returned</Table.Th>
<Table.Th>Detention</Table.Th>
<Table.Th>Inspection</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((r) => (
<Table.Tr key={r.key}>
<Table.Td>{r.plate}</Table.Td>
<Table.Td>{r.driver ?? "—"}</Table.Td>
<Table.Td>{r.truckType ?? "—"}</Table.Td>
<Table.Td>{r.containers.length ? r.containers.join(", ") : "—"}</Table.Td>
<Table.Td>{fmt(r.warehouseArrived)}</Table.Td>
<Table.Td>{fmt(r.warehouseDeparted)}</Table.Td>
<Table.Td>{fmt(r.destinationArrived)}</Table.Td>
<Table.Td>
{r.detentionOpen ? (
<Badge size="xs" color="orange" variant="light">
still out
</Badge>
) : (
fmt(r.returned)
)}
</Table.Td>
<Table.Td>
{mode !== "EDR" || r.detentionDays == null ? (
"—"
) : (
<>
{r.detentionDays}d · {money(r.detentionAmount ?? 0, detentionPreview?.currency ?? "USD")}
{!r.hasDetentionRule && (
<Text span size="xs" c="red">
{" "}
· no rule
</Text>
)}
</>
)}
</Table.Td>
<Table.Td>
<Badge size="xs" variant="light" color={r.inspection.color}>
{r.inspection.text}
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
</SectionCard>
<FeePreviewModal
opened={feeModalOpen}
onClose={() => setFeeModalOpen(false)}
inventoryId={latestInventory?.id ?? null}
/>
{mode === "EDR" && (
<TruckDetentionModal
opened={detentionModalOpen}
onClose={() => setDetentionModalOpen(false)}
record={lastMileRecordQuery.data ?? null}
/>
)}
</Stack>
);
}

View File

@@ -2,6 +2,7 @@ export * from "./booking-detail.styles";
export * from "./SectionCard";
export * from "./ClearanceReviewSection";
export * from "./BookingDocumentsPanel";
export * from "./BookingTrucksPanel";
export * from "./ContractOrdersPanel";
export * from "./MetricTile";
export * from "./BookingDetailToolbar";

View File

@@ -1,12 +1,15 @@
import type { ReactNode } from "react";
import { Badge, Stack, Tabs, Text } from "@mantine/core";
import { AlertTriangle, FileText, ShieldAlert } from "lucide-react";
import { AlertTriangle, FileText, Share2, ShieldAlert } from "lucide-react";
import type { Freight } from "@edr/types";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { AssignRiskCard } from "@/components/contracts/gl-actions/AssignRiskCard";
import { IncidentReportCard } from "@/components/contracts/gl-actions/IncidentReportCard";
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
import { GlExchangePanel } from "@/components/contracts/GlExchangePanel";
export interface ClearanceOpsTabsProps {
bookingId: string | undefined;
@@ -17,6 +20,12 @@ export interface ClearanceOpsTabsProps {
/** Phased customs workflow files — enables the Uploaded documents tab. */
workflowFiles?: Freight.ClearanceWorkflowFile[];
showWorkflowFilesTab?: boolean;
/**
* Booking or contract id whose GL Ethiopia ↔ GL Djibouti document exchange
* belongs on this page. Undefined hides the tab; it is also hidden from staff
* who hold neither desk's clearance-actions permission.
*/
exchangeEntityId?: string;
tradeDirection?: string;
onViewFile?: (file: { name: string; url: string }) => void;
onDownloadFile?: (file: { id: string; name: string }) => void;
@@ -40,6 +49,7 @@ export function ClearanceOpsTabs({
clearanceTab,
workflowFiles = [],
showWorkflowFilesTab = false,
exchangeEntityId,
tradeDirection = "IMPORT",
onViewFile,
onDownloadFile,
@@ -53,7 +63,12 @@ export function ClearanceOpsTabs({
return true;
}).length;
const showDocuments = showWorkflowFilesTab && Boolean(onViewFile);
const hasTabs = (showOpsTabs && hasOps) || showDocuments;
const { user } = useAuth();
const showExchange =
Boolean(exchangeEntityId) &&
(hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) ||
hasPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions));
const hasTabs = (showOpsTabs && hasOps) || showDocuments || showExchange;
if (!hasTabs) {
return <>{clearanceTab}</>;
@@ -78,6 +93,11 @@ export function ClearanceOpsTabs({
Uploaded documents
</Tabs.Tab>
) : null}
{showExchange ? (
<Tabs.Tab value="exchange" leftSection={<Share2 size={14} />}>
Document exchange
</Tabs.Tab>
) : null}
{showOpsTabs && riskMs ? (
<Tabs.Tab value="risk" leftSection={<ShieldAlert size={14} />}>
Risk assignment
@@ -103,6 +123,12 @@ export function ClearanceOpsTabs({
</Tabs.Panel>
) : null}
{showExchange ? (
<Tabs.Panel value="exchange">
<GlExchangePanel entityId={exchangeEntityId!} />
</Tabs.Panel>
) : null}
{showOpsTabs && riskMs && bookingId ? (
<Tabs.Panel value="risk">
<SectionCard icon={ShieldAlert} title="Customs risk" accent="edr-green">

View File

@@ -21,12 +21,14 @@ const CATEGORY_LABELS: Record<
string
> = {
declaration: "Declaration",
draft_declaration: "Draft declaration",
duty: "Duty & taxes",
transit: "Transit",
djibouti: "Djibouti",
};
const CATEGORY_ORDER: Freight.ClearanceWorkflowFileCategory[] = [
"draft_declaration",
"declaration",
"duty",
"transit",

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,
// FilePen, // ponytail: back with the "Edit contract articles" button
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">
@@ -182,9 +286,9 @@ export function ContractActionsToolbar({
<>
<Text size="xs" c="dimmed">
{canEditDocument
? "It is your turn to approve. You can edit the articles before approving — the PDF is generated automatically once the last approver approves."
? "It is your turn to approve — the PDF is generated automatically once the last approver approves."
: draft?.nextApproverRole
? `Awaiting ${draft.nextApproverRole}. Only the current approver can edit the document.`
? `Awaiting ${draft.nextApproverRole}.`
: "Awaiting approval."}
</Text>
<Button
@@ -196,6 +300,9 @@ export function ContractActionsToolbar({
>
Preview document
</Button>
{/* Article editing is hidden for now (frontend only) — the approval
chain approves the document as accepted. Uncomment to restore.
{canEditDocument && (
<Button
fullWidth
@@ -210,6 +317,8 @@ export function ContractActionsToolbar({
Edit contract articles
</Button>
)}
*/}
</>
)}
@@ -241,10 +350,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 +437,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

@@ -9,23 +9,24 @@ import {
Group,
Loader,
Modal,
ScrollArea,
// Select, // ponytail: unused now the validity dropdown below is commented out
Stack,
Text,
Textarea,
TextInput,
Tooltip,
// Tooltip, // ponytail: back with the article editor block
} from "@mantine/core";
import {
ArrowDown,
ArrowUp,
// ArrowDown, // ponytail: back with the article editor block
// ArrowUp,
FileText,
Info,
Lock,
Plus,
Trash2,
} from "lucide-react";
import { DateInput } from "@mantine/dates";
import { DateTimePicker } from "@mantine/dates";
import type { Freight } from "@edr/types";
import { contractsService } from "@/services/contracts.service";
@@ -37,6 +38,30 @@ 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;
}
/** Local `YYYY-MM-DD` — the shape Mantine hands day cells. */
function localDay(date: Date): string {
const pad = (n: number) => String(n).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
/**
* Print today in bold inside the calendar. `highlightToday` only rings the cell,
* which staff read as "disabled" on a picker whose minimum IS today — the weight
* makes it obvious the day is pickable.
*/
function boldToday(date: string) {
return date === localDay(new Date())
? { style: { fontWeight: 800 } }
: {};
}
interface EditableArticle {
id: string;
title: string;
@@ -118,6 +143,17 @@ export function ContractDocumentEditorModal({
);
}, [opened, draft]);
// Accept mode opens on NOW — a contract never starts in the past, and the
// pickers below refuse earlier days. Seconds are dropped so the value matches
// what the HH:mm picker shows.
useEffect(() => {
if (!opened || mode !== "accept") return;
const now = new Date();
now.setSeconds(0, 0);
setValidityStart(now);
setValidityEnd(null);
}, [opened, mode]);
// Default validity to the first configured option (accept mode).
// useEffect(() => {
// if (mode === "accept" && !validityDays && validityOptions.length > 0) {
@@ -129,29 +165,30 @@ export function ContractDocumentEditorModal({
// decides per-caller — the client cannot derive this from the contract alone.
const locked = mode === "edit" && !draft?.editableByMe;
const moveArticle = (index: number, delta: number) => {
setArticles((prev) => {
const next = [...prev];
const target = index + delta;
if (target < 0 || target >= next.length) return prev;
[next[index], next[target]] = [next[target], next[index]];
return next;
});
};
// Article edit handlers — parked with the editor block below.
// const moveArticle = (index: number, delta: number) => {
// setArticles((prev) => {
// const next = [...prev];
// const target = index + delta;
// if (target < 0 || target >= next.length) return prev;
// [next[index], next[target]] = [next[target], next[index]];
// return next;
// });
// };
const updateArticle = (id: string, patch: Partial<EditableArticle>) =>
setArticles((prev) =>
prev.map((a) => (a.id === id ? { ...a, ...patch } : a)),
);
// const updateArticle = (id: string, patch: Partial<EditableArticle>) =>
// setArticles((prev) =>
// prev.map((a) => (a.id === id ? { ...a, ...patch } : a)),
// );
const removeArticle = (id: string) =>
setArticles((prev) => prev.filter((a) => a.id !== id));
// const removeArticle = (id: string) =>
// setArticles((prev) => prev.filter((a) => a.id !== id));
const addArticle = () =>
setArticles((prev) => [
...prev,
{ id: newArticleId(), title: "", body: "" },
]);
// const addArticle = () =>
// setArticles((prev) => [
// ...prev,
// { id: newArticleId(), title: "", body: "" },
// ]);
const buildSnapshot = (): Freight.IContractDocumentSnapshot => ({
code: draft?.code ?? null,
@@ -238,17 +275,63 @@ export function ContractDocumentEditorModal({
? draft?.nextApproverRole
? `Only the current approver (${draft.nextApproverRole}) can edit this document right now.`
: "This document can no longer be edited — the contract has advanced beyond approval."
: "Edits apply to THIS contract only. The six shared templates are never changed."}
: "This document is read-only — it is accepted exactly as the template produced it. Set the validity dates, then accept."}
</Alert>
<TextInput
label="Document title"
placeholder="e.g. Bulk Cargo Transportation and Customs Clearance Services"
value={documentTitle}
onChange={(e) => setDocumentTitle(e.currentTarget.value)}
disabled={locked}
/>
{/* Accept is a REVIEW step: the document is shown exactly as the
template produced it, with nothing editable. Any wording change
belongs to the template or to the separate edit action. */}
{mode === "accept" ? (
<ScrollArea.Autosize mah={340} type="auto">
<Stack gap="sm" pr="sm">
<Text fw={700} fz={15}>
{documentTitle || "Contract document"}
</Text>
{whereasClauses.length > 0 && (
<Stack gap={4}>
{whereasClauses.map((clause, i) => (
<Text key={i} fz={13} c="dimmed">
WHEREAS {clause}
</Text>
))}
</Stack>
)}
{articles.length === 0 ? (
<Text fz={13} c="dimmed">
This template carries no articles.
</Text>
) : (
articles.map((article, index) => (
<Box key={article.id}>
<Text fz={13} fw={700}>
Article {index + 1}
{article.title ? `${article.title}` : ""}
</Text>
<Text
fz={12.5}
c="dimmed"
style={{ whiteSpace: "pre-wrap" }}
>
{article.body}
</Text>
</Box>
))
)}
</Stack>
</ScrollArea.Autosize>
) : (
<TextInput
label="Document title"
placeholder="e.g. Bulk Cargo Transportation and Customs Clearance Services"
value={documentTitle}
onChange={(e) => setDocumentTitle(e.currentTarget.value)}
disabled={locked}
/>
)}
{mode !== "accept" && (
<Box>
<Group justify="space-between" mb={6}>
<Text size="sm" fw={600}>
@@ -304,6 +387,12 @@ export function ContractDocumentEditorModal({
</Stack>
)}
</Box>
)}
{/* Article editing is hidden for now (frontend only) — staff accept the
contract on the template's articles as-is. The articles themselves
still ride along in buildSnapshot(), so the generated document is
unchanged. Uncomment this block to bring the editor back.
<Divider label="Articles" labelPosition="left" />
@@ -364,7 +453,7 @@ export function ContractDocumentEditorModal({
}
/>
<Textarea
placeholder="Article body — each line becomes a numbered clause. Use '- ' for bullets. Placeholders like {{client.companyName}} are supported."
placeholder="Article body — each line becomes a numbered clause."
autosize
minRows={3}
styles={{ input: { fontFamily: "var(--mantine-font-family-monospace)" } }}
@@ -389,6 +478,8 @@ export function ContractDocumentEditorModal({
</Button>
</Stack>
*/}
<Divider />
{mode === "accept" && (
@@ -414,20 +505,29 @@ export function ContractDocumentEditorModal({
</Text>
)} */}
<Group grow align="flex-start">
<DateInput
label="Start date"
<DateTimePicker
label="Start date & time"
placeholder="Contract validity start"
value={validityStart}
onChange={(v) => setValidityStart(v ? new Date(v) : null)}
// Today is the earliest start — and it is ringed in the
// calendar so it reads as selectable rather than blocked.
minDate={startOfToday()}
maxDate={validityEnd ?? undefined}
highlightToday
getDayProps={boldToday}
valueFormat="DD MMM YYYY HH:mm"
clearable
/>
<DateInput
label="End date"
<DateTimePicker
label="End date & time"
placeholder="Contract validity end"
value={validityEnd}
onChange={(v) => setValidityEnd(v ? new Date(v) : null)}
minDate={validityStart ?? undefined}
minDate={validityStart ?? startOfToday()}
highlightToday
getDayProps={boldToday}
valueFormat="DD MMM YYYY HH:mm"
clearable
/>
</Group>

View File

@@ -0,0 +1,224 @@
import { useMemo } from "react";
import {
BadgeCheck,
CalendarClock,
Flame,
Send,
ShieldCheck,
FileSignature,
} from "lucide-react";
import { Group, Stack, Text, Timeline, Tooltip } from "@mantine/core";
import type { Freight } from "@edr/types";
import {
CONTRACT_APPROVAL_ROLE_LABELS,
HAZARDOUS_APPROVAL_ROLE_PERMISSION,
} from "@/lib/permissions";
interface ContractMilestonesTimelineProps {
contract: Freight.IContract;
}
const SIGNATURE_ROLE_LABELS: Record<Freight.ContractSignatureRole, string> = {
CUSTOMER: "Signed by customer",
STAFF: "Signed by EDR — line staff",
DIRECTOR: "Signed by EDR — director",
CEO: "Signed by EDR — CEO",
};
/** "27 Jul 2026, 18:18" — the exact stamp, shown in the tooltip. */
function formatWhen(iso: string): string {
return new Date(iso).toLocaleString(undefined, {
dateStyle: "medium",
timeStyle: "short",
});
}
/** "3 hours ago" — the at-a-glance read. */
function formatAgo(iso: string): string {
const seconds = Math.round((Date.now() - new Date(iso).getTime()) / 1000);
if (seconds < 60) return "just now";
const units: Array<[Intl.RelativeTimeFormatUnit, number]> = [
["year", 31536000],
["month", 2592000],
["day", 86400],
["hour", 3600],
["minute", 60],
];
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" });
for (const [unit, secondsPerUnit] of units) {
if (seconds >= secondsPerUnit) {
return rtf.format(-Math.floor(seconds / secondsPerUnit), unit);
}
}
return "just now";
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, { dateStyle: "medium" });
}
type MilestoneIcon = typeof Send;
interface Milestone {
key: string;
at: string;
title: string;
detail?: string;
color: string;
icon: MilestoneIcon;
}
/**
* The dated moments of a contract's life — submission, hazardous approval,
* final approval, both parties' signatures, full execution — read straight off
* the contract and its already-loaded approvalSteps/signatures (no extra
* fetch). Sits above the document edit history on the History tab.
*/
export function ContractMilestonesTimeline({
contract,
}: ContractMilestonesTimelineProps) {
const milestones = useMemo<Milestone[]>(() => {
const items: Milestone[] = [];
// A DRAFT/RENEWAL_DRAFT contract hasn't been (re)submitted yet — nothing
// to date. submittedAt is only tracked going forward; a contract that
// reached SUBMITTED before that column existed falls back to createdAt.
const submittedAt =
contract.submittedAt ??
(contract.status !== "DRAFT" && contract.status !== "RENEWAL_DRAFT"
? contract.createdAt
: null);
if (submittedAt) {
items.push({
key: "submitted",
at: submittedAt,
title: "Submitted for review",
color: "blue",
icon: Send,
});
}
// Every acted approval step, not just hazardous ones — this is the one
// place the approval-time record shows up in the page's main content
// (the sidebar's ContractApprovalStepsCard has the same times, but only
// there, and only while the chain is still actionable).
for (const step of contract.approvalSteps ?? []) {
if (!step.actedAt) continue;
const hazard = step.requiredRole in HAZARDOUS_APPROVAL_ROLE_PERMISSION;
items.push({
key: `step-${step.id}`,
at: step.actedAt,
title: `${CONTRACT_APPROVAL_ROLE_LABELS[step.requiredRole] ?? step.requiredRole} ${step.status === "REJECTED" ? "rejected" : "approved"}`,
detail: step.note ?? undefined,
color: step.status === "REJECTED" ? "red" : hazard ? "orange" : "edr-green",
icon: hazard ? Flame : ShieldCheck,
});
}
if (contract.contractGeneratedAt) {
items.push({
key: "approved",
at: contract.contractGeneratedAt,
title: "Contract approved",
detail: "Every approval step cleared and the document was generated",
color: "edr-green",
icon: ShieldCheck,
});
}
for (const sig of contract.signatures ?? []) {
items.push({
key: `signature-${sig.id}`,
at: sig.signedAt,
title: SIGNATURE_ROLE_LABELS[sig.role] ?? `Signed by ${sig.role}`,
detail: sig.signerDisplayName,
color: "grape",
icon: FileSignature,
});
}
if (contract.fullyExecutedAt) {
items.push({
key: "executed",
at: contract.fullyExecutedAt,
title: "Fully executed",
detail: "Both parties have signed",
color: "edr-green",
icon: BadgeCheck,
});
}
return items.sort(
(a, b) => new Date(a.at).getTime() - new Date(b.at).getTime(),
);
}, [contract]);
const hasValidity = contract.contractValidFrom && contract.contractValidUntil;
if (milestones.length === 0 && !hasValidity) {
return (
<Text size="sm" c="dimmed" ta="center" py="lg">
No dated milestones recorded yet.
</Text>
);
}
return (
<Stack gap="md">
{hasValidity && (
<Group
gap="xs"
px="sm"
py="xs"
style={{
borderRadius: 8,
border: "1px solid var(--mantine-color-gray-2)",
background: "var(--mantine-color-gray-0)",
width: "fit-content",
}}
>
<CalendarClock size={16} color="var(--mantine-color-gray-6)" />
<Text size="sm">
Valid <strong>{formatDate(contract.contractValidFrom!)}</strong>
{" → "}
<strong>{formatDate(contract.contractValidUntil!)}</strong>
</Text>
</Group>
)}
{milestones.length > 0 && (
<Timeline active={milestones.length} bulletSize={26} lineWidth={2} color="edr-green">
{milestones.map((m) => {
const Icon = m.icon;
return (
<Timeline.Item
key={m.key}
bullet={<Icon size={13} />}
color={m.color}
title={
<Group gap="xs" wrap="wrap" align="baseline">
<Text size="sm" fw={600}>
{m.title}
</Text>
<Tooltip label={formatWhen(m.at)} withArrow>
<Text size="xs" c="dimmed">
{formatAgo(m.at)}
</Text>
</Tooltip>
</Group>
}
>
{m.detail && (
<Text size="xs" c="dimmed" mt={2}>
{m.detail}
</Text>
)}
</Timeline.Item>
);
})}
</Timeline>
)}
</Stack>
);
}

View File

@@ -1,9 +1,10 @@
import { Badge, Group } from "@mantine/core";
import { Repeat } from "lucide-react";
import { Building2, Repeat, UserRound } from "lucide-react";
import {
CONTRACT_STATUS_COLOR,
CONTRACT_STATUS_STYLES,
contractCourt,
} from "@/features/contracts/contract-status.config";
interface ContractStatusBadgeProps {
@@ -69,3 +70,42 @@ export function ContractStatusBadge({
</Group>
);
}
/** Whose court the contract sits in: customer, EDR, or nobody ("—"). */
export function ContractCourtBadge({ status }: { status: string }) {
const court = contractCourt(status);
if (!court) {
return (
<span className="text-sm text-muted-foreground" title="No party is awaited">
</span>
);
}
const isCustomer = court === "customer";
return (
<Badge
color={isCustomer ? "orange" : "edr-green"}
variant="light"
size="sm"
radius="md"
tt="uppercase"
fw={600}
leftSection={
isCustomer ? <UserRound size={12} /> : <Building2 size={12} />
}
title={
isCustomer
? "Waiting on the customer to act"
: "Waiting on EDR staff to act"
}
style={{
fontSize: "0.7rem",
letterSpacing: "0.05em",
display: "inline-flex",
whiteSpace: "nowrap",
}}
>
{isCustomer ? "With customer" : "With EDR"}
</Badge>
);
}

View File

@@ -20,6 +20,7 @@ import {
CheckCircle2,
FileText,
PackageCheck,
PackageOpen,
Receipt,
Ship,
Train,
@@ -31,6 +32,7 @@ import toast from "react-hot-toast";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone";
import { TransitAssigneePanel } from "@/components/contracts/TransitAssigneePanel";
import {
TransitPermitMultiUpload,
type TransitPermitUploadedRow,
@@ -41,9 +43,11 @@ import {
} from "@/components/contracts/PhasedUploadedFileRow";
import {
DeclarationStep,
OffloadStep,
StepStatus,
isBookingMilestoneDone,
isMilestoneDone,
offloadSummary,
type ClearanceViewLike,
type MilestoneRow,
} from "@/components/contracts/PhasedClearanceActionPanel";
@@ -59,7 +63,8 @@ function todayISODate(): string {
/**
* Export customs flow, ordered per the stakeholder process:
* customer docs → RO (DJ) → declaration (ET, auto-releases) → create booking (ET)
* customer docs → transit assignee (DJ names officer) → declaration (ET,
* releases the export) → RO (DJ, auto-releases) → create booking (ET)
* → payment + wagons → transport document / T1 (ET) → train to Djibouti
* → accept T1 (DJ, one button after arrival) → gate pass (DJ)
* → final invoice (DJ) + customer slip + GL confirm.
@@ -71,9 +76,10 @@ export function computeExportActiveStep(
): number {
const released = Boolean(clearance.bookingReady || clearance.operationReady);
if (!isMilestoneDone(clearance.milestones, "DOCUMENTS_APPROVED")) return 0;
if (!isMilestoneDone(clearance.milestones, "RELEASE_ORDER_SECURED")) return 1;
if (!isMilestoneDone(clearance.milestones, "DECLARED") || !released) return 2;
if (!bookingCreated) return 3;
if (!clearance.transitAssignee?.name) return 1;
if (!isMilestoneDone(clearance.milestones, "DECLARED")) return 2;
if (!isMilestoneDone(clearance.milestones, "RELEASE_ORDER_SECURED") || !released) return 3;
if (!bookingCreated) return 4;
if (
!isBookingMilestoneDone(bookingMilestones, "FREIGHT_PAYMENT_SETTLED") ||
!(
@@ -81,14 +87,17 @@ export function computeExportActiveStep(
clearance.train?.wagonAllocated
)
) {
return 4;
return 5;
}
if (!isBookingMilestoneDone(bookingMilestones, "EXPORT_TRANSPORT_ISSUED")) return 5;
if (!clearance.train?.arrivedAt) return 6;
if (!clearance.t1Closed) return 7;
if (!clearance.gatepassGranted) return 8;
if (clearance.finalInvoice?.status !== "PAID") return 9;
return 10;
if (!isBookingMilestoneDone(bookingMilestones, "EXPORT_TRANSPORT_ISSUED")) return 6;
if (!clearance.train?.arrivedAt) return 7;
if (!clearance.t1Closed) return 8;
if (!clearance.gatepassGranted) return 9;
// Step 10 is the read-only Offload step. It never gates the flow: the final
// invoice may be raised on a secured gate pass alone, so parking the stepper
// there would hide the invoice actions whenever operations lag on the offload.
if (clearance.finalInvoice?.status !== "PAID") return 11;
return 12;
}
export function exportTransitFilesFromWorkflow(
@@ -168,6 +177,7 @@ export function ExportClearanceStepper({
isBookingMilestoneDone(bookingMilestones, "WAGON_ALLOCATED") ||
Boolean(clearance.train?.wagonAllocated);
const transportIssued = isBookingMilestoneDone(bookingMilestones, "EXPORT_TRANSPORT_ISSUED");
const offloadDone = clearance.offload?.offloaded ?? Boolean(clearance.offloaded);
return (
<Stack gap="md">
@@ -213,6 +223,58 @@ export function ExportClearanceStepper({
/>
</Stepper.Step>
<Stepper.Step
label="Request transit assignee"
description="Ask GL Djibouti to name the officer handling this shipment"
icon={
clearance.transitAssignee?.name ? (
<CheckCircle2 size={14} />
) : undefined
}
>
{showEt && canEt ? (
<TransitAssigneePanel
entityId={entityId}
isBooking={isBooking}
transitAssignee={clearance.transitAssignee}
side="ET"
onChanged={onChanged}
/>
) : (
<StepStatus
done={Boolean(clearance.transitAssignee?.name)}
pendingLabel="Waiting for GL Ethiopia to request a transit assignee from GL Djibouti."
doneLabel={`Transit assignee: ${clearance.transitAssignee?.name ?? ""}`}
/>
)}
</Stepper.Step>
<Stepper.Step
label="Customs declaration"
description="GL Ethiopia uploads — releases the export"
icon={declared ? <CheckCircle2 size={14} /> : <FileText size={14} />}
>
{showEt && canEt && !effectiveBookingCreated && (activeStep >= 2 || declared) ? (
<Stack gap="sm">
<DeclarationStep
entityId={entityId}
isBooking={isBooking}
replaceMode={declared}
workflowFiles={workflowFiles}
onChanged={onChanged}
onViewFile={onViewFile}
onDownloadFile={onDownloadFile}
/>
</Stack>
) : (
<StepStatus
done={declared}
pendingLabel="Waiting for GL Ethiopia to upload the customs declaration."
doneLabel="Declaration uploaded."
/>
)}
</Stepper.Step>
<Stepper.Step
label="Release Order"
description="GL Djibouti uploads RO + vessel date"
@@ -255,31 +317,13 @@ export function ExportClearanceStepper({
</Text>
) : null}
<StepStatus
done={isMilestoneDone(clearance.milestones, "RELEASE_ORDER_SECURED")}
done={isMilestoneDone(clearance.milestones, "RELEASE_ORDER_SECURED") && released}
pendingLabel="Waiting for GL Djibouti to upload the Release Order."
doneLabel="Release Order secured."
doneLabel="Release Order secured — export released."
/>
</Stack>
)}
</Stepper.Step>
<Stepper.Step
label="Customs declaration"
description="GL Ethiopia uploads — releases the export"
icon={declared ? <CheckCircle2 size={14} /> : <FileText size={14} />}
>
{showEt && canEt && !effectiveBookingCreated && (activeStep >= 2 || declared) ? (
<Stack gap="sm">
<DeclarationStep
entityId={entityId}
isBooking={isBooking}
replaceMode={declared}
workflowFiles={workflowFiles}
onChanged={onChanged}
onViewFile={onViewFile}
onDownloadFile={onDownloadFile}
/>
{declared && !released ? (
{/* RO is secured but the auto-release never fired (legacy in-flight
contracts from before the RO step auto-released). */}
{isMilestoneDone(clearance.milestones, "RELEASE_ORDER_SECURED") && !released ? (
<ConfirmExportReleaseFallback
entityId={entityId}
isBooking={isBooking}
@@ -287,12 +331,6 @@ export function ExportClearanceStepper({
/>
) : null}
</Stack>
) : (
<StepStatus
done={declared && released}
pendingLabel="Waiting for GL Ethiopia to upload the customs declaration."
doneLabel="Declaration uploaded — export released."
/>
)}
</Stepper.Step>
@@ -432,6 +470,21 @@ export function ExportClearanceStepper({
<GatepassStep clearance={clearance} />
</Stepper.Step>
{/* Read-only: operations record the offload when the train is unloaded
at the Djibouti port. Stats ride in the description so they stay
visible after the flow moves on to the final invoice. */}
<Stepper.Step
label="Offload"
description={offloadSummary(clearance.offload, Boolean(clearance.offloaded))}
color={offloadDone ? undefined : "gray"}
icon={<PackageOpen size={14} />}
completedIcon={
offloadDone ? <CheckCircle2 size={14} /> : <PackageOpen size={14} />
}
>
<OffloadStep clearance={clearance} />
</Stepper.Step>
<Stepper.Step
label="Final invoice & payment"
description="GL Djibouti invoices after offload; customer pays"
@@ -656,6 +709,8 @@ function FinalInvoiceStep({
const invoice = clearance.finalInvoice ?? null;
const paid = invoice?.status === "PAID";
// Raised as a draft — the customer approves it before paying.
const approved = Boolean(invoice?.approvedAt);
// Export: OFFLOADED is a DJ doc milestone that may never be recorded, so the
// secured gate pass is enough to open invoicing. Sending an invoice is optional.
@@ -684,7 +739,7 @@ function FinalInvoiceStep({
</Text>
</div>
<Badge color={paid ? "edr-green" : "yellow"} variant="light">
{invoice.status}
{approved ? invoice.status : "AWAITING CUSTOMER APPROVAL"}
</Badge>
</Group>
</Paper>
@@ -722,9 +777,11 @@ function FinalInvoiceStep({
<StepStatus
done={false}
pendingLabel={
invoice.slipFile
? "Payment slip attached — confirm to settle the invoice."
: "Waiting for the customer to pay and attach the payment slip."
!approved
? "Waiting for the customer to review and approve the invoice."
: invoice.slipFile
? "Payment slip attached — confirm to settle the invoice."
: "Waiting for the customer to pay and attach the payment slip."
}
doneLabel=""
/>
@@ -754,6 +811,7 @@ function FinalInvoiceStep({
<>
<Text size="sm" c="dimmed">
Send the final invoice to the customer if post-arrival charges apply (optional).
The customer approves it before paying.
</Text>
<Button
color="edr-green"

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

@@ -0,0 +1,479 @@
import { useMemo, useState } from "react";
import {
ActionIcon,
Badge,
Box,
Button,
Group,
Loader,
Menu,
Modal,
Paper,
Stack,
Switch,
Text,
TextInput,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Download,
Eye,
EyeOff,
FileText,
MoreVertical,
Pencil,
Share2,
Trash2,
Upload,
UserCheck,
} from "lucide-react";
import dayjs from "dayjs";
import toast from "react-hot-toast";
import type { Freight } from "@edr/types";
import { isViewable } from "@edr/ui-common";
import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone";
import { useFileViewer } from "@/hooks/useFileViewer";
import { downloadBookingFile, fetchViewableFile } from "@/services/files.service";
import { glExchangeService } from "@/services/glExchange.service";
const SIDES: Record<Freight.GlExchangeDocument["side"], { label: string; color: string }> =
{
ET: { label: "GL Ethiopia", color: "edr-green" },
DJ: { label: "GL Djibouti", color: "blue" },
};
function formatBytes(bytes: number): string {
if (!bytes) return "0 B";
const units = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${parseFloat((bytes / 1024 ** i).toFixed(1))} ${units[i]}`;
}
export interface GlExchangePanelProps {
/** Booking or contract id both desks are working on — the thread key. */
entityId: string;
}
/**
* GL Ethiopia ↔ GL Djibouti document exchange. Either desk attaches any file
* under a title of its own choosing; both desks see the whole thread, only the
* uploader can change or remove what they posted, and each document is shared
* with the customer's portal or kept between the desks.
*/
export function GlExchangePanel({ entityId }: GlExchangePanelProps) {
const queryClient = useQueryClient();
const { view, viewer } = useFileViewer();
const [formDoc, setFormDoc] = useState<
Freight.GlExchangeDocument | "new" | null
>(null);
const [pendingDelete, setPendingDelete] =
useState<Freight.GlExchangeDocument | null>(null);
const {
data: documents = [],
isLoading,
isError,
} = useQuery({
queryKey: ["gl-exchange", entityId],
queryFn: () => glExchangeService.list(entityId),
enabled: Boolean(entityId),
});
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: ["gl-exchange", entityId] });
const removeMutation = useMutation({
mutationFn: (id: string) => glExchangeService.remove(id),
onSuccess: async () => {
setPendingDelete(null);
await invalidate();
toast.success("Document removed");
},
onError: (e: unknown) =>
toast.error(e instanceof Error ? e.message : "Could not remove document"),
});
const stats = useMemo(
() => ({
et: documents.filter((d) => d.side === "ET").length,
dj: documents.filter((d) => d.side === "DJ").length,
shared: documents.filter((d) => d.visibleToCustomer).length,
}),
[documents],
);
return (
<Stack gap="md">
<Paper withBorder radius="md" p="md">
<Group justify="space-between" align="flex-start" wrap="wrap" gap="sm">
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon variant="light" color="edr-green" radius="md" size={44}>
<Share2 size={20} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Text fw={700} fz={16}>
Document exchange
</Text>
<Text size="xs" c="dimmed">
Share any document with the other Global Logistics desk. Both
desks see everything here; only the uploader can edit or remove
a document, and only documents marked visible reach the customer.
</Text>
</Box>
</Group>
<Button
color="edr-green"
radius="md"
leftSection={<Upload size={16} />}
onClick={() => setFormDoc("new")}
>
Share document
</Button>
</Group>
{documents.length > 0 ? (
<Group gap={8} mt="md">
<Badge variant="light" color="edr-green" radius="sm" tt="none">
{stats.et} from GL Ethiopia
</Badge>
<Badge variant="light" color="blue" radius="sm" tt="none">
{stats.dj} from GL Djibouti
</Badge>
<Badge variant="light" color="gray" radius="sm" tt="none">
{stats.shared} visible to customer
</Badge>
</Group>
) : null}
</Paper>
{isLoading ? (
<Group justify="center" py={40} gap={10}>
<Loader size="sm" color="edr-green" />
<Text size="sm" c="dimmed">
Loading shared documents
</Text>
</Group>
) : isError ? (
<Text size="sm" c="red">
Could not load the shared documents.
</Text>
) : documents.length === 0 ? (
<EmptyState onShare={() => setFormDoc("new")} />
) : (
<Stack gap={8}>
{documents.map((doc) => (
<DocumentRow
key={doc.id}
doc={doc}
onView={view}
onEdit={() => setFormDoc(doc)}
onDelete={() => setPendingDelete(doc)}
/>
))}
</Stack>
)}
<DocumentFormModal
entityId={entityId}
doc={formDoc === "new" ? null : formDoc}
opened={formDoc != null}
onClose={() => setFormDoc(null)}
onSaved={() => {
setFormDoc(null);
void invalidate();
}}
/>
<Modal
opened={pendingDelete != null}
onClose={() => setPendingDelete(null)}
title={<Text fw={700}>Remove shared document</Text>}
radius="md"
size="sm"
>
<Stack gap="md">
<Text size="sm">
Remove <b>{pendingDelete?.title}</b> from the exchange? The other
desk and the customer, if it was shared will no longer see it.
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setPendingDelete(null)}>
Cancel
</Button>
<Button
color="red"
loading={removeMutation.isPending}
leftSection={<Trash2 size={15} />}
onClick={() => removeMutation.mutate(pendingDelete!.id)}
>
Remove
</Button>
</Group>
</Stack>
</Modal>
{viewer}
</Stack>
);
}
function EmptyState({ onShare }: { onShare: () => void }) {
return (
<Box
py={44}
style={{
borderRadius: 12,
border: "1px dashed var(--mantine-color-gray-4)",
background: "var(--mantine-color-gray-0)",
textAlign: "center",
}}
>
<Stack gap={10} align="center">
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
<FileText size={22} />
</ThemeIcon>
<Text size="sm" c="dimmed" maw={380}>
Nothing shared yet. Anything either desk uploads here scans,
correspondence, corrected forms is visible to the other side
immediately.
</Text>
<Button
variant="light"
color="edr-green"
radius="md"
leftSection={<Upload size={15} />}
onClick={onShare}
>
Share the first document
</Button>
</Stack>
</Box>
);
}
function DocumentRow({
doc,
onView,
onEdit,
onDelete,
}: {
doc: Freight.GlExchangeDocument;
onView: (file: { name: string; url: string }) => void;
onEdit: () => void;
onDelete: () => void;
}) {
const side = SIDES[doc.side];
const canPreview = isViewable({ name: doc.file.name, url: "" });
return (
<Paper withBorder radius="md" p="sm">
<Group justify="space-between" wrap="nowrap" align="flex-start" gap="sm">
<Group gap={12} wrap="nowrap" align="flex-start" style={{ minWidth: 0 }}>
<ThemeIcon variant="light" color={side.color} radius="md" size={40}>
<FileText size={18} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Group gap={8} wrap="nowrap">
<Text size="sm" fw={700} truncate>
{doc.title}
</Text>
<Badge size="xs" variant="light" color={side.color} radius="sm" tt="none">
{side.label}
</Badge>
<Badge
size="xs"
variant="light"
color={doc.visibleToCustomer ? "teal" : "gray"}
radius="sm"
tt="none"
leftSection={
doc.visibleToCustomer ? <Eye size={11} /> : <EyeOff size={11} />
}
>
{doc.visibleToCustomer ? "Visible to customer" : "GL only"}
</Badge>
</Group>
<Text size="xs" c="dimmed" mt={4} truncate>
{doc.file.name} · {formatBytes(doc.file.size)} ·{" "}
{doc.uploadedByName ?? "Global Logistics"} ·{" "}
{dayjs(doc.uploadedAt).format("D MMM YYYY, HH:mm")}
</Text>
</Box>
</Group>
<Group gap={6} wrap="nowrap">
{canPreview ? (
<Tooltip label="Preview">
<Button
size="compact-xs"
variant="default"
radius="md"
leftSection={<Eye size={13} />}
onClick={() =>
void fetchViewableFile(doc.file.id, doc.file.name).then(onView)
}
>
View
</Button>
</Tooltip>
) : null}
<Tooltip label="Download">
<Button
size="compact-xs"
variant="light"
radius="md"
leftSection={<Download size={13} />}
onClick={() =>
void downloadBookingFile(doc.file.id, doc.file.name)
}
>
Download
</Button>
</Tooltip>
{doc.canEdit ? (
<Menu position="bottom-end" radius="md" withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" color="gray" aria-label="Document actions">
<MoreVertical size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item leftSection={<Pencil size={14} />} onClick={onEdit}>
Edit title, visibility or file
</Menu.Item>
<Menu.Item
color="red"
leftSection={<Trash2 size={14} />}
onClick={onDelete}
>
Remove
</Menu.Item>
</Menu.Dropdown>
</Menu>
) : (
<Tooltip label={`Only ${doc.uploadedByName ?? "the uploader"} can edit this`}>
<ThemeIcon variant="subtle" color="gray" size={28}>
<UserCheck size={15} />
</ThemeIcon>
</Tooltip>
)}
</Group>
</Group>
</Paper>
);
}
function DocumentFormModal({
entityId,
doc,
opened,
onClose,
onSaved,
}: {
entityId: string;
doc: Freight.GlExchangeDocument | null;
opened: boolean;
onClose: () => void;
onSaved: () => void;
}) {
const editing = doc != null;
const [title, setTitle] = useState("");
const [visible, setVisible] = useState(false);
const [file, setFile] = useState<File | null>(null);
// Re-seed the form whenever a different document (or "new") opens it.
const [seededFor, setSeededFor] = useState<string | null>(null);
const seedKey = opened ? (doc?.id ?? "new") : null;
if (seedKey !== seededFor) {
setSeededFor(seedKey);
setTitle(doc?.title ?? "");
setVisible(doc?.visibleToCustomer ?? false);
setFile(null);
}
const save = useMutation({
mutationFn: () =>
editing
? glExchangeService.update(doc.id, {
title: title.trim(),
visibleToCustomer: visible,
file,
})
: glExchangeService.upload(entityId, {
title: title.trim(),
visibleToCustomer: visible,
file: file!,
}),
onSuccess: () => {
toast.success(editing ? "Document updated" : "Document shared");
onSaved();
},
onError: (e: unknown) =>
toast.error(e instanceof Error ? e.message : "Could not save document"),
});
return (
<Modal
opened={opened}
onClose={onClose}
title={
<Group gap={8}>
<Share2 size={18} />
<Text fw={700}>{editing ? "Edit shared document" : "Share a document"}</Text>
</Group>
}
radius="md"
size="md"
>
<Stack gap="md">
<TextInput
label="Document title"
placeholder="e.g. Corrected packing list for container TCLU1234567"
description="What the other desk (and the customer, if shared) will see."
value={title}
onChange={(e) => setTitle(e.currentTarget.value)}
maxLength={300}
required
/>
<PhasedFileDropzone
label={editing ? "Replacement file (optional)" : "File"}
description={
editing
? "Leave empty to keep the current file."
: "Any document type — PDF, image, spreadsheet."
}
accept="*/*"
value={file}
onChange={setFile}
replaceMode={editing}
/>
<Switch
checked={visible}
onChange={(e) => setVisible(e.currentTarget.checked)}
color="edr-green"
label="Visible to the customer"
description="Shows in the customer's booking documents. Off keeps it between the two GL desks."
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onClose} disabled={save.isPending}>
Cancel
</Button>
<Button
color="edr-green"
loading={save.isPending}
disabled={!title.trim() || (!editing && !file)}
leftSection={<Upload size={16} />}
onClick={() => save.mutate()}
>
{editing ? "Save changes" : "Share document"}
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -27,6 +27,7 @@ import {
FileText,
MessageSquareWarning,
PackageCheck,
PackageOpen,
Receipt,
ShieldAlert,
Ship,
@@ -61,7 +62,8 @@ export type ClearanceViewLike = Pick<
| "nextAction"
| "dutyRequired"
| "dutyAdvice"
| "dutyDispute"
| "draftDeclaration"
| "draftDeclarationChangeRequest"
| "transitAssignee"
| "roHold"
| "roHoldReason"
@@ -77,6 +79,7 @@ export type ClearanceViewLike = Pick<
| "t1Closed"
| "t1ClosedAt"
| "offloaded"
| "offload"
| "finalInvoice"
| "vesselDepartureDate"
| "vesselArrivalDate"
@@ -113,32 +116,49 @@ function computeImportActiveStep(
bookingMilestones: MilestoneRow[],
t1Uploaded: boolean,
freightPaid: boolean,
isBooking: boolean,
): number {
if (!isMilestoneDone(clearance.milestones, "DOCUMENTS_APPROVED")) return 0;
if (!isMilestoneDone(clearance.milestones, "DECLARED")) return 1;
if (!clearance.transitAssignee?.name) return 1;
// Draft declaration is a booking-only step — the customer only ever reviews
// it on the booking-scoped portal page, so it never applies (and never
// gates) on the contract-scoped pre-booking page. Also a backward-compat
// guard: a booking that already has a real declaration filed got there
// before this step existed — never send it backward for a draft it was
// never asked to send.
if (
isBooking &&
!isMilestoneDone(clearance.milestones, "DRAFT_DECLARATION_ACCEPTED") &&
!isMilestoneDone(clearance.milestones, "DECLARED")
) {
return 2;
}
if (!isMilestoneDone(clearance.milestones, "DECLARED")) return 3;
if (
clearance.dutyRequired === null ||
clearance.dutyRequired === undefined ||
(clearance.dutyRequired && !isMilestoneDone(clearance.milestones, "DUTY_TAXES_ADVISED"))
) {
return 2;
return 4;
}
if (
clearance.dutyRequired &&
!isMilestoneDone(clearance.milestones, "DUTY_TAX_PAID")
) {
return 3;
return 5;
}
if (!isMilestoneDone(clearance.milestones, "TRANSIT_PERMIT_UPLOADED")) return 4;
if (!clearance.preClearanceFinalized) return 5;
if (!isMilestoneDone(clearance.milestones, "DO_COLLECTED")) return 6;
if (!bookingCreated) return 7;
if (!isMilestoneDone(clearance.milestones, "TRANSIT_PERMIT_UPLOADED")) return 6;
if (!clearance.preClearanceFinalized) return 7;
if (!isMilestoneDone(clearance.milestones, "DO_COLLECTED")) return 8;
if (!bookingCreated) return 9;
// The customer pays the train/freight charges on the booking. Until that
// settles the gate pass is not granted for this booking, so the flow stops here.
if (!freightPaid) return 8;
if (!clearance.gatepassGranted) return 9;
if (!t1Uploaded && !clearance.t1?.closed) return 10;
if (!clearance.t1?.closed) return 11;
if (!freightPaid) return 10;
if (!clearance.gatepassGranted) return 11;
// Step 12 is the read-only Offload step — cargo comes off the train at
// arrival, i.e. AFTER the T1 steps below, so it never gates the flow.
if (!t1Uploaded && !clearance.t1?.closed) return 13;
if (!clearance.t1?.closed) return 14;
// Risk is "assigned" when the booking milestone says so OR the clearance view
// already carries a riskLevel. The ET page derives its bookingMilestones from a
// separately-fetched booking id that can lag or mismatch the booking carrying
@@ -146,15 +166,15 @@ function computeImportActiveStep(
const riskAssigned =
Boolean(clearance.riskLevel) ||
isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED");
if (!riskAssigned) return 12;
if (!riskAssigned) return 15;
// Additional duty round is optional — resolved once skipped or paid.
const secondDutyResolved =
clearance.secondDuty?.skipped ||
clearance.secondDuty?.paid ||
isBookingMilestoneDone(bookingMilestones, "SECOND_DUTY_PAID");
if (!secondDutyResolved) return 13;
if (!clearance.importReleaseGranted) return 14;
return 15;
if (!secondDutyResolved) return 16;
if (!clearance.importReleaseGranted) return 17;
return 18;
}
function t1FilesFromWorkflow(
@@ -267,6 +287,7 @@ export function PhasedClearanceActionPanel({
const freightPaid =
isBookingMilestoneDone(bookingMilestones, "FREIGHT_PAYMENT_SETTLED") ||
Boolean(clearance.gatepassGranted);
const offloadDone = clearance.offload?.offloaded ?? Boolean(clearance.offloaded);
const activeStep = useMemo(
() =>
isImport
@@ -276,6 +297,7 @@ export function PhasedClearanceActionPanel({
bookingMilestones,
t1Uploaded,
freightPaid,
isBooking,
)
: 0,
[
@@ -333,6 +355,76 @@ export function PhasedClearanceActionPanel({
/>
</Stepper.Step>
<Stepper.Step
label="Request transit assignee"
description="Ask GL Djibouti to name the officer handling this shipment"
icon={
clearance.transitAssignee?.name ? (
<CheckCircle2 size={14} />
) : undefined
}
>
{showEt && canEt ? (
<TransitAssigneePanel
entityId={entityId}
isBooking={isBooking}
transitAssignee={clearance.transitAssignee}
side="ET"
onChanged={onChanged}
/>
) : (
<StepStatus
done={Boolean(clearance.transitAssignee?.name)}
pendingLabel="Waiting for GL Ethiopia to request a transit assignee from GL Djibouti."
doneLabel={`Transit assignee: ${clearance.transitAssignee?.name ?? ""}`}
/>
)}
</Stepper.Step>
<Stepper.Step
label="Draft declaration"
description="Send the customer a draft declaration with an estimated price"
icon={
isMilestoneDone(clearance.milestones, "DRAFT_DECLARATION_ACCEPTED") ? (
<CheckCircle2 size={14} />
) : (
<FileText size={14} />
)
}
>
{showEt && canEt && activeStep === 2 ? (
<DraftDeclarationStep
bookingId={entityId}
clearance={clearance}
onChanged={onChanged}
onViewFile={onViewFile}
onDownloadFile={onDownloadFile}
/>
) : (
<Stack gap="sm">
{(clearance.draftDeclaration?.files ?? []).map((file, index) => (
<PhasedUploadedFileRow
key={file.id}
label={`Draft declaration document ${index + 1}`}
file={file}
onView={onViewFile}
onDownload={onDownloadFile}
compact
/>
))}
<StepStatus
done={isMilestoneDone(clearance.milestones, "DRAFT_DECLARATION_ACCEPTED")}
pendingLabel={
clearance.draftDeclaration
? "Waiting for the customer to accept the draft declaration."
: "Send the customer a draft declaration to review."
}
doneLabel="Draft declaration accepted by the customer."
/>
</Stack>
)}
</Stepper.Step>
<Stepper.Step
label="Customs declaration"
description="Upload declaration documents"
@@ -344,24 +436,10 @@ export function PhasedClearanceActionPanel({
)
}
>
{/* Djibouti must name the transit officer first — the declaration
is filed against whoever handles the shipment there, and the
API refuses the upload until the name is in. */}
{showEt &&
canEt &&
!isBooking &&
!clearance.transitAssignee?.name &&
!isMilestoneDone(clearance.milestones, "DECLARED") ? (
<TransitAssigneePanel
contractId={entityId}
transitAssignee={clearance.transitAssignee}
side="ET"
onChanged={onChanged}
/>
) : showEt &&
canEt &&
!clearance.bookingReady &&
(activeStep >= 1 ||
(activeStep >= 3 ||
isMilestoneDone(clearance.milestones, "DECLARED")) ? (
<DeclarationStep
entityId={entityId}
@@ -398,7 +476,7 @@ export function PhasedClearanceActionPanel({
description="Advise amount and attach notice"
icon={<Receipt size={14} />}
>
{showEt && canEt && activeStep === 2 ? (
{showEt && canEt && activeStep === 4 ? (
<DutyStep
entityId={entityId}
isBooking={isBooking}
@@ -460,7 +538,7 @@ export function PhasedClearanceActionPanel({
{showEt &&
canEt &&
!bookingCreated &&
(activeStep >= 4 ||
(activeStep >= 6 ||
isMilestoneDone(clearance.milestones, "TRANSIT_PERMIT_UPLOADED")) ? (
<TransitPermitStep
entityId={entityId}
@@ -504,7 +582,7 @@ export function PhasedClearanceActionPanel({
description="Hand off to GL Djibouti"
icon={<PackageCheck size={14} />}
>
{showEt && canEt && activeStep === 5 ? (
{showEt && canEt && activeStep === 7 ? (
<FinalizePreClearanceStep
entityId={entityId}
isBooking={isBooking}
@@ -624,6 +702,22 @@ export function PhasedClearanceActionPanel({
<ImportGatepassStep clearance={clearance} freightPaid={freightPaid} />
</Stepper.Step>
{/* Read-only: offload is recorded by operations when the train
reaches the destination, which happens after the T1 steps — so
it never holds the active pointer, and its icon stays neutral
until it actually happens. */}
<Stepper.Step
label="Offload"
description={offloadSummary(clearance.offload, Boolean(clearance.offloaded))}
color={offloadDone ? undefined : "gray"}
icon={<PackageOpen size={14} />}
completedIcon={
offloadDone ? <CheckCircle2 size={14} /> : <PackageOpen size={14} />
}
>
<OffloadStep clearance={clearance} />
</Stepper.Step>
<Stepper.Step
label="T1 transport documents"
description="GL Djibouti uploads after the gate pass is secured"
@@ -1049,6 +1143,91 @@ function ImportGatepassStep({
);
}
/**
* Compact offload line for the step's description row — the only part of a
* Mantine step that stays visible once the flow has moved past it.
*/
export function offloadSummary(
offload: ClearanceViewLike["offload"],
offloaded: boolean,
): string {
if (!offload?.offloaded && !offloaded) {
return "Cargo comes off the train at its destination";
}
const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`;
const bits = [
offload?.containers ? plural(offload.containers, "container") : null,
offload?.wagons ? plural(offload.wagons, "wagon") : null,
offload?.weightTons ? `${offload.weightTons.toLocaleString()} t` : null,
offload?.destination ?? null,
].filter(Boolean);
return bits.length ? bits.join(" · ") : "Offloaded";
}
/**
* Offload stats for the booking, read-only. Recorded by the warehouse
* auto-unload that runs when the train reaches the booking's destination —
* nothing here is actioned from clearance.
*/
export function OffloadStep({
clearance,
}: {
clearance: ClearanceViewLike;
}) {
const offload = clearance.offload ?? null;
const done = offload?.offloaded ?? Boolean(clearance.offloaded);
if (!done) {
return (
<StepStatus
done={false}
pendingLabel="Waiting for the cargo to be offloaded at its destination (recorded by operations on arrival)."
doneLabel=""
/>
);
}
const stats: Array<[string, string]> = [
["Containers", offload?.containers ? String(offload.containers) : "—"],
["Wagons", offload?.wagons ? String(offload.wagons) : "—"],
[
"Weight",
offload?.weightTons ? `${offload.weightTons.toLocaleString()} t` : "—",
],
["Destination", offload?.destination ?? "—"],
["GRN", offload?.grnNumber ?? "—"],
["Location", offload?.location ?? "—"],
];
return (
<Paper withBorder radius="md" p="sm" bg="var(--mantine-color-edr-green-0)">
<Group gap="xs" wrap="nowrap" mb="xs">
<Badge color="edr-green" variant="light" leftSection={<CheckCircle2 size={12} />}>
Offloaded
</Badge>
<Text size="sm" c="dimmed">
{offload?.offloadedAt
? new Date(offload.offloadedAt).toLocaleString()
: "Recorded on arrival"}
{offload?.inventoryStatus ? ` · ${offload.inventoryStatus}` : ""}
</Text>
</Group>
<Group gap="lg" wrap="wrap">
{stats.map(([label, value]) => (
<Stack key={label} gap={0}>
<Text size="xs" c="dimmed">
{label}
</Text>
<Text size="sm" fw={600}>
{value}
</Text>
</Stack>
))}
</Group>
</Paper>
);
}
const RISK_LEVEL_COLOR: Record<string, string> = {
GREEN: "green",
YELLOW: "yellow",
@@ -1569,6 +1748,145 @@ export function DeclarationStep({
);
}
/**
* GL Ethiopia sends a draft customs declaration (estimated price + files) for
* the customer to review in the portal before the real declaration is filed.
* Booking-only — the customer only ever sees this on the booking-scoped page.
*/
function DraftDeclarationStep({
bookingId,
clearance,
onChanged,
onViewFile,
onDownloadFile,
}: {
bookingId: string;
clearance: ClearanceViewLike;
onChanged?: () => void;
onViewFile?: (file: { name: string; url: string }) => void;
onDownloadFile?: (file: { id: string; name: string }) => void;
}) {
const [files, setFiles] = useState<File[]>([]);
const [price, setPrice] = useState<number | string>(
clearance.draftDeclaration?.price ?? "",
);
const [currency, setCurrency] = useState(clearance.draftDeclaration?.currency ?? "ETB");
const [loading, setLoading] = useState(false);
const changeRequest = clearance.draftDeclarationChangeRequest;
const existingFiles = clearance.draftDeclaration?.files ?? [];
const replaceMode = existingFiles.length > 0;
return (
<Stack gap="md">
{/* The customer sent this draft back — their words drive the
correction, so they lead the step. */}
{changeRequest ? (
<Alert
color="orange"
radius="md"
icon={<MessageSquareWarning size={16} />}
title={
changeRequest.rounds > 1
? `Customer requested a change (round ${changeRequest.rounds})`
: "Customer requested a change"
}
>
<Stack gap={4}>
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
{changeRequest.note}
</Text>
<Text size="xs" c="dimmed">
Raised {new Date(changeRequest.raisedAt).toLocaleString()} send a
corrected draft below.
</Text>
</Stack>
</Alert>
) : null}
{existingFiles.length > 0 ? (
<Stack gap={8}>
<Text size="xs" fw={700} c="dimmed" tt="uppercase">
Current draft
</Text>
{existingFiles.map((file, index) => (
<PhasedUploadedFileRow
key={file.id}
label={`Draft declaration document ${index + 1}`}
file={file}
onView={onViewFile}
onDownload={onDownloadFile}
/>
))}
</Stack>
) : null}
<Paper withBorder radius="md" p="md" bg="var(--mantine-color-gray-0)">
<Stack gap="md">
<Group grow align="flex-start">
<NumberInput
label="Estimated price"
placeholder="0.00"
value={price}
onChange={setPrice}
min={0}
size="sm"
thousandSeparator=","
/>
<Select
label="Currency"
data={["ETB", "USD"]}
value={currency}
onChange={(v) => setCurrency(v ?? "ETB")}
size="sm"
/>
</Group>
<PhasedMultiFileDropzone
label="Draft declaration documents"
description={
replaceMode
? "Replace the draft — upload one or more corrected documents."
: "Upload one or more draft declaration documents (PDF or image)."
}
value={files}
onChange={setFiles}
replaceMode={replaceMode}
disabled={loading}
/>
</Stack>
</Paper>
<Button
color="edr-green"
loading={loading}
disabled={files.length === 0 || price === ""}
leftSection={<Upload size={16} />}
fullWidth
onClick={async () => {
setLoading(true);
try {
await bookingsService.uploadDraftDeclaration(
bookingId,
files,
Number(price),
currency,
);
setFiles([]);
toast.success(replaceMode ? "Corrected draft sent" : "Draft sent to customer");
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Upload failed");
} finally {
setLoading(false);
}
}}
>
{replaceMode ? "Send corrected draft" : "Send draft to customer"}
</Button>
</Stack>
);
}
function DutyStep({
entityId,
isBooking,
@@ -1597,35 +1915,9 @@ function DutyStep({
const noticeFile = findWorkflowFile(workflowFiles, "duty_tax_notice");
const hasExistingNotice = Boolean(noticeFile);
const dispute = clearance.dutyDispute;
return (
<Stack gap="md">
{/* The customer rejected the last advice — their words drive the
correction, so they lead the step. */}
{dispute ? (
<Alert
color="orange"
radius="md"
icon={<MessageSquareWarning size={16} />}
title={
dispute.rounds > 1
? `Customer asked for a correction (round ${dispute.rounds})`
: "Customer asked for a correction"
}
>
<Stack gap={4}>
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
{dispute.note}
</Text>
<Text size="xs" c="dimmed">
Raised {new Date(dispute.raisedAt).toLocaleString()} re-advise
below to send a corrected notice.
</Text>
</Stack>
</Alert>
) : null}
{noticeFile ? (
<Stack gap={8}>
<Text size="xs" fw={700} c="dimmed" tt="uppercase">

View File

@@ -2,7 +2,7 @@ import { useRef, useState } from "react";
import { Box, Button, Group, Image, Paper, Stack, Text } from "@mantine/core";
import { RefreshCw, Stamp, X } from "lucide-react";
const MAX_STAMP_MB = 5;
const MAX_STAMP_MB = 10;
export interface StampUploadProps {
/** Stamp image as a data URL, or null when none is attached yet. */

View File

@@ -5,20 +5,25 @@ import {
Button,
Group,
Paper,
Select,
Stack,
Text,
TextInput,
Textarea,
} from "@mantine/core";
import { useMutation } from "@tanstack/react-query";
import { useMutation, useQuery } from "@tanstack/react-query";
import { CheckCircle2, Clock, Send, UserCheck } from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
import { bookingsService } from "@/services/bookings.service";
import { contractsService } from "@/services/contracts.service";
import { transitAgentsService } from "@/services/transit-agents.service";
export interface TransitAssigneePanelProps {
contractId: string;
/** Booking id when `isBooking`, contract id otherwise. */
entityId: string;
/** Clearance runs per booking now; contract-level cycles are the legacy case. */
isBooking?: boolean;
transitAssignee: Freight.ContractClearanceView["transitAssignee"];
/**
* ET asks and waits; DJ answers with a name. The same state renders from both
@@ -50,18 +55,32 @@ const fmt = (iso?: string | null) =>
* different name later; the newest one wins and Ethiopia is notified again.
*/
export function TransitAssigneePanel({
contractId,
entityId,
isBooking = false,
transitAssignee,
side,
readOnly = false,
onChanged,
}: TransitAssigneePanelProps) {
const [note, setNote] = useState("");
const [assignee, setAssignee] = useState(transitAssignee?.name ?? "");
const [transitAgentId, setTransitAgentId] = useState<string | null>(null);
const [changing, setChanging] = useState(false);
const service = isBooking ? bookingsService : contractsService;
const { data: assignableAgents, isLoading: loadingAgents } = useQuery({
queryKey: ["transit-agents", "assignable"],
queryFn: () => transitAgentsService.listAssignable(),
enabled: side === "DJ",
});
const agentOptions = (assignableAgents ?? []).map((a) => ({
value: a.id,
label: a.name,
}));
const request = useMutation({
mutationFn: () => contractsService.requestTransitAssignee(contractId, note.trim()),
mutationFn: async () => {
await service.requestTransitAssignee(entityId, note.trim());
},
onSuccess: () => {
toast.success("Request sent to GL Djibouti");
setNote("");
@@ -70,8 +89,10 @@ export function TransitAssigneePanel({
});
const assign = useMutation({
mutationFn: () =>
contractsService.assignTransitAssignee(contractId, assignee.trim()),
mutationFn: async () => {
if (!transitAgentId) return;
await service.assignTransitAssignee(entityId, transitAgentId);
},
onSuccess: () => {
toast.success("Transit assignee sent to GL Ethiopia");
setChanging(false);
@@ -105,7 +126,7 @@ export function TransitAssigneePanel({
variant="light"
radius="md"
onClick={() => {
setAssignee(transitAssignee!.name ?? "");
setTransitAgentId(null);
setChanging(true);
}}
>
@@ -143,13 +164,16 @@ export function TransitAssigneePanel({
GL Ethiopia: {transitAssignee.requestNote}
</Text>
) : null}
<TextInput
<Select
label="Transit officer"
description="Name of the person handling this shipment in Djibouti"
placeholder="e.g. Ahmed Bourhan"
value={assignee}
onChange={(e) => setAssignee(e.currentTarget.value)}
disabled={readOnly}
description="Active, currently-valid transit agents only — configure the roster in Transit Agents settings"
placeholder={loadingAgents ? "Loading…" : "Select transit officer"}
data={agentOptions}
value={transitAgentId}
onChange={setTransitAgentId}
searchable
disabled={readOnly || loadingAgents}
nothingFoundMessage="No active, valid transit agents — add one in Transit Agents settings"
/>
<Group justify="flex-end" gap="sm">
{changing ? (
@@ -162,7 +186,7 @@ export function TransitAssigneePanel({
radius="md"
leftSection={<Send size={15} />}
loading={assign.isPending}
disabled={readOnly || !assignee.trim()}
disabled={readOnly || !transitAgentId}
onClick={() => assign.mutate()}
>
Send assignment

View File

@@ -95,12 +95,16 @@ export function RequestCustomerCard({ contract }: { contract?: ReqContract | nul
);
}
// Validity is accepted to the minute, so the expiry reads with its time — a
// contract that lapses at 09:00 looks identical to one lapsing at 23:59 without it.
const fmtDate = (iso?: string | null) =>
iso
? new Intl.DateTimeFormat("en-GB", {
day: "2-digit",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(iso))
: "—";

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

@@ -43,21 +43,56 @@ function Stat({ label, value, strong }: { label: string; value: React.ReactNode;
);
}
type TruckRow = {
vehicleId: string;
label: string;
arrived: Date | null;
returned: Date | null;
};
const plateOf = (a: NonNullable<LastMileRecord['vehicleAssignments']>[number]) =>
[a.vehicle?.code, a.vehicle?.plateNumber].filter(Boolean).join(' · ') || a.vehicleId;
/**
* View/override the detention clock (arrival + delivery/return) for a last-mile
* leg, preview the per-truck-per-day charge, and generate the detention invoice.
* Detention is PER TRUCK: every truck reaches the destination and is released at
* its own time, so each row carries its own clock, days and amount. Legs with no
* trucks assigned fall back to the single leg-level window.
*/
export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionModalProps) {
const { toast } = useToast();
const qc = useQueryClient();
const id = record?.id ?? null;
const assignments = record?.vehicleAssignments ?? [];
const perTruck = assignments.length > 0;
const [rows, setRows] = useState<TruckRow[]>([]);
// Leg-level fallback (no trucks assigned yet).
const [arrived, setArrived] = useState<Date | null>(null);
const [delivered, setDelivered] = useState<Date | null>(null);
useEffect(() => {
setRows(
assignments.map((a) => ({
vehicleId: a.vehicleId,
label: plateOf(a),
// Fall back to the leg-level pair so a truck without its own window
// shows what it is actually being billed on today.
arrived: a.destinationArrivedAt
? new Date(a.destinationArrivedAt)
: record?.arrivedAt
? new Date(record.arrivedAt)
: null,
returned: a.returnedAt
? new Date(a.returnedAt)
: record?.deliveredAt
? new Date(record.deliveredAt)
: null,
})),
);
setArrived(record?.arrivedAt ? new Date(record.arrivedAt) : null);
setDelivered(record?.deliveredAt ? new Date(record.deliveredAt) : null);
}, [record?.id, record?.arrivedAt, record?.deliveredAt, opened]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [record?.id, record?.arrivedAt, record?.deliveredAt, assignments.length, opened]);
const previewQuery = useQuery({
queryKey: ['truck-detention-preview', id],
@@ -65,19 +100,35 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM
enabled: opened && Boolean(id),
});
const preview = previewQuery.data;
// With several trucks the header rule is null by design (each truck resolves
// its own) — only warn when NO truck matched a rule.
const hasAnyRule = Boolean(preview?.ruleId) || (preview?.groups ?? []).some((g) => g.ruleId);
const byVehicle = new Map((preview?.groups ?? []).map((g) => [g.vehicleId ?? '', g]));
const saveTimes = useMutation({
mutationFn: () =>
lastMileService.update(id as string, {
arrivedAt: arrived ? arrived.toISOString() : null,
deliveredAt: delivered ? delivered.toISOString() : null,
}),
perTruck
? lastMileService.setDetentionTimes(
id as string,
rows.map((r) => ({
vehicleId: r.vehicleId,
destinationArrivedAt: r.arrived ? r.arrived.toISOString() : null,
returnedAt: r.returned ? r.returned.toISOString() : null,
})),
)
: lastMileService.update(id as string, {
arrivedAt: arrived ? arrived.toISOString() : null,
deliveredAt: delivered ? delivered.toISOString() : null,
}),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT });
void previewQuery.refetch();
toast({ title: 'Detention times saved' });
},
onError: () => toast({ title: 'Save failed', variant: 'destructive' }),
onError: (e: unknown) => {
const description = (e as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast({ title: 'Save failed', description, variant: 'destructive' });
},
});
const generate = useMutation({
@@ -93,12 +144,40 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM
},
});
const handleSave = () => {
const values = perTruck
? rows.flatMap((r) => [r.arrived, r.returned])
: [arrived, delivered];
// No backdating: detention times are recorded as they happen.
if (values.some((v) => isBackdated(v))) {
toast({ variant: 'destructive', title: 'Detention times cannot be in the past' });
return;
}
const reversed = perTruck
? rows.find((r) => r.arrived && r.returned && r.returned < r.arrived)
: arrived && delivered && delivered < arrived
? { label: 'this delivery' }
: undefined;
if (reversed) {
toast({
variant: 'destructive',
title: 'Return time is before arrival',
description: `Check the times for ${reversed.label}.`,
});
return;
}
saveTimes.mutate();
};
const patchRow = (vehicleId: string, patch: Partial<TruckRow>) =>
setRows((prev) => prev.map((r) => (r.vehicleId === vehicleId ? { ...r, ...patch } : r)));
return (
<Modal
opened={opened}
onClose={onClose}
centered
size="lg"
size="xl"
title={
<Text fw={700}>
Truck detention{record?.booking?.reference ? ` · ${record.booking.reference}` : ''}
@@ -106,40 +185,94 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM
}
>
<Stack gap="md">
<Group grow align="flex-start">
<DateTimePicker
label="Arrived at"
description="Detention clock start"
value={arrived}
onChange={(v) => setArrived(v ? new Date(v) : null)}
minDate={new Date()}
clearable
/>
<DateTimePicker
label="Delivered / returned at"
description="Clock end (blank = still out)"
value={delivered}
onChange={(v) => setDelivered(v ? new Date(v) : null)}
minDate={new Date()}
clearable
/>
</Group>
{perTruck ? (
<Stack gap="xs">
<Text size="sm" c="dimmed">
Each truck has its own detention clock record when it reached the destination and
when it was released. Days and charges are calculated per truck.
</Text>
{rows.map((r) => {
const g = byVehicle.get(r.vehicleId);
return (
<Paper key={r.vehicleId} withBorder p="sm" radius="md">
<Group justify="space-between" mb={6} wrap="nowrap">
<Group gap={8}>
<Text size="sm" fw={600}>
{r.label}
</Text>
{g?.vehicleType && (
<Badge size="xs" variant="light" color="gray">
{g.vehicleType}
</Badge>
)}
</Group>
{g && (
<Group gap={10} wrap="nowrap">
<Text size="xs" c={g.endIsOpen ? 'orange' : 'dimmed'}>
{g.chargeableDays} day{g.chargeableDays === 1 ? '' : 's'}
{g.endIsOpen ? ' · still out' : ''}
</Text>
<Text size="sm" fw={700}>
{money(g.amount, preview?.currency ?? 'USD')}
</Text>
</Group>
)}
</Group>
<Group grow align="flex-start">
<DateTimePicker
label="Arrived at destination"
description="Detention clock start"
value={r.arrived}
onChange={(v) => patchRow(r.vehicleId, { arrived: v ? new Date(v) : null })}
minDate={new Date()}
clearable
/>
<DateTimePicker
label="Released / returned at"
description="Clock end (blank = still out)"
value={r.returned}
onChange={(v) => patchRow(r.vehicleId, { returned: v ? new Date(v) : null })}
minDate={new Date()}
clearable
/>
</Group>
{g && !g.ruleId && (
<Text size="xs" c="red" mt={4}>
No detention rule matches this truck type it will not be billed.
</Text>
)}
</Paper>
);
})}
</Stack>
) : (
<>
<Text size="sm" c="dimmed">
No trucks assigned yet this records the delivery-level detention window. Assign
trucks to track each one separately.
</Text>
<Group grow align="flex-start">
<DateTimePicker
label="Arrived at"
description="Detention clock start"
value={arrived}
onChange={(v) => setArrived(v ? new Date(v) : null)}
minDate={new Date()}
clearable
/>
<DateTimePicker
label="Delivered / returned at"
description="Clock end (blank = still out)"
value={delivered}
onChange={(v) => setDelivered(v ? new Date(v) : null)}
minDate={new Date()}
clearable
/>
</Group>
</>
)}
<Group justify="flex-end">
<Button
variant="light"
loading={saveTimes.isPending}
onClick={() => {
// No backdating: detention times are recorded as they happen.
if (isBackdated(arrived) || isBackdated(delivered)) {
toast({
variant: 'destructive',
title: 'Detention times cannot be in the past',
});
return;
}
saveTimes.mutate();
}}
>
<Button variant="light" loading={saveTimes.isPending} onClick={handleSave}>
Save times
</Button>
</Group>
@@ -154,7 +287,7 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM
<Alert color="gray" variant="light">
No preview available.
</Alert>
) : !preview.ruleId ? (
) : !hasAnyRule ? (
<Alert color="orange" variant="light">
No active Truck Detention rule matches this booking. Create one under Warehouse Fee rules
(rule type "Truck Detention Cost").
@@ -162,39 +295,47 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM
) : (
<Stack gap="sm">
<Group grow>
<Stat label="Chargeable days" value={preview.chargeableDays} />
<Stat label="Longest detention" value={`${preview.chargeableDays} day(s)`} />
<Stat label="Trucks" value={preview.containerCount} />
<Stat label="Amount" value={money(preview.amount, preview.currency)} strong />
<Stat label="Total amount" value={money(preview.amount, preview.currency)} strong />
</Group>
{preview.endIsOpen && (
<Text size="xs" c="orange">
Still accruing no delivery/return time yet. The amount grows until the vehicle is returned.
Still accruing at least one truck has no release time yet. The amount grows until
every truck is returned.
</Text>
)}
{preview.groups && preview.groups.length > 1 ? (
{preview.groups && preview.groups.length > 0 ? (
<Table withTableBorder withColumnBorders verticalSpacing="xs" fz="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Truck type</Table.Th>
<Table.Th>Trucks</Table.Th>
<Table.Th>Truck</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Days</Table.Th>
<Table.Th ta="right">Rate / truck / day</Table.Th>
<Table.Th ta="right">Rate / day</Table.Th>
<Table.Th ta="right">Amount</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{preview.groups.map((g, i) => (
<Table.Tr key={i}>
<Table.Tr key={g.assignmentId ?? i}>
<Table.Td>
{g.vehicleType ?? 'Unknown'}
{g.plateNumber ?? 'Unassigned'}
{!g.ruleId && (
<Text span size="xs" c="red">
{' '}· no rule
</Text>
)}
</Table.Td>
<Table.Td>{g.truckCount}</Table.Td>
<Table.Td>{g.chargeableDays}</Table.Td>
<Table.Td>{g.vehicleType ?? 'Unknown'}</Table.Td>
<Table.Td>
{g.chargeableDays}
{g.endIsOpen && (
<Text span size="xs" c="orange">
{' '}· open
</Text>
)}
</Table.Td>
<Table.Td ta="right">{money(g.ratePerDay, preview.currency)}</Table.Td>
<Table.Td ta="right">{money(g.amount, preview.currency)}</Table.Td>
</Table.Tr>

View File

@@ -66,6 +66,19 @@ export const formatCell = (value: unknown, format?: ColumnFormat): ReactNode =>
);
}
if (format === "validityBadge") {
const status = String(value);
const label =
status === "VALID" ? "Valid" : status === "EXPIRED" ? "Expired" : "Not started";
const color =
status === "VALID" ? "edr-green" : status === "EXPIRED" ? "red" : "yellow";
return (
<Badge color={color} variant="filled" size="sm" radius="md">
{label}
</Badge>
);
}
if (format === "code") {
return (
<Badge

View File

@@ -41,7 +41,12 @@ export default function AvailableWagonsPanel({
const wagonsQuery = useQuery(
api.wagons.list.queryOptions({
input: {
filters: { status: Freight.WagonStatus.Available, currentYardId: yardId },
filters: {
status: Freight.WagonStatus.Available,
currentYardId: yardId,
// Loose wagons only — one already on another train cannot be coupled.
unassigned: true,
},
},
enabled: Boolean(yardId),
}),

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

@@ -34,12 +34,13 @@ import type {
} from "@/types/trainScheduling";
import { WindowPhasePill } from "./batchVisuals";
import { ForecastPanel } from "./ForecastPanel";
import { forecastIsLive } from "./batchForecast";
import { forecastIsLive, rankBookings } from "./batchForecast";
/**
* Priority Tracking tab — live, glanceable ranking of every booking on this
* schedule in the exact order the batch engine boards them (government first,
* then rule-engine priority score, then oldest). Bookings above the train's
* then window cycle — bookings compete only within their own cycle — then
* rule-engine priority score, then oldest). Bookings above the train's
* wagon-capacity line render as "selected" (green), below it as the waiting
* list; during the PAYMENT phase selected bookings show a live pay-window
* countdown. Purely presentational — data comes from the batch-board detail
@@ -286,19 +287,11 @@ export const PriorityTrackingTab = memo(function PriorityTrackingTab({
);
const showForecast = forecastAvailable && view === "forecast";
// Rank exactly as the batch engine does: government first, then priority score
// desc, then oldest (fullyExecutedAt / selectedForBatchAt as the tiebreak the
// backend uses). The board already returns them in this order, but re-sort
// defensively so the tab is correct even if the source order ever changes.
const ranked = useMemo(() => {
const time = (b: BatchBoardBookingDetail) =>
b.fullyExecutedAt ? new Date(b.fullyExecutedAt).getTime() : Number.MAX_SAFE_INTEGER;
return [...bookings].sort((a, b) => {
if (a.isGovernment !== b.isGovernment) return a.isGovernment ? -1 : 1;
if (b.priorityScore !== a.priorityScore) return b.priorityScore - a.priorityScore;
return time(a) - time(b);
});
}, [bookings]);
// Rank exactly as the batch engine does: government first, then window cycle
// (bookings only compete within the cycle they arrived in — an earlier cycle
// boards before a later one regardless of score), then priority desc, then
// oldest. Shared with the forecast sim so both views agree.
const ranked = useMemo(() => rankBookings(bookings), [bookings]);
const scoreMax = useMemo(() => maxScore(ranked), [ranked]);
// Wagon-slot cap from the board DTO (derived from train length and the
@@ -395,7 +388,8 @@ export const PriorityTrackingTab = memo(function PriorityTrackingTab({
<Stack gap={2}>
<Text fw={700}>Priority ranking</Text>
<Text size="xs" c="dimmed">
Government first, then rule-engine score, then earliest booked.
Government first, then booking window (earlier cycles board
first), then rule-engine score, then earliest booked.
</Text>
</Stack>
</Group>

View File

@@ -61,7 +61,12 @@ export interface ForecastResult {
full: boolean;
}
/** Engine rank order: government first, then priority desc, then oldest booked. */
/**
* Engine rank order: government first, then window cycle asc (bookings compete
* only within the cycle they arrived in — earlier cycles board first no matter
* the score; pending-contract rows sink last), then priority desc, then oldest
* booked.
*/
export function rankBookings(
bookings: BatchBoardBookingDetail[],
): BatchBoardBookingDetail[] {
@@ -69,8 +74,11 @@ export function rankBookings(
b.fullyExecutedAt
? new Date(b.fullyExecutedAt).getTime()
: Number.MAX_SAFE_INTEGER;
const cycle = (b: BatchBoardBookingDetail) =>
b.windowCycleNo ?? Number.MAX_SAFE_INTEGER;
return [...bookings].sort((a, b) => {
if (a.isGovernment !== b.isGovernment) return a.isGovernment ? -1 : 1;
if (cycle(a) !== cycle(b)) return cycle(a) - cycle(b);
if (b.priorityScore !== a.priorityScore)
return b.priorityScore - a.priorityScore;
return time(a) - time(b);

View File

@@ -142,7 +142,7 @@ export const TrainConsistView = ({
weightMax={trainSet?.locomotive?.maxPullWeightTons ?? null}
lengthUsed={lengthUsed}
lengthMax={trainSet?.locomotive?.maxTrainLengthMeters ?? null}
wagonCount={wagons.length}
wagonCount={loadedCount}
wagonMax={maxWagons}
/>

View File

@@ -80,7 +80,7 @@ import { MoveInventoryModal } from './MoveInventoryModal';
import { ReleaseOrderModal } from './ReleaseOrderModal';
import { StoreInventoryModal } from './StoreInventoryModal';
import { WarehouseInquiryTable } from './WarehouseInquiryTable';
import { extractDownloadErrorMessage, extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions } from './options';
import { extractDownloadErrorMessage, extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions, warehousesAtStation, yardsForBooking } from './options';
import { openPdfBlob } from './pdf';
import '@/components/overview/overview.css';
@@ -1978,14 +1978,6 @@ function LoadedExportTab({
);
}
const importLocationTypesForFreight = (freightType: string | null | undefined) => {
const normalized = (freightType ?? '').toUpperCase();
if (normalized === 'CONTAINER') {
return { yardTypes: ['CONTAINER_YARD', 'GENERAL_CARGO_YARD'], zoneTypes: ['CONTAINER_ZONE', 'GENERAL_CARGO_ZONE'] };
}
return { yardTypes: ['BULK_YARD', 'GENERAL_CARGO_YARD'], zoneTypes: ['BULK_ZONE', 'GENERAL_CARGO_ZONE'] };
};
const isImportContainerFreight = (freightType: string | null | undefined) =>
(freightType ?? '').toUpperCase() === 'CONTAINER';
@@ -2039,10 +2031,60 @@ function ImportTrainDetailTable({
enabled: Boolean(train.scheduleId),
}),
);
const warehouseOptions = useMemo(
() => warehouses.map((warehouse) => ({ value: warehouse.id, label: `${warehouse.name} (${warehouse.code})` })),
[warehouses],
// A train only ever unloads at the warehouse actually sitting at its
// destination station — Indode's train never offers Sebeta's warehouse.
const scopedWarehouses = useMemo(
() => warehousesAtStation(warehouses, train.destinationStationId),
[warehouses, train.destinationStationId],
);
const warehouseOptions = useMemo(
() => scopedWarehouses.map((warehouse) => ({ value: warehouse.id, label: `${warehouse.name} (${warehouse.code})` })),
[scopedWarehouses],
);
// With exactly one warehouse at the station there is nothing to choose —
// pre-fill it so staff only has to pick yard/zone, not re-discover Indode.
useEffect(() => {
if (scopedWarehouses.length !== 1) return;
const onlyWarehouseId = scopedWarehouses[0].id;
items.filter(isImportUnloadPending).forEach((item) => {
if (!assignments[item.bookingId]?.warehouseId) {
onAssignmentChange(item.bookingId, { ...assignments[item.bookingId], warehouseId: onlyWarehouseId });
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scopedWarehouses, items]);
// Once a booking's warehouse is known, its yard (and then zone) follow from
// what the cargo actually is — a Wheat booking only ever has one candidate
// yard (Dry Bulk) once Indode's real yard layout is configured, so staff
// never see a picker for something that isn't actually a choice.
useEffect(() => {
items.filter(isImportUnloadPending).forEach((item) => {
const draft = assignments[item.bookingId];
if (!draft?.warehouseId) return;
if (!draft.yardId) {
const candidateYards = yardsForBooking(yards, {
warehouseId: draft.warehouseId,
freightType: item.freightType,
tradeDirection: 'IMPORT',
cargoTypeCode: item.cargoTypeCode,
});
if (candidateYards.length === 1) {
onAssignmentChange(item.bookingId, { ...draft, yardId: candidateYards[0].id });
}
return;
}
if (!draft.zoneId) {
const candidateZones = zones.filter((zone) => zone.yardId === draft.yardId);
if (candidateZones.length === 1) {
onAssignmentChange(item.bookingId, { ...draft, zoneId: candidateZones[0].id });
}
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [assignments, items, yards, zones]);
useEffect(() => {
const pending = items.filter(isImportUnloadPending);
@@ -2093,12 +2135,17 @@ function ImportTrainDetailTable({
<Table.Tbody>
{items.map((it: ImportTrainItem) => {
const draft = assignments[it.bookingId] ?? {};
const { yardTypes, zoneTypes } = importLocationTypesForFreight(it.freightType);
const yardOptions = yards
.filter((yard) => yard.warehouseId === draft.warehouseId && yardTypes.includes(yard.type))
.map((yard) => ({ value: yard.id, label: `${yard.name} (${yard.code})` }));
const yardOptions = yardsForBooking(yards, {
warehouseId: draft.warehouseId,
freightType: it.freightType,
tradeDirection: 'IMPORT',
cargoTypeCode: it.cargoTypeCode,
}).map((yard) => ({ value: yard.id, label: `${yard.name} (${yard.code})` }));
// The yard is already scoped to what this cargo can go into — a
// zone's own type always matches its parent yard's purpose (see the
// Indode seed migration), so no separate zone-type filter is needed.
const zoneOptions = zones
.filter((zone) => zone.yardId === draft.yardId && zoneTypes.includes(zone.type))
.filter((zone) => zone.yardId === draft.yardId)
.map((zone) => ({ value: zone.id, label: `${zone.name} (${zone.code})` }));
const pending = isImportUnloadPending(it);

View File

@@ -0,0 +1,153 @@
import { describe, expect, it } from "vitest";
import { warehousesAtStation, yardsForBooking } from "./options";
import type { Warehouse, WarehouseYard } from "@/types/warehouse";
// Mirrors Indode's real 11-yard layout at a reduced scale, so these cases read
// against the actual booking-routing decisions staff rely on.
const yard = (overrides: Partial<WarehouseYard>): WarehouseYard =>
({
id: overrides.code,
warehouseId: "indode",
name: overrides.code,
code: overrides.code,
type: "GENERAL_CARGO_YARD",
capacityWeight: null,
capacityContainers: null,
maxWeight: null,
maxVolume: null,
currentWeight: 0,
currentContainers: 0,
currentVolume: 0,
status: "ACTIVE",
isActive: true,
...overrides,
}) as WarehouseYard;
const YARDS: WarehouseYard[] = [
yard({ code: "Y2", type: "GENERAL_CARGO_YARD", cargoTypes: [{ id: "1", code: "STEEL_BILLET" }] }),
yard({ code: "Y3", type: "GENERAL_CARGO_YARD", cargoTypes: [{ id: "2", code: "AUTOMOBILE" }, { id: "3", code: "TRUCK" }] }),
yard({ code: "Y4", type: "BULK_YARD", status: "INACTIVE", isActive: false, cargoTypes: [{ id: "4", code: "WHEAT" }] }),
yard({ code: "Y5", type: "CONTAINER_YARD", direction: "IMPORT" }),
yard({ code: "Y6", type: "CONTAINER_YARD", direction: "EXPORT" }),
yard({ code: "Y10", type: "CONTAINER_YARD", direction: "BOTH" }), // service yard
yard({ code: "Y11", type: "CONTAINER_YARD", direction: "BOTH" }), // equipment yard
];
describe("yardsForBooking", () => {
it("container import narrows to exactly the import stack", () => {
const result = yardsForBooking(YARDS, {
warehouseId: "indode",
freightType: "CONTAINER",
tradeDirection: "IMPORT",
cargoTypeCode: null,
});
expect(result.map((y) => y.code)).toEqual(["Y5"]);
});
it("container export narrows to exactly the export stack", () => {
const result = yardsForBooking(YARDS, {
warehouseId: "indode",
freightType: "CONTAINER",
tradeDirection: "EXPORT",
cargoTypeCode: null,
});
expect(result.map((y) => y.code)).toEqual(["Y6"]);
});
it("never offers a BOTH-direction container yard (service/equipment) for ordinary cargo", () => {
const result = yardsForBooking(YARDS, {
warehouseId: "indode",
freightType: "CONTAINER",
tradeDirection: "IMPORT",
cargoTypeCode: null,
});
expect(result.map((y) => y.code)).not.toContain("Y10");
expect(result.map((y) => y.code)).not.toContain("Y11");
});
it("bulk cargo narrows to the yard configured for that exact cargo type", () => {
const automobile = yardsForBooking(YARDS, {
warehouseId: "indode",
freightType: "BULK",
tradeDirection: "IMPORT",
cargoTypeCode: "AUTOMOBILE",
});
expect(automobile.map((y) => y.code)).toEqual(["Y3"]);
const steel = yardsForBooking(YARDS, {
warehouseId: "indode",
freightType: "BULK",
tradeDirection: "IMPORT",
cargoTypeCode: "STEEL_BILLET",
});
expect(steel.map((y) => y.code)).toEqual(["Y2"]);
});
it("falls back to every non-container yard when the one configured for this cargo type is closed", () => {
// Y4 (Dry Bulk, WHEAT) is inactive — never strand staff with an empty
// picker just because the ideal yard is closed; same safety net as
// warehousesAtStation falling back when a station has no mapped warehouse.
const result = yardsForBooking(YARDS, {
warehouseId: "indode",
freightType: "BULK",
tradeDirection: "IMPORT",
cargoTypeCode: "WHEAT",
});
expect(result.map((y) => y.code).sort()).toEqual(["Y2", "Y3"]);
});
it("falls back to every non-container yard when no yard is configured for that cargo type yet", () => {
const result = yardsForBooking(YARDS, {
warehouseId: "indode",
freightType: "BULK",
tradeDirection: "IMPORT",
cargoTypeCode: "SOMETHING_UNMAPPED",
});
expect(result.map((y) => y.code).sort()).toEqual(["Y2", "Y3"]);
});
it("a yard with no configured cargo types is open to anything (unconfigured, not restrictive)", () => {
const openYard = yard({ code: "GENERIC", type: "BULK_YARD" });
const result = yardsForBooking([...YARDS, openYard], {
warehouseId: "indode",
freightType: "BULK",
tradeDirection: "IMPORT",
cargoTypeCode: "STEEL_BILLET",
});
expect(result.map((y) => y.code).sort()).toEqual(["GENERIC", "Y2"]);
});
it("only offers yards at the requested warehouse", () => {
const otherWarehouseYard = yard({ code: "SEBETA-Y1", warehouseId: "sebeta", type: "GENERAL_CARGO_YARD" });
const result = yardsForBooking([...YARDS, otherWarehouseYard], {
warehouseId: "indode",
freightType: "BULK",
tradeDirection: "IMPORT",
cargoTypeCode: null,
});
expect(result.map((y) => y.code)).not.toContain("SEBETA-Y1");
});
});
describe("warehousesAtStation", () => {
const warehouse = (id: string, stationId: string | null): Warehouse =>
({ id, stationId, name: id, code: id } as Warehouse);
it("restricts to the warehouse at the given station", () => {
const warehouses = [warehouse("indode", "station-a"), warehouse("sebeta", "station-b")];
const result = warehousesAtStation(warehouses, "station-a");
expect(result.map((w) => w.id)).toEqual(["indode"]);
});
it("falls back to every warehouse when the station has no match", () => {
const warehouses = [warehouse("indode", "station-a"), warehouse("sebeta", "station-b")];
const result = warehousesAtStation(warehouses, "station-unknown");
expect(result).toEqual(warehouses);
});
it("falls back to every warehouse when the station is null", () => {
const warehouses = [warehouse("indode", "station-a")];
expect(warehousesAtStation(warehouses, null)).toEqual(warehouses);
});
});

View File

@@ -4,6 +4,8 @@ import {
WAREHOUSE_ZONE_TYPES,
WAREHOUSE_STATUSES,
INVENTORY_STATUSES,
type Warehouse,
type WarehouseYard,
} from '@/types/warehouse';
export const humanizeEnum = (value: string) =>
@@ -16,6 +18,58 @@ export const humanizeEnum = (value: string) =>
const toOptions = (values: readonly string[]) =>
values.map((value) => ({ value, label: humanizeEnum(value) }));
/**
* Warehouses actually located at a train's station — e.g. a train destined for
* Indode should only offer Indode's own warehouse, not Sebeta's or Modjo's.
* Falls back to every warehouse when the station is unmapped (no `stationId`
* match anywhere), so unusual/legacy data never blocks the unload flow entirely.
*/
export const warehousesAtStation = (warehouses: Warehouse[], stationId: string | null | undefined) => {
if (!stationId) return warehouses;
const atStation = warehouses.filter((w) => w.stationId === stationId);
return atStation.length ? atStation : warehouses;
};
/**
* Yards at ONE warehouse eligible to receive a booking, given what it actually
* is — e.g. at Indode: container import always narrows to Yard 5, export to
* Yard 6; a Wheat booking narrows to Yard 4 (Dry Bulk), not Break Bulk or
* Coffee/Tea. Mirrors `warehousesAtStation`'s fallback philosophy: an
* unconfigured yard (no cargo types set) stays open rather than disappearing,
* but a yard that IS configured for other cargo never shows for a mismatch.
*
* Container yards are the one case with no such fallback: a CONTAINER_YARD
* left at direction BOTH/null (Indode's Yard 10 service yard, Yard 11
* equipment yard) is a service/equipment yard, not a customer cargo yard, and
* must never be offered just because the exact-direction stack is missing.
*/
export const yardsForBooking = (
yards: WarehouseYard[],
params: {
warehouseId: string | null | undefined;
freightType: string | null | undefined;
tradeDirection: string | null | undefined;
cargoTypeCode: string | null | undefined;
},
): WarehouseYard[] => {
const atWarehouse = yards.filter((y) => y.warehouseId === params.warehouseId && y.isActive);
const isContainer = (params.freightType ?? '').toUpperCase() === 'CONTAINER';
if (isContainer) {
const direction = (params.tradeDirection ?? '').toUpperCase();
return atWarehouse.filter((y) => y.type === 'CONTAINER_YARD' && y.direction === direction);
}
const nonContainer = atWarehouse.filter((y) => y.type !== 'CONTAINER_YARD');
if (!params.cargoTypeCode) return nonContainer;
const cargoMatched = nonContainer.filter((y) => {
const codes = (y.cargoTypes ?? []).map((c) => c.code);
return codes.length === 0 || codes.includes(params.cargoTypeCode as string);
});
return cargoMatched.length ? cargoMatched : nonContainer;
};
export const warehouseTypeOptions = toOptions(WAREHOUSE_TYPES);
export const yardTypeOptions = toOptions(WAREHOUSE_YARD_TYPES);
export const zoneTypeOptions = toOptions(WAREHOUSE_ZONE_TYPES);

View File

@@ -144,6 +144,12 @@ export const URL_CONSTANTS = {
CLEARANCE_DECLARATION: (id: string) =>
`/bookings/${id}/clearance/declaration`,
CLEARANCE_DUTY: (id: string) => `/bookings/${id}/clearance/duty`,
CLEARANCE_DRAFT_DECLARATION: (id: string) =>
`/bookings/${id}/clearance/draft-declaration`,
CLEARANCE_TRANSIT_ASSIGNEE_REQUEST: (id: string) =>
`/bookings/${id}/clearance/transit-assignee/request`,
CLEARANCE_TRANSIT_ASSIGNEE_ASSIGN: (id: string) =>
`/bookings/${id}/clearance/transit-assignee/assign`,
CLEARANCE_FINALIZE_PRE: (id: string) =>
`/bookings/${id}/clearance/finalize-pre-clearance`,
CLEARANCE_TRANSIT_PERMIT: (id: string) =>
@@ -162,6 +168,13 @@ export const URL_CONSTANTS = {
CLEARANCE_DJ_QUEUE: "/bookings/clearance/dj-queue",
},
// GL Ethiopia ↔ GL Djibouti document exchange, keyed by the booking or
// contract both desks are working on.
GL_EXCHANGE: {
FOR_ENTITY: (entityId: string) => `/gl-exchange/${entityId}`,
DOCUMENT: (documentId: string) => `/gl-exchange/documents/${documentId}`,
},
CONTRACTS: {
BASE: "/contracts",
LIST_SUMMARY: "/contracts/list-summary",
@@ -170,6 +183,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 +232,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 +240,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`,
@@ -461,6 +476,10 @@ export const URL_CONSTANTS = {
APPROVAL_RULE_BY_ID: (id: string) => `/approval-rules/${id}`,
APPROVAL_RULES_CHAIN: "/approval-rules/chain",
APPROVAL_RULES_POSITION_TYPES: "/approval-rules/position-types",
TRANSIT_AGENTS: "/transit-agents",
TRANSIT_AGENT_BY_ID: (id: string) => `/transit-agents/${id}`,
TRANSIT_AGENTS_ASSIGNABLE: "/transit-agents/assignable",
},
RATE_MATRIX: {
BASE: "/api/rate-matrices",

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.",
@@ -257,6 +270,41 @@ export const CONTRACT_STATUS_META: Record<string, StatusMeta> = {
},
};
/** Statuses where the next action sits with the customer (portal side). */
const WITH_CUSTOMER_STATUSES = new Set([
"DRAFT",
"PRICE_CHANGED_PENDING_CONFIRM",
"CHANGES_REQUESTED",
"CONTRACT_READY", // generated contract awaits the customer's signature
"AWAITING_CLEARANCE_DOCUMENTS",
"RENEWAL_DRAFT",
]);
/** Statuses where the next action sits with EDR staff. */
const WITH_EDR_STATUSES = new Set([
"SUBMITTED",
"PENDING_APPROVAL",
"APPROVED",
"APPROVED_PENDING_SIGNATURE",
"SIGNED_CUSTOMER",
"CLEARANCE_UNDER_REVIEW",
"CLEARANCE_READY_FOR_BOOKING",
"RENEWAL_SUBMITTED",
"RENEWAL_PENDING_APPROVAL",
]);
/**
* Whose court the contract is in. Null for states with no pending party
* (active, closed, rejected…).
*/
export function contractCourt(
status: ContractStatus | string,
): "customer" | "edr" | null {
if (WITH_CUSTOMER_STATUSES.has(status)) return "customer";
if (WITH_EDR_STATUSES.has(status)) return "edr";
return null;
}
export const CONTRACT_LIST_TABS = [
{ key: "all", label: "All contracts", statuses: null as string[] | null },
{

View File

@@ -53,30 +53,9 @@ export function useContractClearanceQueue(enabled = true) {
});
}
export function useEtClearanceQueue(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(),
enabled,
});
}
// The awaiting-shipment worklist hook was removed with the clearance hub's
// "Start shipment" dialog — nothing calls GET /contracts/awaiting-shipment any
// more. The endpoint still exists server-side if the worklist comes back.
export function useContractClearanceHistory(enabled = true) {
return useQuery({
@@ -172,6 +151,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 +269,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

@@ -6,6 +6,7 @@ import {
LayoutGrid,
Milestone,
Package,
Truck,
} from "lucide-react";
import {
Container,
@@ -36,6 +37,7 @@ import {
BookingContractSummaryCard,
BookingContainerUnitsCard,
BookingDocumentsPanel,
BookingTrucksPanel,
ContractOrdersPanel,
} from "@/components/bookings/detail";
import { WarehouseInfoCard } from "@/components/warehouses";
@@ -141,7 +143,9 @@ export default function BookingRequestDetailPage() {
? "orders"
: requestedTab === "documents"
? "documents"
: "overview";
: requestedTab === "trucks"
? "trucks"
: "overview";
const setActiveTab = (tab: string | null) => {
const next = new URLSearchParams(searchParams);
if (tab && tab !== "overview") next.set("tab", tab);
@@ -207,6 +211,9 @@ export default function BookingRequestDetailPage() {
>
Documents
</Tabs.Tab>
<Tabs.Tab value="trucks" leftSection={<Truck size={16} />}>
Trucks
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="overview">
@@ -223,6 +230,9 @@ export default function BookingRequestDetailPage() {
<Tabs.Panel value="documents">
<BookingDocumentsPanel bookingId={booking.id} />
</Tabs.Panel>
<Tabs.Panel value="trucks">
<BookingTrucksPanel bookingId={booking.id} />
</Tabs.Panel>
</Tabs>
</Grid.Col>

View File

@@ -1,6 +1,6 @@
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { useNavigate, useParams } from "react-router-dom";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import {
Alert,
Badge,
@@ -50,9 +50,20 @@ export default function DocumentClearanceDetailPage() {
const params = useParams<{ id?: string; bookingId?: string }>();
const id = params.id ?? params.bookingId;
const navigate = useNavigate();
const location = useLocation();
const { user } = useAuth();
const { view, viewer } = useFileViewer();
// The same shipment is opened from several worklists (GL Ethiopia clearance,
// the Operations clearance-documents hub, shipment requests…), so "back" is
// whichever list sent us here. Deep links have no sender: fall back to the
// hub this user actually works in.
const backTo =
(location.state as { from?: string } | null)?.from ??
(hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions)
? "/dashboard/contracts/clearance"
: "/dashboard/contracts/clearance-documents");
const { data: booking } = useBookingDetail(id);
const {
data: clearance,
@@ -95,10 +106,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.
@@ -147,9 +158,9 @@ export default function DocumentClearanceDetailPage() {
<PageContainer>
<PageHeader
title="Clearance not found"
backTo="/dashboard/contracts/clearance"
backTo={backTo}
breadcrumbs={[
{ label: "Document Clearance", href: "/dashboard/contracts/clearance" },
{ label: "Document Clearance", href: backTo },
{ label: "Not found" },
]}
/>
@@ -165,9 +176,9 @@ export default function DocumentClearanceDetailPage() {
<Stack gap="lg">
<PageHeader
title={reference}
backTo="/dashboard/contracts/clearance"
backTo={backTo}
breadcrumbs={[
{ label: "Document Clearance", href: "/dashboard/contracts/clearance" },
{ label: "Document Clearance", href: backTo },
{ label: reference },
]}
meta={
@@ -243,6 +254,7 @@ export default function DocumentClearanceDetailPage() {
milestones={bookingMilestones}
showOpsTabs={Boolean(id)}
showWorkflowFilesTab={isPhasedGeneral}
exchangeEntityId={id}
tradeDirection={booking?.tradeDirection ?? "IMPORT"}
workflowFiles={workflowFiles}
onViewFile={view}

View File

@@ -1,6 +1,6 @@
import { useCallback, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { useLocation, useNavigate } from "react-router-dom";
import {
ActionIcon,
Badge,
@@ -139,6 +139,7 @@ export default function DocumentClearanceListPage({
opsMode?: boolean;
}) {
const navigate = useNavigate();
const location = useLocation();
const [pageTab, setPageTab] = useState<PageTab>("queue");
const [activeTab, setActiveTab] = useState<ClearanceTabKey>("all");
const [query, setQuery] = useState("");
@@ -205,8 +206,13 @@ export default function DocumentClearanceListPage({
}, [rows, pagination.pageIndex, pagination.pageSize]);
const openDetail = useCallback(
(id: string) => navigate(`/dashboard/clearance/${id}`),
[navigate],
// `from` so the detail page's Back returns to this list, whichever route
// it is mounted at (ops self-clearance review, history, …).
(id: string) =>
navigate(`/dashboard/clearance/${id}`, {
state: { from: location.pathname },
}),
[navigate, location.pathname],
);
const statusBadge = isHistory ? (

View File

@@ -1,4 +1,3 @@
import { directionLabel } from "@/lib/utils";
import {
ActionIcon,
Box,
@@ -6,36 +5,22 @@ import {
Group,
Select,
Stack,
Tabs,
Text,
TextInput,
ThemeIcon,
} from "@mantine/core";
import { DateInput } from "@mantine/dates";
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 { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink";
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 +31,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",
@@ -95,17 +51,46 @@ const BOOKING_STATUS_OPTIONS = [
{ value: "CLEARANCE_READY", label: "Clearance ready" },
];
const TRADE_DIRECTION_OPTIONS = [
{ value: "IMPORT", label: "Import" },
{ value: "EXPORT", label: "Export" },
{ value: "DOMESTIC", label: "Domestic" },
];
const FREIGHT_TYPE_OPTIONS = [
{ value: "CONTAINER", label: "Container" },
{ value: "BULK", label: "Bulk" },
];
const OWNERSHIP_OPTIONS = [
{ value: "true", label: "Government" },
{ value: "false", label: "Private" },
];
function startOfDayIso(d: Date): string {
const x = new Date(d);
x.setHours(0, 0, 0, 0);
return x.toISOString();
}
function endOfDayIso(d: Date): string {
const x = new Date(d);
x.setHours(23, 59, 59, 999);
return x.toISOString();
}
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,
);
const [directionFilter, setDirectionFilter] = useState<string | null>(null);
const [freightTypeFilter, setFreightTypeFilter] = useState<string | null>(null);
const [ownershipFilter, setOwnershipFilter] = useState<string | null>(null);
const [createdFrom, setCreatedFrom] = useState<Date | null>(null);
const [createdTo, setCreatedTo] = useState<Date | null>(null);
const { pagination, setPagination } = usePagination({ pageSize: PAGE_SIZE });
const search = debouncedQuery.trim() || undefined;
@@ -116,147 +101,41 @@ export default function ClearanceDocumentsPage() {
const page = pagination.pageIndex + 1;
const contractsQuery = useQuery({
const bookingsQuery = useQuery({
queryKey: [
"clearance-documents",
"contracts",
contractStatuses,
"bookings",
bookingStatuses,
directionFilter,
freightTypeFilter,
ownershipFilter,
createdFrom,
createdTo,
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",
page,
pageSize: PAGE_SIZE,
search,
...(directionFilter ? { tradeDirection: directionFilter } : {}),
...(freightTypeFilter ? { freightType: freightTypeFilter } : {}),
...(ownershipFilter
? { isGovernment: ownershipFilter as "true" | "false" }
: {}),
...(createdFrom ? { createdFrom: startOfDayIso(createdFrom) } : {}),
...(createdTo ? { createdTo: endOfDayIso(createdTo) } : {}),
}),
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(
() => [
@@ -289,9 +168,18 @@ export default function ClearanceDocumentsPage() {
{
id: "contractRef",
header: () => <span className={bookingTable.headerCell}>Contract</span>,
cell: ({ row }) => (
<Text size="sm">{row.original.contractReference ?? "—"}</Text>
),
cell: ({ row }) => {
const b = row.original;
return b.contractId && b.contractReference ? (
<ContractReferenceLink
contractId={b.contractId}
contractReference={b.contractReference}
className="block truncate text-sm text-foreground underline underline-offset-2 hover:text-muted-foreground"
/>
) : (
<Text size="sm"></Text>
);
},
},
{
id: "shipment",
@@ -335,39 +223,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 +253,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 +285,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}
@@ -439,6 +300,73 @@ export default function ClearanceDocumentsPage() {
{total} record{total !== 1 ? "s" : ""}
</Text>
</Group>
<Group gap="sm" mt="sm" wrap="wrap">
<Select
placeholder="Direction"
data={TRADE_DIRECTION_OPTIONS}
value={directionFilter}
onChange={(v) => {
setDirectionFilter(v);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 130 }}
aria-label="Filter by direction"
/>
<Select
placeholder="Freight type"
data={FREIGHT_TYPE_OPTIONS}
value={freightTypeFilter}
onChange={(v) => {
setFreightTypeFilter(v);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 140 }}
aria-label="Filter by freight type"
/>
<Select
placeholder="Gov / Private"
data={OWNERSHIP_OPTIONS}
value={ownershipFilter}
onChange={(v) => {
setOwnershipFilter(v);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 140 }}
aria-label="Filter by ownership"
/>
<DateInput
placeholder="Created from"
value={createdFrom}
onChange={(v) => {
setCreatedFrom(v ? new Date(v) : null);
resetPage();
}}
maxDate={createdTo ?? undefined}
clearable
radius="lg"
style={{ minWidth: 140 }}
aria-label="Created from"
/>
<DateInput
placeholder="Created to"
value={createdTo}
onChange={(v) => {
setCreatedTo(v ? new Date(v) : null);
resetPage();
}}
minDate={createdFrom ?? undefined}
clearable
radius="lg"
style={{ minWidth: 140 }}
aria-label="Created to"
/>
</Group>
</Box>
{showEmpty ? (
@@ -446,61 +374,36 @@ 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}
// `from` so the detail page's Back returns to THIS hub, not
// to whichever worklist the fallback would guess.
onRowClick={(row) =>
navigate(`/dashboard/clearance/${row.id}`, {
state: { from: "/dashboard/contracts/clearance-documents" },
})
}
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

@@ -352,6 +352,7 @@ export default function ContractClearanceDetailPage() {
milestones={bookingMilestones}
showOpsTabs={Boolean(linkedBookingId)}
showWorkflowFilesTab={phasedCustoms}
exchangeEntityId={id}
tradeDirection={contract?.tradeDirection ?? "IMPORT"}
workflowFiles={workflowFiles}
onViewFile={view}

View File

@@ -1,12 +1,4 @@
import { directionLabel } from "@/lib/utils";
import {
Fragment,
useCallback,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { useCallback, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
ActionIcon,
@@ -16,7 +8,6 @@ import {
Card,
Group,
Menu,
SegmentedControl,
Stack,
Text,
TextInput,
@@ -26,38 +17,32 @@ import {
import {
ArrowRight,
Calendar,
ChevronRight,
ExternalLink,
Eye,
FileText,
Inbox,
LayoutGrid,
MoreHorizontal,
PackageCheck,
PackagePlus,
RefreshCw,
Search,
ShieldCheck,
ShipWheel,
Table as TableIcon,
Truck,
User,
X,
} from "lucide-react";
import {
DataTable,
DataTableFooter,
usePagination,
type ColumnDef,
} from "@edr/ui-common";
import type { Freight } from "@edr/types";
import { useQuery } from "@tanstack/react-query";
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 { useAuth } from "@/auth/useAuth";
import { useContractClearanceQueue } from "@/hooks/contracts/useContracts";
import { useBookingEtClearanceQueue } from "@/hooks/bookings/useBookings";
import type { BookingDetail } from "@/types/booking";
import { FREIGHT_PERMS, hasPermission, isDjiboutiGl } from "@/lib/permissions";
@@ -67,99 +52,6 @@ import {
summarizeRequestedCargo,
} from "@/features/clearance/requestedCargo";
import { contractsService } from "@/services/contracts.service";
import { useQuery } from "@tanstack/react-query";
type ViewMode = "table" | "cards";
type QueueTab = "all" | "shipments";
/** Persist the selected queue tab so returning from a detail keeps it. */
const QUEUE_TAB_STORAGE_KEY = "edr.clearance.queueTab";
interface ClearanceRow {
id: string;
reference: string;
customerLabel: string;
tradeDirection: string;
freightType: string;
originLabel: string;
destinationLabel: string;
/** Full ordered corridor across the contract's route legs (origin → … → destination). */
routeStops: string[];
contractKind: string;
serviceTypeName: string;
customs: boolean;
status: string;
/** true once GL has finalized clearance — customer may book in the portal. */
ready: boolean;
/** true once GL Ethiopia created the shipment booking. */
bookingCreated: boolean;
/** true when the created booking EXPIRED unpaid — GL must rebook. */
paymentExpired: boolean;
/** The expired booking, so rebook can copy its cargo. */
expiredBookingId: string | null;
}
function yardLabel(
yard?: { label?: string; code?: string; name?: string } | null,
fallback = "—",
): string {
if (!yard) return fallback;
return yard.label ?? yard.name ?? yard.code ?? fallback;
}
/**
* Chain the contract's ordered route legs into one corridor of stops —
* origin of the first leg, then each leg's destination (Djibouti → Adama →
* Dire Dawa). A leg whose origin differs from the previous destination inserts
* that stop too, so gapped route lists stay readable.
*/
function contractRouteStops(routes: Freight.IContractRoute[]): string[] {
const stops: string[] = [];
for (const r of routes) {
const origin = yardLabel(r.originYard);
const destination = yardLabel(r.destinationYard);
if (stops.length === 0 || stops[stops.length - 1] !== origin) {
stops.push(origin);
}
stops.push(destination);
}
return stops;
}
function toClearanceRow(contract: Freight.IContract): ClearanceRow {
const routes = [...(contract.routes ?? [])].sort(
(a, b) => a.sortOrder - b.sortOrder,
);
const first = routes[0];
const last = routes[routes.length - 1] ?? first;
return {
id: contract.id,
reference: contract.reference,
// The queue joins the company relation — show its name, never the raw uuid.
customerLabel: contract.isGovernment
? (contract.governmentInstitution ?? "Government")
: (contract.company?.name ?? "—"),
tradeDirection: contract.tradeDirection ?? "—",
freightType: contract.freightType ?? "—",
originLabel: yardLabel(first?.originYard),
destinationLabel: yardLabel(last?.destinationYard),
routeStops: contractRouteStops(routes),
contractKind: contract.contractKind,
serviceTypeName: contract.serviceType?.serviceName ?? "—",
customs:
contract.serviceType?.includesCustoms ?? contract.customsClearingEnabled,
status: contract.status,
ready: contract.status === "CLEARANCE_READY_FOR_BOOKING",
bookingCreated: contract.status === "ACTIVE_SHIPMENT_IN_PROGRESS",
paymentExpired:
contract.status === "ACTIVE_SHIPMENT_IN_PROGRESS" &&
contract.latestCycleBookingStatus === "EXPIRED",
expiredBookingId:
contract.latestCycleBookingStatus === "EXPIRED"
? (contract.latestCycleBookingId ?? null)
: null,
};
}
function CustomsBadge({ customs }: { customs: boolean }) {
return customs ? (
@@ -179,199 +71,32 @@ function CustomsBadge({ customs }: { customs: boolean }) {
);
}
function DirectionIcon({ direction }: { direction: string }) {
const isImport = direction === "IMPORT";
const Icon = isImport ? Truck : ShipWheel;
const label = directionLabel(direction);
return (
<Tooltip label={label} withArrow>
<ThemeIcon
variant="light"
color={isImport ? "edr-green" : "gray"}
radius="md"
size={28}
aria-label={label}
>
<Icon size={15} strokeWidth={1.9} />
</ThemeIcon>
</Tooltip>
);
}
function StatusBadge({ row }: { row: ClearanceRow }) {
// Terminal contracts stay listed as history — badge the terminal state
// instead of falling through to "Under review".
if (["EXPIRED", "CANCELLED", "REJECTED"].includes(row.status)) {
return (
<Tooltip
label="This contract is no longer active — kept here for clearance history."
withArrow
>
<Badge
size="sm"
variant="light"
color={row.status === "EXPIRED" ? "orange" : "red"}
radius="sm"
>
{row.status === "EXPIRED"
? "Contract expired"
: row.status === "CANCELLED"
? "Cancelled"
: "Rejected"}
</Badge>
</Tooltip>
);
}
if (row.paymentExpired) {
return (
<Tooltip
label="The customer did not pay in time — the booking expired. GL rebooks on the customer's behalf."
withArrow
>
<Badge
size="sm"
variant="light"
color="orange"
radius="sm"
leftSection={<RefreshCw size={12} />}
>
Payment expired
</Badge>
</Tooltip>
);
}
if (row.bookingCreated) {
return (
<Tooltip label="GL Ethiopia created the shipment booking" withArrow>
<Badge
size="sm"
variant="light"
color="blue"
radius="sm"
leftSection={<PackagePlus size={12} />}
>
Booking created
</Badge>
</Tooltip>
);
}
if (row.ready) {
return (
<Tooltip
label="Document approval finalized — the customer creates the booking in the portal"
withArrow
>
<Badge
size="sm"
variant="light"
color="edr-green"
radius="sm"
leftSection={<PackageCheck size={12} />}
>
Documents approved
</Badge>
</Tooltip>
);
}
return (
<Badge size="sm" variant="light" color="yellow" radius="sm">
Under review
</Badge>
);
}
/**
* Document Clearance hub. Lists every customs (Path B) contract in phased clearance,
* including after booking is created — stays visible for reference and follow-up.
* Document Clearance hub (GL Ethiopia). Clearance always runs on the SHIPMENT:
* every row here is a booking instance in phased customs clearance, whatever
* kind of contract it draws on. The "Start shipment" dialog (and its
* awaiting-shipment contract list) was removed — shipments are opened from the
* contract itself, not from this hub.
*/
export default function ContractClearanceListPage() {
const navigate = useNavigate();
const { user } = useAuth();
const canReview = hasPermission(user, FREIGHT_PERMS.contracts.clearanceReview);
const canEt = hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions);
// Creating a booking under a cleared contract is a GL Ethiopia action — never
// Opening/creating a booking under a contract is a GL Ethiopia action — never
// available to Djibouti GL.
const canCreateBooking =
hasPermission(user, FREIGHT_PERMS.contracts.createBooking) &&
!isDjiboutiGl(user);
const defaultQueue: QueueTab = canReview ? "all" : "shipments";
const [queueTab, setQueueTab] = useState<QueueTab>(() => {
const stored =
typeof window !== "undefined"
? window.localStorage.getItem(QUEUE_TAB_STORAGE_KEY)
: null;
return stored === "all" || stored === "shipments" ? stored : defaultQueue;
});
const [query, setQuery] = useState("");
const [view, setView] = useState<ViewMode>("table");
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const selectQueueTab = useCallback((tab: QueueTab) => {
setQueueTab(tab);
if (typeof window !== "undefined") {
window.localStorage.setItem(QUEUE_TAB_STORAGE_KEY, tab);
}
}, []);
// Contract clearance rows feed both the Contracts tab and the header KPIs, so
// they load regardless of the active tab.
const { data: allData, isLoading: allLoading, isError: allError, isFetching: allFetching, refetch: refetchAll } =
useContractClearanceQueue(true);
const {
data: bookingQueue,
isLoading: bookingsLoading,
isError: bookingsError,
isFetching: bookingsFetching,
refetch: refetchBookings,
} = useBookingEtClearanceQueue(queueTab === "shipments");
const data = allData;
const isLoading = queueTab === "shipments" ? bookingsLoading : allLoading;
const isError = queueTab === "shipments" ? bookingsError : allError;
const isFetching = queueTab === "shipments" ? bookingsFetching : allFetching;
const refetch = () => {
if (queueTab === "shipments") void refetchBookings();
else void refetchAll();
};
const queueTabOptions = useMemo(() => {
const opts: { value: QueueTab; label: ReactNode }[] = [];
if (canReview) {
opts.push({
value: "all",
label: (
<Group gap={6} wrap="nowrap">
<FileText size={15} />
<Box visibleFrom="sm">Contracts</Box>
</Group>
),
});
}
if (canReview || canEt) {
opts.push({
value: "shipments",
label: (
<Group gap={6} wrap="nowrap">
<PackageCheck size={15} />
<Box visibleFrom="sm">Shipments</Box>
</Group>
),
});
}
return opts;
}, [canReview, canEt]);
// If a persisted/default tab isn't available for this user, fall back to the
// first permitted tab.
useEffect(() => {
if (
queueTabOptions.length > 0 &&
!queueTabOptions.some((o) => o.value === queueTab)
) {
selectQueueTab(queueTabOptions[0].value);
}
}, [queueTabOptions, queueTab, selectQueueTab]);
isLoading,
isError,
isFetching,
refetch,
} = useBookingEtClearanceQueue(true);
// Shipment requests carry the requested quantities (per container type, or
// bulk weight/items). Map them onto the booking rows by createdBookingId so
@@ -379,7 +104,6 @@ export default function ContractClearanceListPage() {
const { data: requestQueue } = useQuery({
queryKey: ["shipment-request-queue"],
queryFn: () => contractsService.getBookingRequestQueue(),
enabled: queueTab === "shipments",
});
const requestedByBooking = useMemo(() => {
const map = new Map<string, Freight.RequestedShipmentLines>();
@@ -389,9 +113,8 @@ export default function ContractClearanceListPage() {
return map;
}, [requestQueue]);
// GENERAL-contract shipment bookings in per-booking clearance.
const bookingRows = useMemo(() => {
const rows: ShipmentBookingRow[] = (bookingQueue ?? []).map((b: BookingDetail) => ({
const allRows = useMemo(() => {
return (bookingQueue ?? []).map((b: BookingDetail) => ({
id: b.id,
reference: b.reference,
customerLabel: b.company?.name ?? b.governmentInstitution ?? "—",
@@ -406,37 +129,11 @@ export default function ContractClearanceListPage() {
contractKind: b.contractKind ?? null,
customs: b.serviceType?.includesCustoms ?? Boolean(b.customsClearingEnabled),
createdAt: b.createdAt ?? null,
// A bare initiated instance has no cargo/price yet — GL still has to create
// (complete) the booking.
// A bare initiated instance has no cargo/price yet — GL still has to
// create (complete) the booking.
bookingCreated: Number(b.totalAmount ?? 0) > 0,
}));
const q = query.trim().toLowerCase();
if (!q) return rows;
return rows.filter(
(r) =>
r.reference.toLowerCase().includes(q) ||
r.customerLabel.toLowerCase().includes(q) ||
(r.contractReference ?? "").toLowerCase().includes(q) ||
r.originLabel.toLowerCase().includes(q) ||
r.destinationLabel.toLowerCase().includes(q) ||
summarizeRequestedCargo(r.requested).toLowerCase().includes(q),
);
}, [bookingQueue, query, requestedByBooking]);
const allRows = useMemo(
() => (data?.items ?? []).map(toClearanceRow),
[data?.items],
);
const counts = useMemo(
() => ({
all: allRows.length,
ready: allRows.filter((r) => r.ready).length,
booked: allRows.filter((r) => r.bookingCreated).length,
review: allRows.filter((r) => !r.ready && !r.bookingCreated).length,
}),
[allRows],
);
})) as ShipmentBookingRow[];
}, [bookingQueue, requestedByBooking]);
const rows = useMemo(() => {
const q = query.trim().toLowerCase();
@@ -445,163 +142,33 @@ export default function ContractClearanceListPage() {
(r) =>
r.reference.toLowerCase().includes(q) ||
r.customerLabel.toLowerCase().includes(q) ||
(r.contractReference ?? "").toLowerCase().includes(q) ||
r.originLabel.toLowerCase().includes(q) ||
r.destinationLabel.toLowerCase().includes(q),
r.destinationLabel.toLowerCase().includes(q) ||
summarizeRequestedCargo(r.requested).toLowerCase().includes(q),
);
}, [allRows, query]);
const total = queueTab === "shipments" ? bookingRows.length : rows.length;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const pagedRows = useMemo(() => {
const start = pagination.pageIndex * pagination.pageSize;
return rows.slice(start, start + pagination.pageSize);
}, [rows, pagination.pageIndex, pagination.pageSize]);
const openDetail = useCallback(
(id: string) => navigate(`/dashboard/contracts/clearance/${id}`),
[navigate],
const counts = useMemo(
() => ({
all: allRows.length,
review: allRows.filter(
(r) => r.status === "AWAITING_DOCUMENTS" || r.status === "DOCUMENTS_UNDER_REVIEW",
).length,
ready: allRows.filter((r) => r.status === "CLEARANCE_READY" || r.bookingCreated)
.length,
}),
[allRows],
);
const columns: ColumnDef<ClearanceRow>[] = 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}>
<ShieldCheck 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 (
<Stack gap={4} py={2}>
<Group gap={6} wrap="wrap">
{(r.routeStops.length >= 2
? r.routeStops
: [r.originLabel, r.destinationLabel]
).map((stop, i) => (
<Fragment key={i}>
{i > 0 ? (
<ArrowRight
size={14}
className="shrink-0 text-muted-foreground"
/>
) : null}
<Text size="sm" fw={500}>
{stop}
</Text>
</Fragment>
))}
</Group>
<Group gap={8} align="center">
<DirectionIcon direction={r.tradeDirection} />
<Badge size="xs" variant="default" radius="sm">
{r.freightType}
</Badge>
</Group>
</Stack>
);
},
},
{
id: "kind",
header: () => <span className={bookingTable.headerCell}>Kind</span>,
cell: ({ row }) => (
<Badge size="xs" variant="default" radius="sm" tt="uppercase">
{row.original.contractKind === "GENERAL" ? "General" : "One-time"}
</Badge>
),
},
{
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>
<CustomsBadge customs={r.customs} />
</Stack>
);
},
},
{
id: "status",
header: () => <span className={bookingTable.headerCell}>Status</span>,
cell: ({ row }) => <StatusBadge row={row.original} />,
},
{
id: "go",
size: 150,
cell: ({ row }) =>
row.original.ready && canCreateBooking ? (
<Group justify="flex-end" pr="xs">
<Button
size="compact-sm"
color="edr-green"
radius="md"
leftSection={<PackagePlus size={14} />}
onClick={(e) => {
e.stopPropagation();
navigate(
`/dashboard/contracts/${row.original.id}/create-booking`,
);
}}
>
Create booking
</Button>
</Group>
) : row.original.paymentExpired && canCreateBooking ? (
<Group justify="flex-end" pr="xs">
<Button
size="compact-sm"
color="grape"
radius="md"
leftSection={<RefreshCw size={14} />}
onClick={(e) => {
e.stopPropagation();
navigate(
`/dashboard/contracts/${row.original.id}/create-booking${
row.original.expiredBookingId
? `?copyFrom=${row.original.expiredBookingId}`
: ""
}`,
);
}}
>
Rebook
</Button>
</Group>
) : (
<Group justify="flex-end" pr="xs">
<ChevronRight size={16} className="text-muted-foreground" />
</Group>
),
},
],
[navigate, canCreateBooking],
const openBooking = useCallback(
// `from` so the detail page's Back returns to this hub.
(id: string) =>
navigate(`/dashboard/clearance/${id}`, {
state: { from: "/dashboard/contracts/clearance" },
}),
[navigate],
);
return (
@@ -609,7 +176,7 @@ export default function ContractClearanceListPage() {
<Stack gap="lg">
<PageHeader
title="Document Clearance"
subtitle="All customs contracts in phased clearance — stays visible after booking is created."
subtitle="Every customs shipment in phased clearance — the documents live on the shipment, not on the contract."
meta={
<Badge
variant="light"
@@ -621,16 +188,18 @@ export default function ContractClearanceListPage() {
</Badge>
}
action={
<ActionIcon
variant="default"
size="lg"
radius="md"
onClick={() => refetch()}
loading={isFetching}
aria-label="Refresh"
>
<RefreshCw size={16} />
</ActionIcon>
<Group gap="sm" wrap="nowrap">
<ActionIcon
variant="default"
size="lg"
radius="md"
onClick={() => void refetch()}
loading={isFetching}
aria-label="Refresh"
>
<RefreshCw size={16} />
</ActionIcon>
</Group>
}
/>
@@ -651,7 +220,7 @@ export default function ContractClearanceListPage() {
},
{
label: "Ready / booked",
value: counts.ready + counts.booked,
value: counts.ready,
icon: PackageCheck,
color: "edr-green",
},
@@ -662,32 +231,15 @@ export default function ContractClearanceListPage() {
<Card p={0} withBorder shadow="sm" radius="lg">
<Stack gap={0}>
{queueTabOptions.length > 1 ? (
<Box px="md" pt="md">
<SegmentedControl
size="sm"
radius="md"
value={queueTab}
onChange={(v) => {
selectQueueTab(v as QueueTab);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
data={queueTabOptions}
/>
</Box>
) : null}
<Box px="md" pt="md" pb="sm">
<Group justify="space-between" gap="md" wrap="wrap">
<TextInput
placeholder="Search reference, customer, or route…"
placeholder="Search shipment, contract, customer or route…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => {
setQuery(e.currentTarget.value);
setPagination({
pageIndex: 0,
pageSize: pagination.pageSize,
});
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
rightSection={
query ? (
@@ -705,100 +257,39 @@ export default function ContractClearanceListPage() {
radius="lg"
style={{ flex: 1, minWidth: 220 }}
/>
<Group gap="sm" wrap="nowrap">
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
<SegmentedControl
size="sm"
radius="md"
value={view}
onChange={(v) => setView(v as ViewMode)}
data={[
{
value: "table",
label: (
<Group gap={6} wrap="nowrap">
<TableIcon size={15} />
<Box visibleFrom="sm">Table</Box>
</Group>
),
},
{
value: "cards",
label: (
<Group gap={6} wrap="nowrap">
<LayoutGrid size={15} />
<Box visibleFrom="sm">Cards</Box>
</Group>
),
},
]}
/>
</Group>
<Text size="sm" c="dimmed">
{rows.length} record{rows.length !== 1 ? "s" : ""}
</Text>
</Group>
</Box>
{queueTab === "shipments" ? (
<ShipmentBookingsTable
rows={bookingRows}
loading={bookingsLoading}
error={bookingsError}
canCreateBooking={canCreateBooking}
onOpen={(id) => navigate(`/dashboard/clearance/${id}`)}
onCreateBooking={(row) =>
navigate(
`/dashboard/contracts/${row.contractId}/bookings/${row.id}/complete`,
)
}
onRebook={(row) =>
// Re-complete the SAME expired booking (new day, same finished
// per-booking clearance) — a fresh create-booking would spawn a
// new instance and force the customer through clearance + fee
// again.
navigate(
`/dashboard/contracts/${row.contractId}/bookings/${row.id}/complete?copyFrom=${row.id}`,
)
}
onViewContract={(contractId) =>
navigate(`/dashboard/contracts/clearance/${contractId}`)
}
/>
) : view === "table" ? (
<Box style={{ overflowX: "auto" }} px="xs" pb="xs">
<DataTable<ClearanceRow, unknown>
columns={columns}
data={pagedRows}
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>
) : (
<ClearanceCardGrid
rows={pagedRows}
loading={isLoading}
onOpen={openDetail}
/>
)}
<ShipmentBookingsTable
rows={rows}
loading={isLoading}
error={isError}
canCreateBooking={canCreateBooking}
onOpen={openBooking}
onCreateBooking={(row) =>
navigate(
`/dashboard/contracts/${row.contractId}/bookings/${row.id}/complete`,
)
}
onRebook={(row) =>
// Re-complete the SAME expired booking (new day, same finished
// per-booking clearance) — a fresh instance would force the
// customer through clearance + fee again.
navigate(
`/dashboard/contracts/${row.contractId}/bookings/${row.id}/complete?copyFrom=${row.id}`,
)
}
onViewContract={(contractId) =>
navigate(`/dashboard/contracts/${contractId}`)
}
/>
</Stack>
</Card>
</Stack>
</PageContainer>
);
}
@@ -1124,135 +615,3 @@ function ShipmentBookingsTable({
</Box>
);
}
function ClearanceCardGrid({
rows,
loading,
onOpen,
}: {
rows: ClearanceRow[];
loading: boolean;
onOpen: (id: string) => void;
}) {
if (loading) {
return (
<Box px="md" py="xl">
<Text c="dimmed" ta="center">
Loading
</Text>
</Box>
);
}
if (rows.length === 0) {
return (
<Stack align="center" gap={8} py={48}>
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
<Inbox size={22} />
</ThemeIcon>
<Text c="dimmed">No contracts need customs clearance.</Text>
</Stack>
);
}
return (
<Box
px="md"
pb="md"
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
gap: "var(--mantine-spacing-md)",
}}
>
{rows.map((r) => (
<ClearanceCard key={r.id} row={r} onOpen={() => onOpen(r.id)} />
))}
</Box>
);
}
function ClearanceCard({
row,
onOpen,
}: {
row: ClearanceRow;
onOpen: () => void;
}) {
return (
<Card
withBorder
shadow="sm"
radius="lg"
p="md"
onClick={onOpen}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onOpen();
}
}}
style={{ cursor: "pointer", transition: "all 120ms ease" }}
className="hover:border-edr-green-4 hover:shadow-md"
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon variant="light" color="edr-green" radius="md" size={40}>
<FileText size={19} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Text fw={700} size="sm" c="edr-text" truncate>
{row.reference}
</Text>
<Group gap={4} wrap="nowrap">
<User size={11} className="shrink-0 opacity-70" />
<Text size="xs" c="dimmed" truncate>
{row.customerLabel}
</Text>
</Group>
</Box>
</Group>
<StatusBadge row={row} />
</Group>
<Box
mt="md"
p="sm"
style={{
borderRadius: 12,
background: "var(--mantine-color-edr-card-6)",
border: "1px solid var(--mantine-color-edr-border-6)",
}}
>
<Group gap={8} wrap="nowrap" justify="center">
<Text size="sm" fw={600} truncate maw={130}>
{row.originLabel}
</Text>
<ArrowRight size={15} className="shrink-0 text-muted-foreground" />
<Text size="sm" fw={600} truncate maw={130}>
{row.destinationLabel}
</Text>
</Group>
</Box>
<Group justify="space-between" mt="md" wrap="nowrap">
<Group gap={8} wrap="nowrap">
<DirectionIcon direction={row.tradeDirection} />
<Badge size="xs" variant="default" radius="sm">
{row.freightType}
</Badge>
</Group>
<Badge size="xs" variant="default" radius="sm" tt="uppercase">
{row.contractKind === "GENERAL" ? "General" : "One-time"}
</Badge>
</Group>
<Group justify="space-between" mt={8} wrap="nowrap" gap={8}>
<Text size="xs" c="dimmed" truncate maw={150}>
{row.serviceTypeName}
</Text>
<CustomsBadge customs={row.customs} />
</Group>
</Card>
);
}

View File

@@ -15,11 +15,14 @@ import {
Files,
Flame,
History,
Info,
LayoutGrid,
Milestone,
Package,
Receipt,
RefreshCw,
Route as RouteIcon,
ShieldCheck,
Snowflake,
Users,
} from "lucide-react";
@@ -34,6 +37,7 @@ import {
Group,
Loader,
Paper,
SimpleGrid,
Stack,
Tabs,
Text,
@@ -47,13 +51,17 @@ import { PageContainer } from "@/components/page";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { detailStyles } from "@/components/bookings/detail/booking-detail.styles";
import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge";
import {
ContractCourtBadge,
ContractStatusBadge,
} from "@/components/contracts/ContractStatusBadge";
import { ContractWorkflowStepper } from "@/components/contracts/ContractWorkflowStepper";
import { ContractActionsToolbar } from "@/components/contracts/ContractActionsToolbar";
import { ContractApprovalStepsCard } from "@/components/contracts/ContractApprovalStepsCard";
import { HazardDeclarationPanel } from "@/components/contracts/HazardDeclarationPanel";
import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel";
import { ContractRevisionTimeline } from "@/components/contracts/ContractRevisionTimeline";
import { ContractMilestonesTimeline } from "@/components/contracts/ContractMilestonesTimeline";
import {
ContractCustomerCard,
ContractDocumentsCard,
@@ -110,6 +118,21 @@ function formatDate(value: string | null | undefined): string {
});
}
/** Same, plus the clock — for values the staff pick to the minute. */
function formatDateTime(value: string | null | undefined): string {
if (!value) return "—";
const d = new Date(value);
return Number.isNaN(d.getTime())
? "—"
: d.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
export default function ContractRequestDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
@@ -357,6 +380,7 @@ export default function ContractRequestDetailPage() {
status={contract.status}
isRenewal={Boolean(contract.renewalOfId)}
/>
<ContractCourtBadge status={contract.status} />
<Badge variant="light" color="gray" radius="sm" tt="uppercase">
{contract.contractKind === "GENERAL" ? "General" : "One-time"}
</Badge>
@@ -370,7 +394,8 @@ export default function ContractRequestDetailPage() {
{contract.contractValidUntil ? (
<MetaItem
icon={CalendarClock}
text={`Valid until ${formatDate(contract.contractValidUntil)}`}
// Validity is accepted to the minute — show the time.
text={`Valid until ${formatDateTime(contract.contractValidUntil)}`}
/>
) : null}
</Group>
@@ -515,17 +540,120 @@ export default function ContractRequestDetailPage() {
) : null}
</Stack>
) : currentTab === "history" ? (
<SectionCard
icon={History}
title="Change history"
subtitle="Every recorded edit to this contract — who changed what, and when."
>
<ContractRevisionTimeline contractId={contract.id} bare />
</SectionCard>
<Stack gap="lg">
<SectionCard
icon={Milestone}
title="Key milestones"
subtitle="Submission, approval, signatures and validity — the dated record of this contract."
>
<ContractMilestonesTimeline contract={contract} />
</SectionCard>
<SectionCard
icon={History}
title="Change history"
subtitle="Every recorded edit to this contract — who changed what, and when."
>
<ContractRevisionTimeline contractId={contract.id} bare />
</SectionCard>
</Stack>
) : currentTab === "customer" ? (
<ContractCustomerCard contract={contract} />
) : (
<Stack gap="lg">
<SectionCard
icon={Info}
title="Contract information"
subtitle="Full commercial and operational detail for this contract."
>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="lg">
<InfoRow
label="Service type"
value={contract.serviceType?.serviceName ?? "—"}
/>
<InfoRow
label="Payment currency"
value={contract.paymentCurrency ?? "—"}
/>
<InfoRow
label="Customs clearing"
value={
contract.customsClearingEnabled
? "Included automatically"
: contract.customsClearingAgent
? `Customer's agent — ${contract.customsClearingAgent}`
: "Not included"
}
/>
{contract.equipmentReturn ? (
<InfoRow
label="Equipment return"
value={
contract.equipmentReturn === "WITH_RETURN"
? "With return"
: "Without return"
}
/>
) : null}
<InfoRow
label="Contract type"
value={contract.contractType ?? "Standard"}
/>
{contract.contractValidityDays != null ? (
<InfoRow
label="Validity period"
value={`${contract.contractValidityDays} days`}
/>
) : null}
{contract.estimatedShipmentDate ? (
<InfoRow
label="Estimated shipment date"
value={formatDate(contract.estimatedShipmentDate)}
/>
) : null}
{contract.firstMilePickupAddress ? (
<InfoRow
label="First-mile pickup"
value={contract.firstMilePickupAddress}
/>
) : null}
{contract.lastMileDeliveryAddress ? (
<InfoRow
label="Last-mile delivery"
value={contract.lastMileDeliveryAddress}
/>
) : null}
</SimpleGrid>
{contract.financialTerms ? (
<Box
mt="md"
pt="md"
style={{ borderTop: "1px solid var(--mantine-color-gray-2)" }}
>
<Text
size="xs"
c="dimmed"
fw={600}
tt="uppercase"
mb={4}
style={{ letterSpacing: 0.3 }}
>
Financial terms
</Text>
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
{contract.financialTerms}
</Text>
</Box>
) : null}
</SectionCard>
<SectionCard
icon={ShieldCheck}
title="Approval & signing timeline"
subtitle="Every dated step in this contract's approval chain, plus signatures — the same record kept in the sidebar, always visible here."
>
<ContractMilestonesTimeline contract={contract} />
</SectionCard>
<SectionCard icon={RouteIcon} title="Routes">
{routes.length === 0 ? (
<Text size="sm" c="dimmed">
@@ -734,6 +862,25 @@ export default function ContractRequestDetailPage() {
);
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div>
<Text
size="xs"
c="dimmed"
fw={600}
tt="uppercase"
style={{ letterSpacing: 0.3 }}
>
{label}
</Text>
<Text size="sm" fw={500} mt={2}>
{value}
</Text>
</div>
);
}
function MetaItem({
icon: Icon,
text,

View File

@@ -34,7 +34,10 @@ import { useCallback, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { ContractApprovalProgressCell } from "@/components/contracts/ContractApprovalProgressCell";
import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge";
import {
ContractCourtBadge,
ContractStatusBadge,
} from "@/components/contracts/ContractStatusBadge";
import {
ContractStatusTabs,
type ContractStatusTabKey,
@@ -334,6 +337,19 @@ export default function ContractRequestsPage() {
</div>
),
},
{
id: "court",
size: COLUMN_WIDTH,
meta: COLUMN_META,
header: () => (
<span className={bookingTable.headerCell}>Waiting on</span>
),
cell: ({ row }) => (
<div className="py-1">
<ContractCourtBadge status={row.original.status} />
</div>
),
},
{
id: "approval",
size: COLUMN_WIDTH,
@@ -666,7 +682,7 @@ export default function ContractRequestsPage() {
}}
// table-fixed makes the per-column 120px widths stick; without
// it auto-layout re-widens columns once cells wrap.
containerClassName="border-0 shadow-none bg-transparent [&_table]:table-fixed [&_table]:min-w-[840px]"
containerClassName="border-0 shadow-none bg-transparent [&_table]:table-fixed [&_table]:min-w-[960px]"
footer={DataTableFooter}
/>
</Box>

View File

@@ -20,6 +20,7 @@ import {
AlertTriangle,
ClipboardList,
FileText,
Share2,
Upload,
} from "lucide-react";
import type { Freight } from "@edr/types";
@@ -33,6 +34,7 @@ import { PageHeader } from "@/components/page/PageHeader";
import { ClearanceReviewSection } from "@/components/bookings/detail/ClearanceReviewSection";
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel";
import { GlExchangePanel } from "@/components/contracts/GlExchangePanel";
import {
GlClearanceUploadModal,
type GlClearanceUploadKind,
@@ -228,6 +230,9 @@ export default function GlClearanceDetailPage() {
>
Customs documents (all steps)
</Tabs.Tab>
<Tabs.Tab value="exchange" leftSection={<Share2 size={14} />}>
Document exchange
</Tabs.Tab>
{incidentBookingId ? (
<Tabs.Tab value="incidents" leftSection={<AlertTriangle size={14} />}>
Incidents
@@ -236,22 +241,21 @@ export default function GlClearanceDetailPage() {
</Tabs.List>
<Tabs.Panel value="workflow">
{/* GL Ethiopia cannot file the customs declaration until this desk
names the officer handling the shipment in transit, so the ask
sits above everything else on the page. */}
{data.kind === "contract" ? (
<Box mb="md">
<TransitAssigneePanel
contractId={id!}
transitAssignee={data.clearance.transitAssignee}
side="DJ"
readOnly={
!hasPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions)
}
onChanged={() => void refetch()}
/>
</Box>
) : null}
{/* GL Ethiopia cannot file the import customs declaration until this
desk names the officer handling the shipment in transit. Exports also
need transit assignment at the DJ stage after ET requests it. */}
<Box mb="md">
<TransitAssigneePanel
entityId={id!}
isBooking={data.kind === "booking"}
transitAssignee={data.clearance.transitAssignee}
side="DJ"
readOnly={
!hasPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions)
}
onChanged={() => void refetch()}
/>
</Box>
<Grid>
<Grid.Col span={{ base: 12, lg: 7 }}>
@@ -340,6 +344,10 @@ export default function GlClearanceDetailPage() {
)}
</Tabs.Panel>
<Tabs.Panel value="exchange">
<GlExchangePanel entityId={id!} />
</Tabs.Panel>
{incidentBookingId ? (
<Tabs.Panel value="incidents">
<SectionCard icon={AlertTriangle} title="Incident reports" accent="edr-green">

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

@@ -1,7 +1,7 @@
import type { ColumnDef } from "@edr/ui-common";
import { Box, Button, Card, Container, Group, Modal, Select, Stack, Text, Title } from "@mantine/core";
import { DatePickerInput } from "@mantine/dates";
import { useMutation, useQuery } from "@tanstack/react-query";
import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
import { useAuth } from "@/auth/useAuth";
@@ -30,8 +30,13 @@ import {
type FleetFormFieldDef,
type FleetResourceSlug,
} from "@/pages/fleet/config/resources";
import type { FleetListFilters, FleetRecord } from "@/services/fleet/fleet.service";
import {
isFleetServerPaginated,
type FleetListFilters,
type FleetRecord,
} from "@/services/fleet/fleet.service";
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
import { useDebouncedValue } from "@mantine/hooks";
const DEFAULT_SLUG: FleetResourceSlug = "locomotives";
@@ -53,6 +58,10 @@ const FleetResourcePage = () => {
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, 300);
// Wagons and locomotives page in the database; the rest still list in full
// and page in the browser (see `pagedHandlers` in fleet.service).
const serverPaged = isFleetServerPaginated(slug);
const [statusFilter, setStatusFilter] = useState("ALL");
// Registration date range. Server-side list filters (status/yard/train) are
// applied by the API; this narrows what comes back, alongside search.
@@ -93,14 +102,42 @@ const FleetResourcePage = () => {
if (wagonTypeId && wagonTypeId !== "ALL") {
(filters as { wagonTypeId?: string }).wagonTypeId = wagonTypeId;
}
if (slug !== "locomotives" && search.trim()) {
filters.search = search.trim();
// The plain locomotives list has no server-side search — its page window
// does, so the term is only sent on the paginated path.
if ((serverPaged || slug !== "locomotives") && debouncedSearch.trim()) {
filters.search = debouncedSearch.trim();
}
return filters;
}, [slug, listFilterValues, search]);
}, [slug, listFilterValues, debouncedSearch, serverPaged]);
const { data: allRows = [], isLoading, isError, error } = useQuery(
api.fleet.list.queryOptions({ input: { slug, filters: serverListFilters } }),
// On the server-paged path the page window, the search and the registration
// date range are all resolved by the API — nothing is filtered client-side.
const pagedFilters = useMemo(
(): FleetListFilters => ({
...serverListFilters,
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
...(dateFrom ? { createdFrom: dateFrom } : {}),
...(dateTo ? { createdTo: dateTo } : {}),
}),
[serverListFilters, pagination.pageIndex, pagination.pageSize, dateFrom, dateTo],
);
const listQuery = useQuery({
...api.fleet.list.queryOptions({ input: { slug, filters: serverListFilters } }),
enabled: !serverPaged,
});
const pagedQuery = useQuery({
...api.fleet.listPaged.queryOptions({ input: { slug, filters: pagedFilters } }),
enabled: serverPaged,
placeholderData: keepPreviousData,
});
const activeQuery = serverPaged ? pagedQuery : listQuery;
const { isLoading, isError, error } = activeQuery;
const allRows = useMemo(
() => (serverPaged ? (pagedQuery.data?.items ?? []) : (listQuery.data ?? [])),
[serverPaged, pagedQuery.data, listQuery.data],
);
const create = useMutation(api.fleet.create.mutationOptions());
const update = useMutation(api.fleet.update.mutationOptions());
@@ -118,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(),
);
@@ -270,6 +313,9 @@ const FleetResourcePage = () => {
const filteredRows = useMemo(() => {
if (!config) return allRows;
// The API already applied every filter and cut the page — re-filtering here
// would drop rows the server deliberately returned.
if (serverPaged) return allRows;
const term = search.trim().toLowerCase();
return allRows.filter((row) => {
const record = row as unknown as Record<string, unknown>;
@@ -287,13 +333,19 @@ const FleetResourcePage = () => {
.includes(term),
);
});
}, [allRows, search, statusFilter, config, usesServerListFilters, dateFrom, dateTo]);
}, [allRows, search, statusFilter, config, usesServerListFilters, dateFrom, dateTo, serverPaged]);
const pageCount = Math.max(1, Math.ceil(filteredRows.length / pagination.pageSize));
const totalCount = serverPaged
? (pagedQuery.data?.meta.total ?? 0)
: filteredRows.length;
const pageCount = serverPaged
? Math.max(1, pagedQuery.data?.meta.totalPages ?? 1)
: Math.max(1, Math.ceil(filteredRows.length / pagination.pageSize));
const pagedRows = useMemo(() => {
if (serverPaged) return filteredRows;
const start = pagination.pageIndex * pagination.pageSize;
return filteredRows.slice(start, start + pagination.pageSize);
}, [filteredRows, pagination.pageIndex, pagination.pageSize]);
}, [filteredRows, pagination.pageIndex, pagination.pageSize, serverPaged]);
const columns = useMemo((): ColumnDef<FleetRecord>[] => {
if (!config) return [];
@@ -584,7 +636,7 @@ const FleetResourcePage = () => {
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: filteredRows.length,
totalCount,
}}
tableOptions={{
manualPagination: true,
@@ -610,7 +662,7 @@ const FleetResourcePage = () => {
emptyMessage={`No ${itemLabel} found`}
pagination={pagination}
pageCount={pageCount}
totalCount={filteredRows.length}
totalCount={totalCount}
onPaginationChange={setPagination}
onEdit={
canUpdate

View File

@@ -1,4 +1,4 @@
import { FormEvent, useMemo, useState } from "react";
import { FormEvent, useEffect, useMemo, useState } from "react";
import {
ArrowRight,
Ban,
@@ -27,7 +27,8 @@ import {
Tooltip,
} from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useDebouncedValue } from "@mantine/hooks";
import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import FleetToolbar from "@/components/fleet/FleetToolbar";
@@ -155,6 +156,7 @@ function RouteTimeline({ route }: { route: RouteRecord }) {
export default function RoutesPage() {
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, 300);
const [formOpen, setFormOpen] = useState(false);
const [viewing, setViewing] = useState<RouteRecord | null>(null);
const [editing, setEditing] = useState<RouteRecord | null>(null);
@@ -167,7 +169,26 @@ export default function RoutesPage() {
const canUpdate = canFleetAction(user, "routes", "update");
const canDelete = canFleetAction(user, "routes", "delete");
const routesQuery = useQuery(api.routes.list.queryOptions());
const routesQuery = useQuery({
...api.routes.listPaged.queryOptions({
input: {
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
...(debouncedSearch.trim() ? { search: debouncedSearch.trim() } : {}),
},
}),
placeholderData: keepPreviousData,
});
// KPI counts stay whole-fleet (they must not move with the search box), so
// they come from two count-only pages rather than the visible one.
const totalCountQuery = useQuery(
api.routes.listPaged.queryOptions({ input: { page: 1, pageSize: 1 } }),
);
const availableCountQuery = useQuery(
api.routes.listPaged.queryOptions({
input: { page: 1, pageSize: 1, status: "AVAILABLE" },
}),
);
const yardsQuery = useQuery(api.routes.yards.queryOptions());
// Segment km are configured in Configuration → Yard Distances and resolved
// by the API on save; this fetch is only to preview them in the form.
@@ -182,35 +203,19 @@ export default function RoutesPage() {
const updateMutation = useMutation(api.routes.update.mutationOptions());
const deactivateMutation = useMutation(api.routes.deactivate.mutationOptions());
const filteredRoutes = useMemo(() => {
const query = search.trim().toLowerCase();
if (!query) return routesQuery.data ?? [];
return (routesQuery.data ?? []).filter((route) => {
const searchable = [
formatRouteLabel(route),
route.originYard?.label,
route.originYard?.code,
route.destinationYard?.label,
route.destinationYard?.code,
...(route.milestones ?? []).map(
(m) => m.yard?.label ?? m.yard?.code ?? m.yardId,
),
]
.filter(Boolean)
.join(" ")
.toLowerCase();
return searchable.includes(query);
});
}, [routesQuery.data, search]);
// Narrowing the result set can strand the user on a page that no longer
// exists (search down to 3 rows while on page 5 → empty table).
useEffect(() => {
setPagination((prev) => (prev.pageIndex === 0 ? prev : { ...prev, pageIndex: 0 }));
}, [debouncedSearch, setPagination]);
const pageCount = Math.max(1, Math.ceil(filteredRoutes.length / pagination.pageSize));
const pagedRoutes = useMemo(() => {
const start = pagination.pageIndex * pagination.pageSize;
return filteredRoutes.slice(start, start + pagination.pageSize);
}, [filteredRoutes, pagination.pageIndex, pagination.pageSize]);
// Filtering, sorting and the page window all happen server-side.
const pagedRoutes = routesQuery.data?.items ?? [];
const matchCount = routesQuery.data?.meta.total ?? 0;
const pageCount = Math.max(1, routesQuery.data?.meta.totalPages ?? 1);
const allRoutes = routesQuery.data ?? [];
const availableCount = allRoutes.filter((r) => r.status === "AVAILABLE").length;
const totalRoutes = totalCountQuery.data?.meta.total ?? 0;
const availableCount = availableCountQuery.data?.meta.total ?? 0;
const yardOptions = useMemo(
() =>
@@ -489,11 +494,11 @@ export default function RoutesPage() {
<KpiStrip
loading={routesQuery.isLoading}
items={[
{ label: "Total routes", value: allRoutes.length, icon: RouteIcon },
{ label: "Total routes", value: totalRoutes, icon: RouteIcon },
{ label: "Available", value: availableCount, icon: CircleCheck, color: "edr-green" },
{
label: "Unavailable",
value: allRoutes.length - availableCount,
value: totalRoutes - availableCount,
icon: Ban,
color: "gray",
},
@@ -522,7 +527,7 @@ export default function RoutesPage() {
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: filteredRoutes.length,
totalCount: matchCount,
}}
tableOptions={{
manualPagination: true,
@@ -581,7 +586,7 @@ export default function RoutesPage() {
<RuleEngineListFooter
pagination={pagination}
pageCount={pageCount}
totalCount={filteredRoutes.length}
totalCount={matchCount}
itemLabel="routes"
onPaginationChange={setPagination}
/>

View File

@@ -10,6 +10,7 @@ export type ColumnFormat =
| "boolean"
| "activeBadge"
| "rateStatus"
| "validityBadge"
| "date"
| "number"
| "currency"
@@ -483,6 +484,39 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
{ name: "isActive", label: "Active", type: "boolean" },
],
},
{
slug: "transit-agents",
label: "Transit Agents",
category: "configuration",
subtitle:
"Djibouti transit officers GL Djibouti may assign to a shipment — each carries a validity window",
searchPlaceholder: "Search transit agents by name...",
cardTitleKey: "name",
columns: [
{ id: "name", header: "Name", accessorKey: "name" },
{ id: "validFrom", header: "Valid from", accessorKey: "validFrom", format: "date" },
{ id: "validTo", header: "Valid to", accessorKey: "validTo", format: "date" },
{
id: "validityStatus",
header: "Validity",
accessorKey: "validityStatus",
format: "validityBadge",
},
activeColumn,
],
formFields: [
{ name: "name", label: "Name", type: "text", required: true },
{ name: "validFrom", label: "Valid from", type: "date", required: true },
{
name: "validTo",
label: "Valid to",
type: "date",
required: true,
description: "Expired or not-yet-started agents can't be assigned — extend the dates or add a new one",
},
{ name: "isActive", label: "Active", type: "boolean", description: "Off suspends the officer regardless of the validity window" },
],
},
{
slug: "yard-distances",
label: "Yard Distances",

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

@@ -48,6 +48,8 @@ export function TransferFulfillModal({
currentYardId: request.fromYardId,
wagonTypeId: request.wagonTypeId,
status: Freight.WagonStatus.Available,
// A coupled wagon cannot be moved out of its train by a transfer.
unassigned: true,
}
: {},
},

View File

@@ -0,0 +1,342 @@
import {
Badge,
Button,
Card,
Group,
SegmentedControl,
Skeleton,
Stack,
Switch,
Text,
ThemeIcon,
Timeline,
Tooltip,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import {
ArrowRight,
ChevronLeft,
ChevronRight,
History,
Inbox,
Truck,
} from "lucide-react";
import { useState } from "react";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { api } from "@/services/api";
import type {
WagonMovementRecord,
WagonTransferRequest,
} from "@/services/wagon.service";
import {
STATUS_META,
TransferProgress,
TransferStatusBadge,
wagonTypeLabel,
yardLabel,
} from "./wagon-transfer-ui";
const fmtTime = (iso?: string | null) =>
iso
? new Date(iso).toLocaleTimeString("en-GB", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
})
: "—";
/** "Today" / "Yesterday" / "Mon 12 Jul 2026" — the header of one timeline block. */
const dayLabel = (iso: string) => {
const d = new Date(iso);
const days = Math.round(
(new Date().setHours(0, 0, 0, 0) - new Date(iso).setHours(0, 0, 0, 0)) /
86_400_000,
);
if (days === 0) return "Today";
if (days === 1) return "Yesterday";
return d.toLocaleDateString("en-GB", {
weekday: "short",
day: "numeric",
month: "short",
year: "numeric",
});
};
/** Bucket an already-DESC-sorted list into day blocks, order preserved. */
function groupByDay<T>(items: T[], at: (item: T) => string) {
const groups: Array<{ key: string; label: string; items: T[] }> = [];
for (const item of items) {
const iso = at(item);
const key = new Date(iso).toDateString();
const last = groups[groups.length - 1];
if (last?.key === key) last.items.push(item);
else groups.push({ key, label: dayLabel(iso), items: [item] });
}
return groups;
}
const MOVEMENT_KIND_LABEL: Record<string, string> = {
LOADED: "Carried cargo",
EMPTY_REPOSITION: "Repositioned empty",
MANUAL: "Manual move",
};
function EmptyState({ label }: { label: string }) {
return (
<Stack align="center" gap={6} py="xl">
<ThemeIcon variant="light" color="gray" radius="xl" size={44}>
<History size={20} />
</ThemeIcon>
<Text size="sm" c="dimmed">
{label}
</Text>
</Stack>
);
}
function RequestItem({ request }: { request: WagonTransferRequest }) {
const meta = STATUS_META[request.status];
return (
<Timeline.Item
bullet={<Inbox size={12} />}
color={meta?.color ?? "gray"}
lineVariant="dotted"
>
<Group justify="space-between" align="flex-start" gap="md" wrap="wrap">
<Stack gap={4} style={{ flex: 1, minWidth: 220 }}>
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={600}>
{yardLabel(request.fromYard)}
</Text>
<ArrowRight size={13} className="shrink-0 opacity-60" />
<Text size="sm" fw={600}>
{yardLabel(request.toYard)}
</Text>
<Badge variant="default" radius="sm" size="sm">
{wagonTypeLabel(request.wagonType)}
</Badge>
</Group>
{request.reason ? (
<Text size="xs" c="dimmed" lineClamp={1}>
{request.reason}
</Text>
) : null}
</Stack>
<Group gap="sm" wrap="nowrap">
<TransferProgress request={request} />
<TransferStatusBadge status={request.status} />
<Text size="xs" c="dimmed" w={38} ta="right">
{fmtTime(request.createdAt)}
</Text>
</Group>
</Group>
</Timeline.Item>
);
}
function MovementItem({ movement }: { movement: WagonMovementRecord }) {
return (
<Timeline.Item
bullet={<Truck size={12} />}
color={movement.transferRequestId ? "edr-green" : "gray"}
lineVariant="dotted"
>
<Group justify="space-between" align="center" gap="md" wrap="wrap">
<Group gap={8} wrap="nowrap" style={{ flex: 1, minWidth: 220 }}>
<Badge variant="light" color="gray" radius="sm" ff="monospace">
{movement.wagon?.wagonNumber ?? "Wagon"}
</Badge>
<Text size="sm" fw={500}>
{yardLabel(movement.fromYard)}
</Text>
<ArrowRight size={13} className="shrink-0 opacity-60" />
<Text size="sm" fw={500}>
{yardLabel(movement.toYard)}
</Text>
</Group>
<Group gap="sm" wrap="nowrap">
{movement.transferRequestId ? (
<Tooltip label="Delivered against a transfer request" withArrow>
<Badge variant="dot" color="teal" radius="sm" size="sm">
Transfer
</Badge>
</Tooltip>
) : (
<Badge variant="light" color="gray" radius="sm" size="sm">
{MOVEMENT_KIND_LABEL[movement.kind] ?? movement.kind}
</Badge>
)}
<Text size="xs" c="dimmed" w={38} ta="right">
{fmtTime(movement.occurredAt)}
</Text>
</Group>
</Group>
</Timeline.Item>
);
}
/**
* Who moved what. A staffer sees their own activity; holders of
* `transfer_history_all` can widen it to every staffer (the backend enforces
* the scope regardless of the toggle).
*/
export default function TransferHistoryPanel() {
const { user } = useAuth();
const canSeeAll = hasPermission(user, FREIGHT_PERMS.wagons.transferHistoryAll);
const [allStaff, setAllStaff] = useState(false);
const [view, setView] = useState<"requests" | "movements">("requests");
const [page, setPage] = useState(1);
const scopeAll = canSeeAll && allStaff;
const mine = useQuery({
...api.wagonTransferRequests.history.queryOptions({
input: { page, pageSize: 20 },
}),
enabled: !scopeAll,
});
const all = useQuery({
...api.wagonTransferRequests.historyAll.queryOptions({
input: { page, pageSize: 20 },
}),
enabled: scopeAll,
});
const source = scopeAll ? all : mine;
const requests = source.data?.requests ?? [];
const movements = source.data?.movements ?? [];
const meta = source.data?.meta;
const showingRequests = view === "requests";
const total = showingRequests
? (meta?.requestsTotal ?? 0)
: (meta?.movementsTotal ?? 0);
// Each list pages independently on the server; the pager follows the one on screen.
const pageSize = meta?.pageSize ?? 20;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const groups = showingRequests
? groupByDay(requests, (r) => r.createdAt)
: groupByDay(movements, (m) => m.occurredAt);
return (
<Card withBorder radius="md" p="md">
<Stack gap="md">
<Group justify="space-between" align="flex-start" gap="md" wrap="wrap">
<Stack gap={2}>
<Text fw={600}>Transfer history</Text>
<Text size="xs" c="dimmed">
{scopeAll
? "Every staffer's requests and wagon moves"
: "Requests you filed or fulfilled, and the wagons you moved"}
</Text>
</Stack>
<Group gap="sm" wrap="wrap">
<SegmentedControl
size="xs"
radius="md"
value={view}
onChange={(v) => {
setView(v as "requests" | "movements");
setPage(1);
}}
data={[
{
value: "requests",
label: `Requests ${meta?.requestsTotal ?? 0}`,
},
{
value: "movements",
label: `Wagons moved ${meta?.movementsTotal ?? 0}`,
},
]}
/>
{canSeeAll ? (
<Switch
label="All staff"
checked={allStaff}
onChange={(e) => {
setAllStaff(e.currentTarget.checked);
setPage(1);
}}
/>
) : null}
</Group>
</Group>
{source.isLoading ? (
<Stack gap="sm">
{[0, 1, 2, 3].map((i) => (
<Skeleton key={i} height={44} radius="md" />
))}
</Stack>
) : groups.length === 0 ? (
<EmptyState
label={
showingRequests
? "No transfer requests recorded yet."
: "No wagon moves recorded yet."
}
/>
) : (
<Stack gap="lg">
{groups.map((group) => (
<Stack key={group.key} gap="xs">
<Group gap="xs" wrap="nowrap">
<Text size="xs" fw={700} c="dimmed" tt="uppercase">
{group.label}
</Text>
<Text size="xs" c="dimmed">
· {group.items.length}
</Text>
</Group>
<Timeline
bulletSize={22}
lineWidth={2}
active={group.items.length}
>
{showingRequests
? (group.items as WagonTransferRequest[]).map((r) => (
<RequestItem key={r.id} request={r} />
))
: (group.items as WagonMovementRecord[]).map((m) => (
<MovementItem key={m.id} movement={m} />
))}
</Timeline>
</Stack>
))}
</Stack>
)}
<Group justify="space-between" gap="sm" wrap="wrap">
<Text size="xs" c="dimmed">
{total} {showingRequests ? "request(s)" : "move(s)"} · page{" "}
{meta?.page ?? page} of {totalPages}
</Text>
<Group gap="xs">
<Button
variant="default"
size="xs"
radius="md"
leftSection={<ChevronLeft size={14} />}
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
Previous
</Button>
<Button
variant="default"
size="xs"
radius="md"
rightSection={<ChevronRight size={14} />}
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
Next
</Button>
</Group>
</Group>
</Stack>
</Card>
);
}

View File

@@ -4,10 +4,9 @@ import {
Button,
Card,
Group,
Loader,
Modal,
Select,
Stack,
Switch,
Tabs,
Text,
TextInput,
@@ -46,6 +45,7 @@ import {
} from "@edr/ui-common";
import TransferFulfillModal from "./TransferFulfillModal";
import TransferHistoryPanel from "./TransferHistoryPanel";
import {
TransferCloseShortModal,
TransferRequestFormModal,
@@ -103,6 +103,9 @@ export default function WagonTransfersPage() {
const [formOpen, setFormOpen] = useState(false);
const [carryOver, setCarryOver] = useState<WagonTransferRequest | null>(null);
const [fulfilling, setFulfilling] = useState<WagonTransferRequest | null>(null);
const [withdrawing, setWithdrawing] = useState<WagonTransferRequest | null>(
null,
);
const [closingShort, setClosingShort] = useState<WagonTransferRequest | null>(
null,
);
@@ -269,15 +272,7 @@ export default function WagonTransfersPage() {
radius="md"
variant="subtle"
color="red"
loading={cancel.isPending}
onClick={async () => {
try {
await cancel.mutateAsync({ id: r.id });
toast.success("Request withdrawn");
} catch {
// interceptor surfaces the reason
}
}}
onClick={() => setWithdrawing(r)}
>
Withdraw
</Button>
@@ -494,132 +489,53 @@ export default function WagonTransfersPage() {
}
}}
/>
<Modal
opened={Boolean(withdrawing)}
onClose={() => setWithdrawing(null)}
radius="md"
title="Withdraw this request?"
>
{!withdrawing ? null : (
<Stack gap="sm">
<Text size="sm">
{yardLabel(withdrawing.fromYard)} {" "}
{yardLabel(withdrawing.toYard)} ·{" "}
{wagonTypeLabel(withdrawing.wagonType)} ·{" "}
{withdrawing.quantity} wagon(s)
</Text>
<Text size="sm" c="dimmed">
The source yard stops seeing it. Withdrawing can't be undone
raise a new request if you still need the wagons.
</Text>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
radius="md"
onClick={() => setWithdrawing(null)}
>
Keep it
</Button>
<Button
color="red"
radius="md"
leftSection={<XCircle size={15} />}
loading={cancel.isPending}
onClick={async () => {
try {
await cancel.mutateAsync({ id: withdrawing.id });
toast.success("Request withdrawn");
setWithdrawing(null);
} catch {
// interceptor surfaces the reason
}
}}
>
Withdraw
</Button>
</Group>
</Stack>
)}
</Modal>
</PageContainer>
);
}
/**
* Who moved what. A staffer sees their own activity; holders of
* `transfer_history_all` can widen it to every staffer (the backend enforces
* the scope regardless of the toggle).
*/
function TransferHistoryPanel() {
const { user } = useAuth();
const canSeeAll = hasPermission(user, FREIGHT_PERMS.wagons.transferHistoryAll);
const [allStaff, setAllStaff] = useState(false);
const [page, setPage] = useState(1);
const scopeAll = canSeeAll && allStaff;
const mine = useQuery({
...api.wagonTransferRequests.history.queryOptions({
input: { page, pageSize: 20 },
}),
enabled: !scopeAll,
});
const all = useQuery({
...api.wagonTransferRequests.historyAll.queryOptions({
input: { page, pageSize: 20 },
}),
enabled: scopeAll,
});
const source = scopeAll ? all : mine;
const requests = source.data?.requests ?? [];
const movements = source.data?.movements ?? [];
const meta = source.data?.meta;
return (
<Card withBorder radius="md" p="md">
<Stack gap="md">
<Group justify="space-between" wrap="wrap">
<Text fw={600}>Transfer history</Text>
{canSeeAll ? (
<Switch
label="All staff"
checked={allStaff}
onChange={(e) => {
setAllStaff(e.currentTarget.checked);
setPage(1);
}}
/>
) : null}
</Group>
{source.isLoading ? (
<Group justify="center" py="lg">
<Loader size="sm" />
</Group>
) : (
<Group align="flex-start" grow gap="lg" wrap="wrap">
<Stack gap={6} miw={280}>
<Text size="sm" fw={700} c="dimmed" tt="uppercase">
Requests ({meta?.requestsTotal ?? 0})
</Text>
{requests.length === 0 ? (
<Text size="sm" c="dimmed">
Nothing yet.
</Text>
) : (
requests.map((r) => (
<Group key={r.id} gap={8} wrap="nowrap" justify="space-between">
<Text size="sm" truncate>
{yardLabel(r.fromYard)} {yardLabel(r.toYard)} ·{" "}
{r.fulfilledQuantity}/{r.quantity}
</Text>
<TransferStatusBadge status={r.status} />
</Group>
))
)}
</Stack>
<Stack gap={6} miw={280}>
<Text size="sm" fw={700} c="dimmed" tt="uppercase">
Wagons moved ({meta?.movementsTotal ?? 0})
</Text>
{movements.length === 0 ? (
<Text size="sm" c="dimmed">
Nothing yet.
</Text>
) : (
movements.map((m) => (
<Group key={m.id} gap={8} wrap="nowrap" justify="space-between">
<Text size="sm" truncate>
{m.wagon?.wagonNumber ?? "Wagon"} · {yardLabel(m.fromYard)} {" "}
{yardLabel(m.toYard)}
</Text>
<Text size="xs" c="dimmed">
{fmtDateTime(m.occurredAt)}
</Text>
</Group>
))
)}
</Stack>
</Group>
)}
<Group justify="center" gap="sm">
<Button
variant="default"
size="xs"
radius="md"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
Previous
</Button>
<Text size="sm" c="dimmed">
Page {meta?.page ?? page} of {meta?.totalPages ?? 1}
</Text>
<Button
variant="default"
size="xs"
radius="md"
disabled={page >= (meta?.totalPages ?? 1)}
onClick={() => setPage((p) => p + 1)}
>
Next
</Button>
</Group>
</Stack>
</Card>
);
}

View File

@@ -18,6 +18,8 @@ import {
WarehouseOpsKpiStrip,
formatDate,
formatNumber,
warehousesAtStation,
yardsForBooking,
} from '@/components/warehouses';
import {
useAutoUnloadArrivedBookings,
@@ -49,14 +51,6 @@ const getPendingUnloadBookings = (train: ImportTrain) =>
const isFullyUnloaded = (train: ImportTrain) =>
Boolean(train.fullyUnloaded) || (train.totalBookings > 0 && getPendingUnloadBookings(train) === 0);
const locationTypesForFreight = (freightType: string | null | undefined) => {
const normalized = (freightType ?? '').toUpperCase();
if (normalized === 'CONTAINER') {
return { yardTypes: ['CONTAINER_YARD', 'GENERAL_CARGO_YARD'], zoneTypes: ['CONTAINER_ZONE', 'GENERAL_CARGO_ZONE'] };
}
return { yardTypes: ['BULK_YARD', 'GENERAL_CARGO_YARD'], zoneTypes: ['BULK_ZONE', 'GENERAL_CARGO_ZONE'] };
};
const isContainerFreight = (freightType: string | null | undefined) =>
(freightType ?? '').toUpperCase() === 'CONTAINER';
@@ -65,7 +59,7 @@ function isUnloadPending(item: ImportTrainItem) {
}
function ImportTrainDetailRows({
scheduleId,
train,
warehouses,
yards,
zones,
@@ -73,7 +67,7 @@ function ImportTrainDetailRows({
onAssignmentChange,
onReadyChange,
}: {
scheduleId: string;
train: ImportTrain;
warehouses: Warehouse[];
yards: WarehouseYard[];
zones: WarehouseZone[];
@@ -81,11 +75,61 @@ function ImportTrainDetailRows({
onAssignmentChange: (bookingId: string, draft: AssignmentDraft) => void;
onReadyChange: (ready: boolean) => void;
}) {
const { data: items = [], isLoading } = useImportTrainItems(scheduleId);
const warehouseOptions = useMemo(
() => warehouses.map((warehouse) => ({ value: warehouse.id, label: `${warehouse.name} (${warehouse.code})` })),
[warehouses],
const { data: items = [], isLoading } = useImportTrainItems(train.scheduleId);
// A train only ever unloads at the warehouse actually sitting at its
// destination station — Indode's train never offers Sebeta's warehouse.
const scopedWarehouses = useMemo(
() => warehousesAtStation(warehouses, train.destinationStationId),
[warehouses, train.destinationStationId],
);
const warehouseOptions = useMemo(
() => scopedWarehouses.map((warehouse) => ({ value: warehouse.id, label: `${warehouse.name} (${warehouse.code})` })),
[scopedWarehouses],
);
// With exactly one warehouse at the station there is nothing to choose —
// pre-fill it so staff only has to pick yard/zone, not re-discover Indode.
useEffect(() => {
if (scopedWarehouses.length !== 1) return;
const onlyWarehouseId = scopedWarehouses[0].id;
items.filter(isUnloadPending).forEach((item) => {
if (!assignments[item.bookingId]?.warehouseId) {
onAssignmentChange(item.bookingId, { ...assignments[item.bookingId], warehouseId: onlyWarehouseId });
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scopedWarehouses, items]);
// Once a booking's warehouse is known, its yard (and then zone) follow from
// what the cargo actually is — a Wheat booking only ever has one candidate
// yard (Dry Bulk) once Indode's real yard layout is configured, so staff
// never see a picker for something that isn't actually a choice.
useEffect(() => {
items.filter(isUnloadPending).forEach((item) => {
const draft = assignments[item.bookingId];
if (!draft?.warehouseId) return;
if (!draft.yardId) {
const candidateYards = yardsForBooking(yards, {
warehouseId: draft.warehouseId,
freightType: item.freightType,
tradeDirection: 'IMPORT',
cargoTypeCode: item.cargoTypeCode,
});
if (candidateYards.length === 1) {
onAssignmentChange(item.bookingId, { ...draft, yardId: candidateYards[0].id });
}
return;
}
if (!draft.zoneId) {
const candidateZones = zones.filter((zone) => zone.yardId === draft.yardId);
if (candidateZones.length === 1) {
onAssignmentChange(item.bookingId, { ...draft, zoneId: candidateZones[0].id });
}
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [assignments, items, yards, zones]);
useEffect(() => {
const pending = items.filter(isUnloadPending);
@@ -135,12 +179,17 @@ function ImportTrainDetailRows({
<Table.Tbody>
{items.map((item: ImportTrainItem) => {
const draft = assignments[item.bookingId] ?? {};
const { yardTypes, zoneTypes } = locationTypesForFreight(item.freightType);
const yardOptions = yards
.filter((yard) => yard.warehouseId === draft.warehouseId && yardTypes.includes(yard.type))
.map((yard) => ({ value: yard.id, label: `${yard.name} (${yard.code})` }));
const yardOptions = yardsForBooking(yards, {
warehouseId: draft.warehouseId,
freightType: item.freightType,
tradeDirection: 'IMPORT',
cargoTypeCode: item.cargoTypeCode,
}).map((yard) => ({ value: yard.id, label: `${yard.name} (${yard.code})` }));
// The yard is already scoped to what this cargo can go into — a
// zone's own type always matches its parent yard's purpose (see the
// Indode seed migration), so no separate zone-type filter is needed.
const zoneOptions = zones
.filter((zone) => zone.yardId === draft.yardId && zoneTypes.includes(zone.type))
.filter((zone) => zone.yardId === draft.yardId)
.map((zone) => ({ value: zone.id, label: `${zone.name} (${zone.code})` }));
const pending = isUnloadPending(item);
@@ -395,7 +444,7 @@ export default function ArrivalQueuePage() {
<Table.Tr>
<Table.Td colSpan={10} bg="var(--mantine-color-gray-0)">
<ImportTrainDetailRows
scheduleId={train.scheduleId}
train={train}
warehouses={warehouses}
yards={yards}
zones={zones}

View File

@@ -170,6 +170,7 @@ import {
} from "./payments.service";
import {
routesService,
type RouteListFilters,
type RouteRecord,
type SaveRoutePayload,
type YardRef,
@@ -1507,6 +1508,13 @@ export const api = {
(input) => ["routes", input?.status ?? "all"],
),
listPaged: endpoint<RouteListFilters, PaginatedResponse<RouteRecord>>(
"routes",
"listPaged",
(filters) => routesService.getPaged(filters).then((r) => r.data),
(filters) => ["routes", "paged", filters],
),
yards: endpoint<void, YardRef[]>(
"routes",
"yards",
@@ -1624,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],
),
@@ -2082,6 +2098,16 @@ export const api = {
({ slug, filters }) => [...QUERY_KEYS.FLEET.list(slug), filters ?? {}],
),
listPaged: endpoint<
{ slug: FleetResourceSlug; filters?: FleetListFilters },
PaginatedResponse<FleetRecord>
>(
"fleet",
"listPaged",
({ slug, filters }) => fleetService.listPaged(slug, filters),
({ slug, filters }) => [...QUERY_KEYS.FLEET.list(slug), "paged", filters ?? {}],
),
create: endpoint<
{ slug: FleetResourceSlug; data: Record<string, unknown> },
unknown

View File

@@ -332,6 +332,18 @@ export const bookingsService = {
return unwrap(response.data) as Freight.ClearanceView;
},
/** GL ET asks Djibouti to name the officer handling the shipment in transit. */
requestTransitAssignee: (id: string, note?: string) =>
postBooking<BookingDetail>(B.CLEARANCE_TRANSIT_ASSIGNEE_REQUEST(id), {
note,
}),
/** GL Djibouti picks (or changes) that officer — unblocks the declaration. */
assignTransitAssignee: (id: string, transitAgentId: string) =>
postBooking<BookingDetail>(B.CLEARANCE_TRANSIT_ASSIGNEE_ASSIGN(id), {
transitAgentId,
}),
uploadDeclaration: async (
id: string,
files: Record<string, File | null>,
@@ -370,6 +382,22 @@ export const bookingsService = {
return unwrap(response.data) as BookingDetail;
},
uploadDraftDeclaration: async (
id: string,
files: File[],
price: number,
currency: string,
): Promise<BookingDetail> => {
const form = new FormData();
files.forEach((file, index) => form.append(`draft_declaration_${index}`, file));
form.append("price", String(price));
form.append("currency", currency);
const response = await client.post(B.CLEARANCE_DRAFT_DECLARATION(id), form, {
headers: { "Content-Type": "multipart/form-data" },
});
return unwrap(response.data) as BookingDetail;
},
finalizePreClearance: (id: string) =>
postBooking<BookingDetail>(B.CLEARANCE_FINALIZE_PRE(id)),

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.
@@ -323,10 +331,10 @@ export const contractsService = {
{ note },
),
/** GL Djibouti names (or changes) that officer — unblocks the declaration. */
assignTransitAssignee: (id: string, assignee: string) =>
/** GL Djibouti picks (or changes) that officer — unblocks the declaration. */
assignTransitAssignee: (id: string, transitAgentId: string) =>
postContract<Freight.IContract>(C.CLEARANCE_TRANSIT_ASSIGNEE_ASSIGN(id), {
assignee,
transitAgentId,
}),
/**
@@ -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

@@ -1,3 +1,5 @@
import type { PaginatedResponse } from "@edr/types";
import { cargoService, type Cargo } from "@/services/cargoService";
import { containerService, type Container } from "@/services/containerService";
import {
@@ -21,13 +23,27 @@ 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),
drivers: (filters) => driversService.getAll(filters ?? {}).then((r) => r.data),
};
/**
* Server-paginated slugs. Everything else still lists in full and pages in the
* browser — add an entry here once its API grows a `/paged` endpoint.
*/
const pagedHandlers: Partial<
Record<FleetResourceSlug, (filters: FleetListFilters) => Promise<PaginatedResponse<FleetRecord>>>
> = {
locomotives: (filters) => locomotivesService.getPaged(filters).then((r) => r.data),
wagons: (filters) => wagonService.getAll(filters).then((r) => r.data),
};
export const isFleetServerPaginated = (slug: FleetResourceSlug): boolean =>
slug in pagedHandlers;
const createHandlers: Record<FleetResourceSlug, (data: Record<string, unknown>) => Promise<unknown>> = {
locomotives: (data) => locomotivesService.create(data),
trains: (data) => trainService.create(data),
@@ -63,6 +79,12 @@ const removeHandlers: Record<FleetResourceSlug, (id: string) => Promise<unknown>
export const fleetService = {
list: (slug: FleetResourceSlug, filters?: FleetListFilters) => listHandlers[slug](filters),
/** Only for slugs in `pagedHandlers` — guard with `isFleetServerPaginated`. */
listPaged: (slug: FleetResourceSlug, filters: FleetListFilters = {}) => {
const handler = pagedHandlers[slug];
if (!handler) throw new Error(`Fleet resource "${slug}" has no paginated list endpoint`);
return handler(filters);
},
create: (slug: FleetResourceSlug, data: Record<string, unknown>) => createHandlers[slug](data),
update: (slug: FleetResourceSlug, id: string, data: Record<string, unknown>) =>
updateHandlers[slug](id, data),

View File

@@ -0,0 +1,60 @@
import type { Freight } from "@edr/types";
import { api as client } from "../auth/http";
import { unwrap } from "@/utils/endpoint";
import { URL_CONSTANTS } from "@/constants/URLS";
const X = URL_CONSTANTS.GL_EXCHANGE;
export interface GlExchangeUpload {
title: string;
visibleToCustomer: boolean;
file: File;
}
export interface GlExchangeEdit {
title?: string;
visibleToCustomer?: boolean;
/** Optional replacement bytes — omit to keep the stored file. */
file?: File | null;
}
const multipart = { headers: { "Content-Type": "multipart/form-data" } };
/** GL Ethiopia ↔ GL Djibouti shared documents for one booking or contract. */
export const glExchangeService = {
list: async (entityId: string): Promise<Freight.GlExchangeDocument[]> => {
const response = await client.get(X.FOR_ENTITY(entityId));
return (unwrap(response.data) ?? []) as Freight.GlExchangeDocument[];
},
upload: async (
entityId: string,
input: GlExchangeUpload,
): Promise<Freight.GlExchangeDocument> => {
const form = new FormData();
form.append("file", input.file);
form.append("title", input.title);
form.append("visibleToCustomer", String(input.visibleToCustomer));
const response = await client.post(X.FOR_ENTITY(entityId), form, multipart);
return unwrap(response.data) as Freight.GlExchangeDocument;
},
update: async (
documentId: string,
input: GlExchangeEdit,
): Promise<Freight.GlExchangeDocument> => {
const form = new FormData();
if (input.file) form.append("file", input.file);
if (input.title != null) form.append("title", input.title);
if (input.visibleToCustomer != null) {
form.append("visibleToCustomer", String(input.visibleToCustomer));
}
const response = await client.patch(X.DOCUMENT(documentId), form, multipart);
return unwrap(response.data) as Freight.GlExchangeDocument;
},
remove: async (documentId: string): Promise<void> => {
await client.delete(X.DOCUMENT(documentId));
},
};

View File

@@ -75,6 +75,9 @@ export interface LastMileRecord {
/** Per-truck arrival / exit, stamped by the warehouse weighing steps. */
arrivedAt?: string | null;
departedAt?: string | null;
/** This truck's own detention window (destination arrival → released). */
destinationArrivedAt?: string | null;
returnedAt?: string | null;
grossWeightTons?: number | null;
netWeightTons?: number | null;
vehicle?: LastMileVehicle | null;
@@ -143,4 +146,13 @@ export const lastMileService = {
/** Preview the truck-detention charge for a last-mile leg. */
truckDetentionPreview: (id: string) =>
api.get<FeePreview>(`${LM.BASE}/${id}/truck-detention-preview`),
/** Per-truck detention windows — each truck has its own clock. */
setDetentionTimes: (
id: string,
trucks: Array<{
vehicleId: string;
destinationArrivedAt?: string | null;
returnedAt?: string | null;
}>,
) => api.post<LastMileRecord>(`${LM.BASE}/${id}/detention-times`, { trucks }),
};

View File

@@ -1,3 +1,5 @@
import type { PaginatedResponse } from '@edr/types';
import { api as apiClient } from '../auth/http';
import { URL_CONSTANTS } from '@/constants/URLS';
@@ -19,8 +21,31 @@ export interface LocomotiveListFilters {
excludeCoupled?: boolean;
/** With excludeCoupled: keep THIS train's own coupled locos in the list. */
excludeTrainId?: string;
/** Free-text over code + name — only honoured by `getPaged`. */
search?: string;
/** Registration day range (YYYY-MM-DD), both ends inclusive. */
createdFrom?: string;
createdTo?: string;
/** Only read by `getPaged`. */
page?: number;
pageSize?: number;
}
const locomotiveListQuery = (filters: LocomotiveListFilters): string => {
const params = new URLSearchParams();
if (filters.status) params.set('status', filters.status);
if (filters.currentYardId) params.set('currentYardId', filters.currentYardId);
if (filters.excludeCoupled) params.set('excludeCoupled', 'true');
if (filters.excludeTrainId) params.set('excludeTrainId', filters.excludeTrainId);
if (filters.search?.trim()) params.set('search', filters.search.trim());
if (filters.createdFrom) params.set('createdFrom', filters.createdFrom);
if (filters.createdTo) params.set('createdTo', filters.createdTo);
if (filters.page) params.set('page', String(filters.page));
if (filters.pageSize) params.set('pageSize', String(filters.pageSize));
const qs = params.toString();
return qs ? `?${qs}` : '';
};
export interface Locomotive {
id: string;
code: string;
@@ -45,17 +70,15 @@ export type SaveLocomotivePayload = Omit<
>;
export const locomotivesService = {
getAll: (filters: LocomotiveListFilters = {}) => {
const params = new URLSearchParams();
if (filters.status) params.set('status', filters.status);
if (filters.currentYardId) params.set('currentYardId', filters.currentYardId);
if (filters.excludeCoupled) params.set('excludeCoupled', 'true');
if (filters.excludeTrainId) params.set('excludeTrainId', filters.excludeTrainId);
const qs = params.toString();
return apiClient.get<Locomotive[]>(
`${URL_CONSTANTS.LOCOMOTIVES.BASE}${qs ? `?${qs}` : ''}`,
);
},
getAll: (filters: LocomotiveListFilters = {}) =>
apiClient.get<Locomotive[]>(
`${URL_CONSTANTS.LOCOMOTIVES.BASE}${locomotiveListQuery(filters)}`,
),
/** Same filters as `getAll` plus search, server-paginated ({items, meta}). */
getPaged: (filters: LocomotiveListFilters = {}) =>
apiClient.get<PaginatedResponse<Locomotive>>(
`${URL_CONSTANTS.LOCOMOTIVES.BASE}/paged${locomotiveListQuery(filters)}`,
),
getById: (id: string) => apiClient.get<Locomotive>(URL_CONSTANTS.LOCOMOTIVES.BY_ID(id)),
create: (data: Partial<SaveLocomotivePayload>) =>
apiClient.post(URL_CONSTANTS.LOCOMOTIVES.BASE, data),

View File

@@ -1,3 +1,5 @@
import type { PaginatedResponse } from '@edr/types';
import { api as apiClient } from '../auth/http';
import { URL_CONSTANTS } from '@/constants/URLS';
@@ -78,9 +80,22 @@ export const ROUTE_STATUS_OPTIONS: Array<{ value: RouteStatus; label: string }>
{ value: 'STOP_WORKING', label: 'Stop working' },
];
export interface RouteListFilters {
status?: RouteStatus;
search?: string;
page?: number;
pageSize?: number;
}
export const routesService = {
getAll: (params?: { status?: RouteStatus; search?: string }) =>
apiClient.get<RouteRecord[]>(URL_CONSTANTS.ROUTES.BASE, { params }),
/** Same filters as `getAll`, server-paginated ({items, meta}). */
getPaged: (params: RouteListFilters = {}) =>
apiClient.get<PaginatedResponse<RouteRecord>>(
`${URL_CONSTANTS.ROUTES.BASE}/paged`,
{ params },
),
getById: (id: string) => apiClient.get<RouteRecord>(URL_CONSTANTS.ROUTES.BY_ID(id)),
create: (data: SaveRoutePayload) => apiClient.post(URL_CONSTANTS.ROUTES.BASE, data),
update: (id: string, data: Partial<SaveRoutePayload>) =>

View File

@@ -94,6 +94,7 @@ const RESOURCE_BASE: Record<RuleEngineResourceSlug, string> = {
"shipping-lines": URL_CONSTANTS.RULE_ENGINE.SHIPPING_LINES,
rates: URL_CONSTANTS.RULE_ENGINE.RATES,
"approval-rules": URL_CONSTANTS.RULE_ENGINE.APPROVAL_RULES,
"transit-agents": URL_CONSTANTS.RULE_ENGINE.TRANSIT_AGENTS,
};
const byIdPath = (resource: RuleEngineResourceSlug, id: string): string => {

View File

@@ -0,0 +1,20 @@
import { api } from "../auth/http";
import { URL_CONSTANTS } from "../constants/URLS";
export interface TransitAgent {
id: string;
name: string;
validFrom: string;
validTo: string;
isActive: boolean;
}
export const transitAgentsService = {
/** Active + currently inside its validity window — the assignment dropdown. */
async listAssignable() {
const response = await api.get<TransitAgent[]>(
URL_CONSTANTS.RULE_ENGINE.TRANSIT_AGENTS_ASSIGNABLE,
);
return response.data;
},
};

View File

@@ -41,10 +41,35 @@ export interface WagonListFilters {
currentYardId?: string;
wagonTypeId?: string;
trainId?: string;
/** Drop wagons already coupled to a built train — only loose ones can be taken. */
unassigned?: boolean;
/** Run number — matches a wagon whose export OR import run equals it. */
trainNumber?: string;
/** Registration day range (YYYY-MM-DD), both ends inclusive. */
createdFrom?: string;
createdTo?: string;
/** Only read by `getPaged`. */
page?: number;
pageSize?: number;
}
const wagonListQuery = (filters: WagonListFilters): string => {
const params = new URLSearchParams();
if (filters.search?.trim()) params.set('search', filters.search.trim());
if (filters.status) params.set('status', filters.status);
if (filters.currentYardId) params.set('currentYardId', filters.currentYardId);
if (filters.wagonTypeId) params.set('wagonTypeId', filters.wagonTypeId);
if (filters.trainId) params.set('trainId', filters.trainId);
if (filters.unassigned) params.set('unassigned', 'true');
if (filters.trainNumber) params.set('trainNumber', filters.trainNumber);
if (filters.createdFrom) params.set('createdFrom', filters.createdFrom);
if (filters.createdTo) params.set('createdTo', filters.createdTo);
if (filters.page) params.set('page', String(filters.page));
if (filters.pageSize) params.set('pageSize', String(filters.pageSize));
const qs = params.toString();
return qs ? `?${qs}` : '';
};
/**
* One row of the wagon_movements ledger: every physical relocation between
* yards — a booking's loaded leg, an empty reposition ride, or a manual staff
@@ -70,21 +95,28 @@ export interface WagonMovementRecord {
}
export const wagonService = {
getAll: (filters: WagonListFilters = {}) => {
const params = new URLSearchParams();
if (filters.search?.trim()) params.set('search', filters.search.trim());
if (filters.status) params.set('status', filters.status);
if (filters.currentYardId) params.set('currentYardId', filters.currentYardId);
if (filters.wagonTypeId) params.set('wagonTypeId', filters.wagonTypeId);
if (filters.trainId) params.set('trainId', filters.trainId);
if (filters.trainNumber) params.set('trainNumber', filters.trainNumber);
const qs = params.toString();
return apiClient.get<Wagon[]>(`/wagons${qs ? `?${qs}` : ''}`);
/** One page ({items, meta}); 10 rows unless `pageSize` says otherwise. */
getAll: (filters: WagonListFilters = {}) =>
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

@@ -91,6 +91,7 @@ export interface ContainerItem {
contractId: string | null;
hasLastMile: boolean;
handoverSigned: boolean;
inspectionStatus: string | null;
}
/** A pre-dispatch EXPORT train that has inventory waiting to be loaded. */
@@ -136,6 +137,8 @@ const cleanParams = (params: object) =>
/** An assigned EDR last-mile truck, shaped for the arrival/exit weighing prefill. */
export interface LastMileArrivalTruck {
/** The last-mile leg this truck belongs to — feed straight into lastMileService.truckDetentionPreview(lastMileId). */
lastMileId: string;
vehicleId: string;
truckPlateNumber: string | null;
trailerPlateNumber: string | null;

View File

@@ -25,6 +25,9 @@ axiosInstance.interceptors.request.use((config) => {
}
// X-Requested-With prevents CSRF via browser-native form/fetch without custom headers
config.headers["X-Requested-With"] = "XMLHttpRequest";
// Tells the backend which app is asking, so /auth/login can reject
// cross-audience credentials (EDRFREIGHT-415).
config.headers["X-Client-App"] = "backoffice";
return config;
});

View File

@@ -10,7 +10,8 @@ export type RuleEngineResourceSlug =
| "yard-distances"
| "shipping-lines"
| "rates"
| "approval-rules";
| "approval-rules"
| "transit-agents";
/**
* Mirrors the API's shared `PaginationMeta` (@edr/types). The `has*` flags are

View File

@@ -411,6 +411,12 @@ export type BookingAllocationStatus =
| "FAILED";
export interface BatchBoardBookingDetail extends BatchBoardBooking {
/**
* 0-based booking-window cycle the booking entered the pool in. Ranking is
* per-cycle: an earlier cycle always boards before a later one regardless of
* priority score. Null while the contract is still pending.
*/
windowCycleNo: number | null;
fullyExecutedAt: string | null;
selectedForBatchAt: string | null;
allocationStatus: BookingAllocationStatus;

View File

@@ -118,12 +118,19 @@ export interface WarehouseZone {
isActive: boolean;
}
/** IMPORT | EXPORT | BOTH | null. Only meaningful for CONTAINER_YARD — everything else takes cargo either way. */
export type WarehouseYardDirection = 'IMPORT' | 'EXPORT' | 'BOTH';
export interface WarehouseYard {
id: string;
warehouseId: string;
name: string;
code: string;
type: WarehouseYardType;
/** For CONTAINER_YARD: which direction this stack serves. BOTH/null on a container yard means "not a customer cargo yard" (service/equipment), not "any direction". */
direction?: WarehouseYardDirection | null;
/** Cargo types this yard accepts. Empty/absent = open to any cargo type of this yard's structural type. */
cargoTypes?: Array<{ id: string; code: string }>;
capacityWeight: number | null;
capacityContainers: number | null;
maxWeight: number | null;
@@ -518,6 +525,8 @@ export interface ImportTrain {
route: string | null;
origin: string | null;
destination: string | null;
/** freight.yards.id the train is heading to — matches Warehouse.stationId, so the unload picker can be scoped to the warehouse actually at this station. */
destinationStationId: string | null;
departureTime?: string | null;
arrivalTime: string | null;
totalBookings: number;
@@ -632,6 +641,8 @@ export interface ImportTrainItem {
freightType: string | null;
containerNumber: string | null;
cargoType: string | null;
/** Cargo type CODE (e.g. "WHEAT"), for matching against a yard's configured cargo types — `cargoType` above is the display name. */
cargoTypeCode: string | null;
weight: number | null;
arrivalTime: string | null;
currentStatus: string | null;
@@ -856,10 +867,16 @@ export interface FeePreview {
billableUnits: number;
amount: number;
tiers?: FeePreviewTier[];
/** Truck detention: per-vehicle-type breakdown. */
/** Truck detention: one row per truck — each has its own window and rule. */
groups?: Array<{
assignmentId?: string | null;
vehicleId?: string | null;
plateNumber?: string | null;
vehicleType: string | null;
truckCount: number;
startDate?: string | null;
endDate?: string | null;
endIsOpen?: boolean;
chargeableDays: number;
ratePerDay: number;
amount: number;