Merge branch 'dev' into freight/nati-1

This commit is contained in:
Nathnael
2026-07-29 13:39:20 +00:00
269 changed files with 12185 additions and 5087 deletions

View File

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

View File

@@ -1,6 +1,6 @@
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { useNavigate, useParams } from "react-router-dom";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import {
Alert,
Badge,
@@ -50,9 +50,20 @@ export default function DocumentClearanceDetailPage() {
const params = useParams<{ id?: string; bookingId?: string }>();
const id = params.id ?? params.bookingId;
const navigate = useNavigate();
const location = useLocation();
const { user } = useAuth();
const { view, viewer } = useFileViewer();
// The same shipment is opened from several worklists (GL Ethiopia clearance,
// the Operations clearance-documents hub, shipment requests…), so "back" is
// whichever list sent us here. Deep links have no sender: fall back to the
// hub this user actually works in.
const backTo =
(location.state as { from?: string } | null)?.from ??
(hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions)
? "/dashboard/contracts/clearance"
: "/dashboard/contracts/clearance-documents");
const { data: booking } = useBookingDetail(id);
const {
data: clearance,
@@ -95,10 +106,10 @@ export default function DocumentClearanceDetailPage() {
}, [clearance]);
const reference = booking?.reference ?? "Clearance";
// Phased customs clearance runs on every contract booking now — ONE_TIME and
// GENERAL alike; the persisted phase is what marks the workflow as running.
const isPhasedGeneral =
Boolean(booking?.customsClearingEnabled) &&
booking?.contractKind === "GENERAL" &&
Boolean(clearance?.phase);
Boolean(booking?.customsClearingEnabled) && Boolean(clearance?.phase);
// Bare initiated instance whose clearance is done: GL completes the booking
// (container numbers, VGM, shipment day) via the completion form.
@@ -147,9 +158,9 @@ export default function DocumentClearanceDetailPage() {
<PageContainer>
<PageHeader
title="Clearance not found"
backTo="/dashboard/contracts/clearance"
backTo={backTo}
breadcrumbs={[
{ label: "Document Clearance", href: "/dashboard/contracts/clearance" },
{ label: "Document Clearance", href: backTo },
{ label: "Not found" },
]}
/>
@@ -165,9 +176,9 @@ export default function DocumentClearanceDetailPage() {
<Stack gap="lg">
<PageHeader
title={reference}
backTo="/dashboard/contracts/clearance"
backTo={backTo}
breadcrumbs={[
{ label: "Document Clearance", href: "/dashboard/contracts/clearance" },
{ label: "Document Clearance", href: backTo },
{ label: reference },
]}
meta={
@@ -243,6 +254,7 @@ export default function DocumentClearanceDetailPage() {
milestones={bookingMilestones}
showOpsTabs={Boolean(id)}
showWorkflowFilesTab={isPhasedGeneral}
exchangeEntityId={id}
tradeDirection={booking?.tradeDirection ?? "IMPORT"}
workflowFiles={workflowFiles}
onViewFile={view}

View File

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

View File

@@ -1,4 +1,3 @@
import { directionLabel } from "@/lib/utils";
import {
ActionIcon,
Box,
@@ -6,36 +5,22 @@ import {
Group,
Select,
Stack,
Tabs,
Text,
TextInput,
ThemeIcon,
} from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { useDebouncedValue } from "@mantine/hooks";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import {
ArrowRight,
FileText,
Inbox,
RefreshCw,
Repeat,
Search,
User,
X,
} from "lucide-react";
import { FileText, Inbox, RefreshCw, Search, User, X } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink";
import { bookingTable } from "@/components/bookings/booking-ui.styles";
import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge";
import { PageContainer, PageHeader } from "@/components/page";
import {
toContractListRow,
type ContractListRow,
} from "@/features/contracts/mapContractListRow";
import { bookingsService } from "@/services/bookings.service";
import { contractsService } from "@/services/contracts.service";
import type { BookingDetail } from "@/types/booking";
import {
Badge,
@@ -46,45 +31,16 @@ import {
} from "@edr/ui-common";
/**
* Operations "Clearance Documents" hub — worklist for clearance-document
* review on contracts WITHOUT customs clearing (self-clearance):
* Contracts tab = contract-level review (one-time flow), General tab =
* per-booking review under GENERAL non-customs contracts. Rows deep-link to
* the existing review detail pages; search / status filter / pagination are
* all server-side.
* Operations "Clearance Documents" hub — the worklist for self-clearance
* (non-customs) document review. Clearance is always per SHIPMENT: the customer
* uploads his documents on the booking he initiated, whatever kind of contract
* it draws on, so this hub lists bookings only. Rows deep-link to the booking
* clearance review page; search / status filter / pagination are server-side.
*/
type HubTab = "contracts" | "general";
const PAGE_SIZE = 10;
/** Status filter options for the Contracts tab (values = `statuses` param). */
const CONTRACT_STATUS_OPTIONS = [
{
value: [
"AWAITING_CLEARANCE_DOCUMENTS",
"CLEARANCE_UNDER_REVIEW",
"CLEARANCE_READY_FOR_BOOKING",
"FULLY_EXECUTED",
"CONTRACT_ACTIVE",
"ACTIVE_SHIPMENT_IN_PROGRESS",
"CONTRACT_CLOSED",
"CANCELLED",
].join(","),
label: "All statuses",
},
{ value: "AWAITING_CLEARANCE_DOCUMENTS", label: "Awaiting documents" },
{ value: "CLEARANCE_UNDER_REVIEW", label: "Under review" },
{ value: "CLEARANCE_READY_FOR_BOOKING", label: "Ready for booking" },
{ value: "FULLY_EXECUTED,CONTRACT_ACTIVE", label: "Finalized" },
{
value: "ACTIVE_SHIPMENT_IN_PROGRESS,CONTRACT_CLOSED",
label: "In progress / closed",
},
{ value: "CANCELLED", label: "Cancelled" },
];
/** Status filter options for the General (per-booking) tab. */
/** Status filter options (values = `statuses` param). */
const BOOKING_STATUS_OPTIONS = [
{
value: "AWAITING_DOCUMENTS,DOCUMENTS_UNDER_REVIEW,CLEARANCE_READY",
@@ -95,17 +51,46 @@ const BOOKING_STATUS_OPTIONS = [
{ value: "CLEARANCE_READY", label: "Clearance ready" },
];
const TRADE_DIRECTION_OPTIONS = [
{ value: "IMPORT", label: "Import" },
{ value: "EXPORT", label: "Export" },
{ value: "DOMESTIC", label: "Domestic" },
];
const FREIGHT_TYPE_OPTIONS = [
{ value: "CONTAINER", label: "Container" },
{ value: "BULK", label: "Bulk" },
];
const OWNERSHIP_OPTIONS = [
{ value: "true", label: "Government" },
{ value: "false", label: "Private" },
];
function startOfDayIso(d: Date): string {
const x = new Date(d);
x.setHours(0, 0, 0, 0);
return x.toISOString();
}
function endOfDayIso(d: Date): string {
const x = new Date(d);
x.setHours(23, 59, 59, 999);
return x.toISOString();
}
export default function ClearanceDocumentsPage() {
const navigate = useNavigate();
const [hubTab, setHubTab] = useState<HubTab>("contracts");
const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query, 300);
const [contractStatuses, setContractStatuses] = useState(
CONTRACT_STATUS_OPTIONS[0].value,
);
const [bookingStatuses, setBookingStatuses] = useState(
BOOKING_STATUS_OPTIONS[0].value,
);
const [directionFilter, setDirectionFilter] = useState<string | null>(null);
const [freightTypeFilter, setFreightTypeFilter] = useState<string | null>(null);
const [ownershipFilter, setOwnershipFilter] = useState<string | null>(null);
const [createdFrom, setCreatedFrom] = useState<Date | null>(null);
const [createdTo, setCreatedTo] = useState<Date | null>(null);
const { pagination, setPagination } = usePagination({ pageSize: PAGE_SIZE });
const search = debouncedQuery.trim() || undefined;
@@ -116,147 +101,41 @@ export default function ClearanceDocumentsPage() {
const page = pagination.pageIndex + 1;
const contractsQuery = useQuery({
const bookingsQuery = useQuery({
queryKey: [
"clearance-documents",
"contracts",
contractStatuses,
"bookings",
bookingStatuses,
directionFilter,
freightTypeFilter,
ownershipFilter,
createdFrom,
createdTo,
page,
search,
],
queryFn: () =>
contractsService.getOpsClearanceQueue({
page,
pageSize: PAGE_SIZE,
statuses: contractStatuses,
search,
}),
enabled: hubTab === "contracts",
placeholderData: keepPreviousData,
});
const generalQuery = useQuery({
queryKey: ["clearance-documents", "general", bookingStatuses, page, search],
queryFn: () =>
// Per-booking self-clearance instances are drawdowns under GENERAL
// non-customs contracts: they carry bookingType=ONE_TIME (each shipment
// is one-time) with contractKind=GENERAL, so filtering on
// bookingType=GENERAL_CONTRACT returned nothing. customsClearingEnabled
// =false + the three per-booking clearance statuses already isolate
// exactly this worklist — the same set the old booking-request tab showed.
// Self-clearance instances carry bookingType=ONE_TIME whatever their
// contract kind, so customsClearingEnabled=false + the three per-booking
// clearance statuses are what isolate exactly this worklist.
bookingsService.list({
statuses: bookingStatuses,
customsClearingEnabled: "false",
page,
pageSize: PAGE_SIZE,
search,
...(directionFilter ? { tradeDirection: directionFilter } : {}),
...(freightTypeFilter ? { freightType: freightTypeFilter } : {}),
...(ownershipFilter
? { isGovernment: ownershipFilter as "true" | "false" }
: {}),
...(createdFrom ? { createdFrom: startOfDayIso(createdFrom) } : {}),
...(createdTo ? { createdTo: endOfDayIso(createdTo) } : {}),
}),
enabled: hubTab === "general",
placeholderData: keepPreviousData,
});
const contractRows = useMemo(
() => (contractsQuery.data?.items ?? []).map(toContractListRow),
[contractsQuery.data?.items],
);
const bookingRows = generalQuery.data?.items ?? [];
const contractColumns: ColumnDef<ContractListRow>[] = useMemo(
() => [
{
id: "contract",
header: () => <span className={bookingTable.headerCell}>Customer</span>,
cell: ({ row }) => {
const c = row.original;
return (
<div className="flex items-center gap-3 py-1.5">
<div className={bookingTable.rowIcon}>
<User className="size-4" strokeWidth={1.75} />
</div>
<div className="min-w-0">
<p className="truncate font-medium text-foreground">
{c.customerLabel}
</p>
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
<FileText className="size-3 shrink-0 opacity-70" />
{c.reference}
</p>
</div>
</div>
);
},
},
{
id: "route",
header: () => <span className={bookingTable.headerCell}>Route</span>,
cell: ({ row }) => {
const c = row.original;
return (
<div className="space-y-1 py-1">
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
<span className="max-w-[8rem] truncate">{c.originLabel}</span>
<ArrowRight className="size-3.5 shrink-0 text-muted-foreground" />
<span className="max-w-[8rem] truncate">
{c.destinationLabel}
</span>
</div>
<div className="flex gap-1.5">
<Badge
variant="outline"
className="h-5 border-border/50 bg-background/50 px-1.5 text-[10px] font-medium uppercase backdrop-blur-sm"
>
{directionLabel(c.tradeDirection)}
</Badge>
<Badge
variant="secondary"
className="h-5 bg-muted/40 px-1.5 text-[10px] font-medium"
>
{c.freightType}
</Badge>
</div>
</div>
);
},
},
{
id: "kind",
header: () => <span className={bookingTable.headerCell}>Kind</span>,
cell: ({ row }) => (
<Badge
variant="outline"
className="h-5 border-border/50 bg-background/50 px-1.5 text-[10px] font-medium uppercase"
>
{row.original.contractKind === "GENERAL" ? (
<span className="inline-flex items-center gap-1">
<Repeat className="size-3" /> General
</span>
) : (
"One-time"
)}
</Badge>
),
},
{
id: "status",
size: 200,
minSize: 180,
header: () => <span className={bookingTable.headerCell}>Status</span>,
cell: ({ row }) => (
<div className="py-1">
<ContractStatusBadge
status={row.original.status}
isRenewal={row.original.isRenewal}
/>
</div>
),
meta: {
headerClassName: "min-w-[11rem]",
cellClassName: "min-w-[11rem]",
},
},
],
[],
);
const bookingRows = bookingsQuery.data?.items ?? [];
const bookingColumns: ColumnDef<BookingDetail>[] = useMemo(
() => [
@@ -289,9 +168,18 @@ export default function ClearanceDocumentsPage() {
{
id: "contractRef",
header: () => <span className={bookingTable.headerCell}>Contract</span>,
cell: ({ row }) => (
<Text size="sm">{row.original.contractReference ?? "—"}</Text>
),
cell: ({ row }) => {
const b = row.original;
return b.contractId && b.contractReference ? (
<ContractReferenceLink
contractId={b.contractId}
contractReference={b.contractReference}
className="block truncate text-sm text-foreground underline underline-offset-2 hover:text-muted-foreground"
/>
) : (
<Text size="sm"></Text>
);
},
},
{
id: "shipment",
@@ -335,39 +223,29 @@ export default function ClearanceDocumentsPage() {
[],
);
const isContracts = hubTab === "contracts";
const activeQuery = isContracts ? contractsQuery : generalQuery;
const total = activeQuery.data?.total ?? 0;
const total = bookingsQuery.data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
const showEmpty =
!activeQuery.isLoading &&
!activeQuery.isError &&
(isContracts ? contractRows.length : bookingRows.length) === 0;
const tableStatus = activeQuery.isLoading
!bookingsQuery.isLoading && !bookingsQuery.isError && bookingRows.length === 0;
const tableStatus = bookingsQuery.isLoading
? "loading"
: activeQuery.isError
: bookingsQuery.isError
? "error"
: "success";
const statusOptions = isContracts
? CONTRACT_STATUS_OPTIONS
: BOOKING_STATUS_OPTIONS;
const statusValue = isContracts ? contractStatuses : bookingStatuses;
const setStatusValue = isContracts ? setContractStatuses : setBookingStatuses;
return (
<PageContainer>
<Stack gap="lg">
<PageHeader
title="Clearance Documents"
subtitle="Operations review of customer clearance documents for contracts without customs clearing."
subtitle="Operations review of the clearance documents customers upload on their shipments (services without customs clearing)."
action={
<ActionIcon
variant="default"
size="lg"
radius="md"
loading={activeQuery.isFetching}
onClick={() => void activeQuery.refetch()}
loading={bookingsQuery.isFetching}
onClick={() => void bookingsQuery.refetch()}
aria-label="Refresh"
>
<RefreshCw size={16} />
@@ -375,29 +253,12 @@ export default function ClearanceDocumentsPage() {
}
/>
<Tabs
value={hubTab}
onChange={(v) => {
setHubTab((v as HubTab) ?? "contracts");
resetPage();
}}
>
<Tabs.List>
<Tabs.Tab value="contracts">Contracts</Tabs.Tab>
<Tabs.Tab value="general">General</Tabs.Tab>
</Tabs.List>
</Tabs>
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<Group justify="space-between" gap="md" wrap="wrap">
<TextInput
placeholder={
isContracts
? "Search reference or customer…"
: "Search booking, contract or customer…"
}
placeholder="Search booking, contract or customer…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => {
@@ -424,10 +285,10 @@ export default function ClearanceDocumentsPage() {
radius="lg"
/>
<Select
data={statusOptions}
value={statusValue}
data={BOOKING_STATUS_OPTIONS}
value={bookingStatuses}
onChange={(v) => {
setStatusValue(v ?? statusOptions[0].value);
setBookingStatuses(v ?? BOOKING_STATUS_OPTIONS[0].value);
resetPage();
}}
allowDeselect={false}
@@ -439,6 +300,73 @@ export default function ClearanceDocumentsPage() {
{total} record{total !== 1 ? "s" : ""}
</Text>
</Group>
<Group gap="sm" mt="sm" wrap="wrap">
<Select
placeholder="Direction"
data={TRADE_DIRECTION_OPTIONS}
value={directionFilter}
onChange={(v) => {
setDirectionFilter(v);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 130 }}
aria-label="Filter by direction"
/>
<Select
placeholder="Freight type"
data={FREIGHT_TYPE_OPTIONS}
value={freightTypeFilter}
onChange={(v) => {
setFreightTypeFilter(v);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 140 }}
aria-label="Filter by freight type"
/>
<Select
placeholder="Gov / Private"
data={OWNERSHIP_OPTIONS}
value={ownershipFilter}
onChange={(v) => {
setOwnershipFilter(v);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 140 }}
aria-label="Filter by ownership"
/>
<DateInput
placeholder="Created from"
value={createdFrom}
onChange={(v) => {
setCreatedFrom(v ? new Date(v) : null);
resetPage();
}}
maxDate={createdTo ?? undefined}
clearable
radius="lg"
style={{ minWidth: 140 }}
aria-label="Created from"
/>
<DateInput
placeholder="Created to"
value={createdTo}
onChange={(v) => {
setCreatedTo(v ? new Date(v) : null);
resetPage();
}}
minDate={createdFrom ?? undefined}
clearable
radius="lg"
style={{ minWidth: 140 }}
aria-label="Created to"
/>
</Group>
</Box>
{showEmpty ? (
@@ -446,61 +374,36 @@ export default function ClearanceDocumentsPage() {
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
<Inbox size={22} />
</ThemeIcon>
<Text c="dimmed">
No {isContracts ? "contracts" : "bookings"} match this view.
</Text>
<Text c="dimmed">No shipments match this view.</Text>
</Stack>
) : (
<Box style={{ overflowX: "auto" }} w="100%">
{isContracts ? (
<DataTable
columns={contractColumns}
data={contractRows}
status={tableStatus}
onRowClick={(row) =>
navigate(
`/dashboard/contracts/clearance-documents/${row.id}`,
)
}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>
) : (
<DataTable
columns={bookingColumns}
data={bookingRows}
status={tableStatus}
onRowClick={(row) =>
navigate(`/dashboard/clearance/${row.id}`)
}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>
)}
<DataTable
columns={bookingColumns}
data={bookingRows}
status={tableStatus}
// `from` so the detail page's Back returns to THIS hub, not
// to whichever worklist the fallback would guess.
onRowClick={(row) =>
navigate(`/dashboard/clearance/${row.id}`, {
state: { from: "/dashboard/contracts/clearance-documents" },
})
}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>
</Box>
)}
</Stack>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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