mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 15:48:11 +00:00
Merge branch 'dev' into freight/nati-1
This commit is contained in:
@@ -168,8 +168,8 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
icon: <FileText />,
|
||||
permission: FREIGHT_PERMS.bookings.view,
|
||||
},
|
||||
// Operations hub: clearance-document review for contracts WITHOUT
|
||||
// customs clearing (contract-level for one-time, per-booking for general).
|
||||
// Operations hub: per-shipment clearance-document review for services
|
||||
// WITHOUT customs clearing (self-clearance) — bookings only.
|
||||
{
|
||||
label: "Clearance Documents",
|
||||
href: "/dashboard/contracts/clearance-documents",
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
: "—";
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
|
||||
@@ -30,7 +30,7 @@ const parseError = (error: unknown, fallback: string) => {
|
||||
|
||||
/**
|
||||
* Step one of the Train Builder: pick the yard it is being assembled in and
|
||||
* couple at least two locomotives from that yard. The train code is assigned by
|
||||
* couple at least one locomotive from that yard. The train code is assigned by
|
||||
* the system. Wagons are attached afterwards on the composition page.
|
||||
*/
|
||||
export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrainModalProps) {
|
||||
@@ -86,9 +86,9 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
|
||||
}, [opened]);
|
||||
|
||||
const handleBuild = async () => {
|
||||
if (!yardId || locomotiveIds.length < 2) {
|
||||
if (!yardId || locomotiveIds.length < 1) {
|
||||
toast({
|
||||
title: "Pick a yard and couple at least two locomotives",
|
||||
title: "Pick a yard and couple at least one locomotive",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
@@ -185,17 +185,15 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
|
||||
/>
|
||||
<MultiSelect
|
||||
label="Locomotives"
|
||||
description="A train must be pulled by at least two locomotives (front and back). First pick becomes the lead."
|
||||
placeholder={yardId ? "Select at least two locomotives" : "Select a yard first"}
|
||||
description="A train must be pulled by at least one locomotive. First pick becomes the lead."
|
||||
placeholder={yardId ? "Select at least one locomotive" : "Select a yard first"}
|
||||
data={locomotiveOptions}
|
||||
value={locomotiveIds}
|
||||
onChange={setLocomotiveIds}
|
||||
searchable
|
||||
disabled={!yardId}
|
||||
error={
|
||||
locomotiveIds.length > 0 && locomotiveIds.length < 2
|
||||
? "Select at least two locomotives"
|
||||
: undefined
|
||||
locomotiveIds.length < 1 ? "Select at least one locomotive" : undefined
|
||||
}
|
||||
nothingFoundMessage={
|
||||
yardId ? "No available locomotives in this yard" : "Select a yard first"
|
||||
|
||||
@@ -16,7 +16,7 @@ const parseError = (error: unknown, fallback: string) => {
|
||||
return fallback;
|
||||
};
|
||||
|
||||
/** Swap the locomotive set of a built train (minimum 2, same-yard rule). */
|
||||
/** Swap the locomotive set of a built train (minimum 1, same-yard rule). */
|
||||
export default function ChangeLocomotivesModal({
|
||||
composition,
|
||||
opened,
|
||||
@@ -74,8 +74,8 @@ export default function ChangeLocomotivesModal({
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!composition) return;
|
||||
if (locomotiveIds.length < 2) {
|
||||
toast({ title: "A train needs at least two locomotives", variant: "destructive" });
|
||||
if (locomotiveIds.length < 1) {
|
||||
toast({ title: "A train needs at least one locomotive", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -112,9 +112,7 @@ export default function ChangeLocomotivesModal({
|
||||
onChange={setLocomotiveIds}
|
||||
searchable
|
||||
error={
|
||||
locomotiveIds.length > 0 && locomotiveIds.length < 2
|
||||
? "Select at least two locomotives"
|
||||
: undefined
|
||||
locomotiveIds.length < 1 ? "Select at least one locomotive" : undefined
|
||||
}
|
||||
nothingFoundMessage="No available locomotives in this yard"
|
||||
/>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import type { TrainCompositionWagon } from "@/services/trainBuilder.service";
|
||||
import { wagonTypeColor } from "./trainStatus";
|
||||
|
||||
/** Reparent dragged row to body — fixes position:fixed inside transformed parents. */
|
||||
const PortalAwareRow = ({
|
||||
@@ -58,11 +59,36 @@ export default function ConsistWagonList({
|
||||
);
|
||||
}
|
||||
|
||||
// Legend of the types actually coupled, in consist order — the colour code is
|
||||
// only readable if the row tints are keyed somewhere.
|
||||
const legend = [
|
||||
...new Map(
|
||||
wagons
|
||||
.filter((w) => w.wagonType)
|
||||
.map((w) => [w.wagonType!.code, w.wagonType!]),
|
||||
).values(),
|
||||
];
|
||||
|
||||
return (
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<Droppable droppableId="train-consist-wagons" isDropDisabled={!editable || busy}>
|
||||
{(dropProvided) => (
|
||||
<Stack gap="xs" ref={dropProvided.innerRef} {...dropProvided.droppableProps}>
|
||||
{legend.length > 1 ? (
|
||||
<Group gap={6} wrap="wrap">
|
||||
{legend.map((type) => (
|
||||
<Badge
|
||||
key={type.code}
|
||||
size="sm"
|
||||
radius="sm"
|
||||
variant="light"
|
||||
color={wagonTypeColor(type.code)}
|
||||
>
|
||||
{type.code} · {type.name}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
) : null}
|
||||
{wagons.map((wagon, index) => (
|
||||
<Draggable
|
||||
key={wagon.id}
|
||||
@@ -97,8 +123,8 @@ export interface ConsistWagonListProps {
|
||||
editable: boolean;
|
||||
onReorder: (wagonIds: string[]) => void;
|
||||
onRemove: (wagonId: string) => void;
|
||||
/** Detach the wagon and move it to MAINTENANCE status. */
|
||||
onMaintenance: (wagonId: string) => void;
|
||||
/** Detach the wagon and move it to MAINTENANCE status (page confirms first). */
|
||||
onMaintenance: (wagon: TrainCompositionWagon) => void;
|
||||
busy?: boolean;
|
||||
}
|
||||
|
||||
@@ -119,8 +145,10 @@ function WagonRow({
|
||||
editable: boolean;
|
||||
busy: boolean;
|
||||
onRemove: (wagonId: string) => void;
|
||||
onMaintenance: (wagonId: string) => void;
|
||||
onMaintenance: (wagon: TrainCompositionWagon) => void;
|
||||
}) {
|
||||
const color = wagonTypeColor(wagon.wagonType?.code);
|
||||
|
||||
return (
|
||||
<PortalAwareRow snapshot={snapshot}>
|
||||
<Group
|
||||
@@ -132,9 +160,14 @@ function WagonRow({
|
||||
p="sm"
|
||||
style={{
|
||||
...dragProvided.draggableProps.style,
|
||||
border: "1px solid var(--mantine-color-gray-3)",
|
||||
border: `1px solid var(--mantine-color-${color}-2)`,
|
||||
borderLeft: `4px solid var(--mantine-color-${color}-5)`,
|
||||
borderRadius: "var(--mantine-radius-md)",
|
||||
background: snapshot.isDragging ? "var(--mantine-color-gray-0)" : "white",
|
||||
// Tinted by wagon type so a mixed consist is scannable at a glance;
|
||||
// the drag state keeps its own neutral lift.
|
||||
background: snapshot.isDragging
|
||||
? "white"
|
||||
: `var(--mantine-color-${color}-0)`,
|
||||
boxShadow: snapshot.isDragging ? "0 8px 24px rgba(0, 0, 0, 0.12)" : undefined,
|
||||
cursor: editable ? (snapshot.isDragging ? "grabbing" : "grab") : "default",
|
||||
userSelect: "none",
|
||||
@@ -145,13 +178,20 @@ function WagonRow({
|
||||
<GripVertical size={18} />
|
||||
</Box>
|
||||
) : null}
|
||||
<Badge variant="light" color="gray" size="sm">
|
||||
<Badge variant="filled" color={color} size="sm">
|
||||
{index + 1}
|
||||
</Badge>
|
||||
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="sm" fw={600} ff="monospace" truncate>
|
||||
{wagon.wagonNumber}
|
||||
</Text>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm" fw={600} ff="monospace" truncate>
|
||||
{wagon.wagonNumber}
|
||||
</Text>
|
||||
{wagon.wagonType ? (
|
||||
<Badge variant="light" color={color} size="xs" radius="sm">
|
||||
{wagon.wagonType.code}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{wagon.wagonType
|
||||
? `${wagon.wagonType.name} · ${wagon.wagonType.capacityTons}T cap · ${wagon.wagonType.lengthMeters}m`
|
||||
@@ -165,7 +205,7 @@ function WagonRow({
|
||||
variant="subtle"
|
||||
color="orange"
|
||||
disabled={busy}
|
||||
onClick={() => onMaintenance(wagon.id)}
|
||||
onClick={() => onMaintenance(wagon)}
|
||||
aria-label={`Send wagon ${wagon.wagonNumber} to maintenance`}
|
||||
>
|
||||
<Wrench size={16} />
|
||||
|
||||
@@ -56,6 +56,58 @@ export const locomotiveStatusLabel = (status: string): string =>
|
||||
.replace(/_/g, " ")
|
||||
.replace(/^\w/, (c) => c.toUpperCase());
|
||||
|
||||
/**
|
||||
* Hues for the wagon-type color code. No red or gray — red reads as a fault on
|
||||
* a consist row, gray is the "unknown type" fallback.
|
||||
*/
|
||||
const WAGON_TYPE_COLORS = [
|
||||
"blue",
|
||||
"teal",
|
||||
"grape",
|
||||
"orange",
|
||||
"cyan",
|
||||
"indigo",
|
||||
"pink",
|
||||
"lime",
|
||||
"violet",
|
||||
"yellow",
|
||||
];
|
||||
|
||||
/**
|
||||
* Fixed hue per seeded wagon-type code. Related families sit on neighbouring
|
||||
* hues (gondolas blue/indigo, hoppers grape/violet, flats teal/lime) so a
|
||||
* consist reads as groups, not confetti. Explicit rather than hashed because
|
||||
* hashing 10 codes into 10 hues collides — and two types sharing a colour is
|
||||
* exactly what a colour code must not do.
|
||||
*/
|
||||
const WAGON_TYPE_CODE_COLORS: Record<string, string> = {
|
||||
CW3: "blue", // Gondola open
|
||||
CW4: "indigo", // Gondola covered
|
||||
KW2: "grape", // Hopper covered
|
||||
KW3: "violet", // Hopper open
|
||||
NW5: "teal", // Flat
|
||||
NW6: "lime", // Flat (long)
|
||||
NW7: "pink", // Double deck sedan
|
||||
BW1: "cyan", // Refrigerated
|
||||
GW2: "orange", // Tank
|
||||
PW2: "yellow", // Box
|
||||
};
|
||||
|
||||
/**
|
||||
* Stable hue per wagon-type code. Unseeded codes fall back to a hash so a new
|
||||
* type still gets a consistent colour instead of collapsing to gray.
|
||||
*/
|
||||
export const wagonTypeColor = (code?: string | null): string => {
|
||||
if (!code) return "gray";
|
||||
const seeded = WAGON_TYPE_CODE_COLORS[code];
|
||||
if (seeded) return seeded;
|
||||
let hash = 0;
|
||||
for (let i = 0; i < code.length; i++) {
|
||||
hash = (hash * 31 + code.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
return WAGON_TYPE_COLORS[hash % WAGON_TYPE_COLORS.length]!;
|
||||
};
|
||||
|
||||
/** Badge color per trade direction (Mantine palette keys). */
|
||||
export const directionColor = (direction?: string | null): string =>
|
||||
direction === "IMPORT" ? "blue" : direction === "EXPORT" ? "orange" : "gray";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 },
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
: {},
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)),
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
};
|
||||
@@ -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 }),
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>) =>
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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`),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user