Merge pull request #330 from Tria-plc/freight_feature/contrat

Freight feature/contrat
This commit is contained in:
marshal
2026-06-29 12:49:33 +03:00
committed by GitHub
238 changed files with 28730 additions and 4456 deletions

View File

@@ -53,7 +53,7 @@ const BookingDetailPage = () => {
id: "1",
quantity: 2,
vgmPerUnitTons: 11.25,
containerType: { label: "20FT Standard", sizeFt: 20, isReefer: false },
containerType: { label: "20FT Standard", sizeFt: 20 },
},
],
approvalSteps: [

View File

@@ -4,6 +4,7 @@ import {
FileSignature,
Layers,
LayoutGrid,
Milestone,
Package,
ShieldCheck,
} from "lucide-react";
@@ -288,6 +289,16 @@ export default function BookingRequestDetailPage() {
booking={booking}
mutations={mutations}
/>
<Button
fullWidth
variant="default"
leftSection={<Milestone size={16} />}
onClick={() =>
navigate(`/dashboard/bookings/${booking.id}/milestones`)
}
>
View clearance milestones
</Button>
{showContractButton && (
<Button
fullWidth

View File

@@ -20,7 +20,9 @@ import {
import {
ArrowRight,
Calendar,
CheckCircle,
ChevronRight,
History,
Inbox,
LayoutGrid,
RefreshCw,
@@ -46,12 +48,15 @@ import { bookingTable } from "@/components/bookings/booking-ui.styles";
import { bookingsService } from "@/services/bookings.service";
import type { BookingDetail } from "@/types/booking";
import {
CLEARANCE_REVIEW_STATUS,
CLEARANCE_TABS,
type ClearanceTabKey,
} from "@/features/clearance/clearance-tabs.config";
type ViewMode = "table" | "cards";
type PageTab = "queue" | "history";
const CLEARANCE_REVIEW_STATUS = "DOCUMENTS_UNDER_REVIEW";
const CLEARANCE_HISTORY_STATUS = "CLEARANCE_READY";
interface ClearanceRow {
id: string;
@@ -62,6 +67,7 @@ interface ClearanceRow {
originLabel: string;
destinationLabel: string;
scheduledDate: string;
updatedAt: string;
hasCustoms: boolean;
}
@@ -85,6 +91,7 @@ function toClearanceRow(booking: BookingDetail): ClearanceRow {
originLabel: labelFromRef(booking.originYard),
destinationLabel: labelFromRef(booking.destinationYard),
scheduledDate: booking.scheduledDate,
updatedAt: booking.updatedAt ?? "",
hasCustoms: Boolean(
booking.customsClearingEnabled ?? booking.serviceType?.includesCustoms,
),
@@ -102,12 +109,6 @@ function formatDate(iso?: string): string {
});
}
/**
* Icon-only chip for a booking's trade direction — Truck for import, ShipWheel
* for export — on a light background, matching the "awaiting review" badge
* styling. Keeps the cards within the white / light-gray / green palette and
* drops the text label in favour of a tooltip.
*/
function DirectionIcon({ direction }: { direction: string }) {
const isImport = direction === "IMPORT";
const Icon = isImport ? Truck : ShipWheel;
@@ -129,33 +130,45 @@ function DirectionIcon({ direction }: { direction: string }) {
export default function DocumentClearanceListPage() {
const navigate = useNavigate();
const [pageTab, setPageTab] = useState<PageTab>("queue");
const [activeTab, setActiveTab] = useState<ClearanceTabKey>("all");
const [query, setQuery] = useState("");
const [view, setView] = useState<ViewMode>("table");
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const isHistory = pageTab === "history";
const { data, isLoading, isError, isFetching, refetch } = useQuery({
queryKey: ["clearance", "list"],
queryKey: ["clearance", "list", isHistory],
queryFn: () =>
bookingsService.list({ status: CLEARANCE_REVIEW_STATUS, pageSize: 200 }),
bookingsService.list({
status: isHistory ? CLEARANCE_HISTORY_STATUS : CLEARANCE_REVIEW_STATUS,
pageSize: 200,
}),
});
// GL clears customs bookings only; non-customs clearance is reviewed by
// Marketing on the booking detail. Scope the queue defensively so a staff or
// marketing user opening this page still sees the customs queue.
const allRows = useMemo(
() => (data?.items ?? []).map(toClearanceRow).filter((r) => r.hasCustoms),
[data?.items],
);
const allRows = useMemo(() => {
// GL clearance queue: customs bookings only
const rows = (data?.items ?? []).map(toClearanceRow).filter((r) => r.hasCustoms);
// Per-tab counts drive the badge on each tab.
const tabCounts = useMemo(() => {
return {
if (isHistory) {
return [...rows].sort((a, b) => {
const ta = new Date(a.updatedAt || 0).getTime();
const tb = new Date(b.updatedAt || 0).getTime();
return tb - ta;
});
}
return rows;
}, [data?.items, isHistory]);
const tabCounts = useMemo(
() => ({
all: allRows.length,
import: allRows.filter((r) => r.tradeDirection === "IMPORT").length,
export: allRows.filter((r) => r.tradeDirection === "EXPORT").length,
} satisfies Record<ClearanceTabKey, number>;
}, [allRows]);
}),
[allRows],
);
const rows = useMemo(() => {
const q = query.trim().toLowerCase();
@@ -185,6 +198,26 @@ export default function DocumentClearanceListPage() {
[navigate],
);
const statusBadge = isHistory ? (
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<CheckCircle size={13} />}
>
{tabCounts.all} cleared
</Badge>
) : (
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<ShieldCheck size={13} />}
>
{tabCounts.all} awaiting review
</Badge>
);
const columns: ColumnDef<ClearanceRow>[] = useMemo(
() => [
{
@@ -249,11 +282,16 @@ export default function DocumentClearanceListPage() {
{
id: "status",
header: () => <span className={bookingTable.headerCell}>Status</span>,
cell: () => (
<Badge size="sm" variant="light" color="edr-green" radius="sm">
Under review
</Badge>
),
cell: () =>
isHistory ? (
<Badge size="sm" variant="light" color="edr-green" radius="sm">
Cleared
</Badge>
) : (
<Badge size="sm" variant="light" color="yellow" radius="sm">
Under review
</Badge>
),
},
{
id: "go",
@@ -265,7 +303,7 @@ export default function DocumentClearanceListPage() {
),
},
],
[],
[isHistory],
);
return (
@@ -274,16 +312,7 @@ export default function DocumentClearanceListPage() {
<PageHeader
title="Document Clearance"
subtitle="Review customer documents, raise queries, and finalize clearance for each booking."
meta={
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<ShieldCheck size={13} />}
>
{tabCounts.all} awaiting review
</Badge>
}
meta={statusBadge}
action={
<ActionIcon
variant="default"
@@ -298,13 +327,45 @@ export default function DocumentClearanceListPage() {
}
/>
<Group>
<SegmentedControl
value={pageTab}
onChange={(v) => {
setPageTab(v as PageTab);
setActiveTab("all");
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
data={[
{
value: "queue",
label: (
<Group gap={6} wrap="nowrap">
<Inbox size={14} />
<span>Queue</span>
</Group>
),
},
{
value: "history",
label: (
<Group gap={6} wrap="nowrap">
<History size={14} />
<span>History</span>
</Group>
),
},
]}
radius="md"
/>
</Group>
<KpiStrip
loading={isLoading}
items={[
{
label: "Awaiting review",
label: isHistory ? "Cleared" : "Awaiting review",
value: tabCounts.all,
icon: Inbox,
icon: isHistory ? CheckCircle : Inbox,
color: "edr-green",
},
{
@@ -370,10 +431,7 @@ export default function DocumentClearanceListPage() {
value={query}
onChange={(e) => {
setQuery(e.currentTarget.value);
setPagination({
pageIndex: 0,
pageSize: pagination.pageSize,
});
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
rightSection={
query ? (
@@ -430,9 +488,7 @@ export default function DocumentClearanceListPage() {
<DataTable<ClearanceRow, unknown>
columns={columns}
data={pagedRows}
status={
isLoading ? "loading" : isError ? "error" : "success"
}
status={isLoading ? "loading" : isError ? "error" : "success"}
onRowClick={(row) => openDetail(row.id)}
pagination={{
pageIndex: pagination.pageIndex,
@@ -454,6 +510,7 @@ export default function DocumentClearanceListPage() {
<ClearanceCardGrid
rows={pagedRows}
loading={isLoading}
isHistory={isHistory}
onOpen={openDetail}
/>
)}
@@ -467,10 +524,12 @@ export default function DocumentClearanceListPage() {
function ClearanceCardGrid({
rows,
loading,
isHistory,
onOpen,
}: {
rows: ClearanceRow[];
loading: boolean;
isHistory: boolean;
onOpen: (id: string) => void;
}) {
if (loading) {
@@ -495,7 +554,12 @@ function ClearanceCardGrid({
return (
<SimpleGrid cols={{ base: 1, sm: 2, xl: 3 }} spacing="md" p="md">
{rows.map((r) => (
<ClearanceCard key={r.id} row={r} onOpen={() => onOpen(r.id)} />
<ClearanceCard
key={r.id}
row={r}
isHistory={isHistory}
onOpen={() => onOpen(r.id)}
/>
))}
</SimpleGrid>
);
@@ -503,9 +567,11 @@ function ClearanceCardGrid({
function ClearanceCard({
row,
isHistory,
onOpen,
}: {
row: ClearanceRow;
isHistory: boolean;
onOpen: () => void;
}) {
return (
@@ -543,9 +609,15 @@ function ClearanceCard({
</Group>
</Box>
</Group>
<Badge size="sm" variant="light" color="edr-green" radius="sm">
Under review
</Badge>
{isHistory ? (
<Badge size="sm" variant="light" color="edr-green" radius="sm">
Cleared
</Badge>
) : (
<Badge size="sm" variant="light" color="yellow" radius="sm">
Under review
</Badge>
)}
</Group>
<Box

View File

@@ -0,0 +1,140 @@
import { useMemo } from "react";
import { useParams } from "react-router-dom";
import {
Badge,
Box,
Center,
Grid,
Group,
Loader,
Progress,
RingProgress,
Stack,
Text,
} from "@mantine/core";
import { Flag, ListChecks } from "lucide-react";
import { PageContainer } from "@/components/page";
import { PageHeader } from "@/components/page/PageHeader";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { ClearanceMilestoneTimeline } from "@/components/contracts/ClearanceMilestoneTimeline";
import { GlActionsPanel } from "@/components/contracts/gl-actions/GlActionsPanel";
import {
useBookingMilestones,
useCompleteMilestone,
} from "@/hooks/contracts/useContracts";
import { useBookingDetail } from "@/hooks/bookings/useBookings";
export default function BookingMilestonesPage() {
const { id } = useParams<{ id: string }>();
const { data: booking } = useBookingDetail(id);
const { data: milestones, isLoading } = useBookingMilestones(id);
const complete = useCompleteMilestone(id ?? "");
const stats = useMemo(() => {
const list = milestones ?? [];
const total = list.length;
const completed = list.filter((m) => m.status === "COMPLETED").length;
const pct = total === 0 ? 0 : Math.round((completed / total) * 100);
return { total, completed, pct };
}, [milestones]);
const reference = booking?.reference ?? "Shipment";
return (
<PageContainer>
<Stack gap="lg">
<PageHeader
title={`${reference} milestones`}
subtitle="Track and advance the Global Logistics clearance milestones for this shipment."
backTo={id ? `/dashboard/booking-requests/${id}` : undefined}
breadcrumbs={[
{ label: "Booking requests", href: "/dashboard/booking-requests" },
{ label: reference },
{ label: "Milestones" },
]}
meta={
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<ListChecks size={13} />}
>
{stats.completed}/{stats.total} done
</Badge>
}
/>
<Grid gap="lg">
<Grid.Col span={{ base: 12, lg: 8 }}>
<Stack gap="lg">
<SectionCard icon={Flag} title="Clearance milestones">
{isLoading ? (
<Center py="xl">
<Loader color="edr-green" size="sm" />
</Center>
) : (
<ClearanceMilestoneTimeline
milestones={milestones ?? []}
busy={complete.isPending}
onComplete={(code, note) =>
complete.mutate({ code, note })
}
/>
)}
</SectionCard>
{id ? (
<GlActionsPanel
bookingId={id}
milestones={milestones ?? []}
/>
) : null}
</Stack>
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 4 }}>
<Box style={{ position: "sticky", top: 24 }}>
<SectionCard icon={ListChecks} title="Progress" accent="edr-green">
<Stack align="center" gap="sm">
<RingProgress
size={140}
thickness={12}
roundCaps
sections={[{ value: stats.pct, color: "edr-green" }]}
label={
<Stack gap={0} align="center">
<Text fw={800} fz={26} lh={1}>
{stats.pct}%
</Text>
<Text size="xs" c="dimmed">
complete
</Text>
</Stack>
}
/>
<Box w="100%">
<Group justify="space-between" mb={6}>
<Text size="xs" c="dimmed" fw={600}>
Milestones
</Text>
<Text size="xs" c="dimmed">
{stats.completed}/{stats.total}
</Text>
</Group>
<Progress
value={stats.pct}
color="edr-green"
radius="xl"
size="md"
/>
</Box>
</Stack>
</SectionCard>
</Box>
</Grid.Col>
</Grid>
</Stack>
</PageContainer>
);
}

View File

@@ -0,0 +1,340 @@
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { useParams } from "react-router-dom";
import {
Alert,
Badge,
Box,
Grid,
Group,
Loader,
Paper,
Progress,
RingProgress,
Stack,
Text,
ThemeIcon,
} from "@mantine/core";
import {
AlertCircle,
ArrowRight,
CheckCircle2,
Clock,
PackageCheck,
ShieldCheck,
} from "lucide-react";
import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { contractsService } from "@/services/contracts.service";
import { useContractDetail } from "@/hooks/contracts/useContracts";
export default function ContractClearanceDetailPage() {
const { id } = useParams<{ id: string }>();
const { data: contract } = useContractDetail(id);
const {
data: clearance,
isLoading,
isError,
} = useQuery({
queryKey: QUERY_KEYS.CONTRACTS.clearance(id ?? ""),
queryFn: () => contractsService.getClearance(id!),
enabled: Boolean(id),
});
const stats = useMemo(() => {
const docs = (clearance?.documents ?? []).filter(
(d) => d.uploadedBy === "customer",
);
const total = docs.length;
const approved = docs.filter((d) => d.reviewStatus === "APPROVED").length;
const queried = docs.filter((d) => d.reviewStatus === "QUERIED").length;
const pending = total - approved - queried;
const pct = total === 0 ? 0 : Math.round((approved / total) * 100);
return { total, approved, queried, pending, pct };
}, [clearance]);
const reference = contract?.reference ?? "Clearance";
// Customs (Path B) hub. The customer always creates the booking in the portal
// after GL finalizes clearance — there is no GL "Create booking" action here.
const ready = clearance?.clearanceStatus === "CLEARANCE_READY_FOR_BOOKING";
if (isLoading) {
return (
<PageContainer>
<Group justify="center" py={80} gap={10}>
<Loader color="edr-green" />
<Text c="dimmed">Loading clearance</Text>
</Group>
</PageContainer>
);
}
if (isError || !clearance) {
return (
<PageContainer>
<PageHeader
title="Clearance not found"
backTo="/dashboard/contracts/clearance"
breadcrumbs={[
{
label: "Document Clearance",
href: "/dashboard/contracts/clearance",
},
{ label: "Not found" },
]}
/>
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
We couldnt load this contracts clearance.
</Alert>
</PageContainer>
);
}
return (
<PageContainer>
<Stack gap="lg">
<PageHeader
title={reference}
backTo="/dashboard/contracts/clearance"
breadcrumbs={[
{
label: "Document Clearance",
href: "/dashboard/contracts/clearance",
},
{ label: reference },
]}
meta={
ready ? (
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<PackageCheck size={13} />}
>
Ready customer books
</Badge>
) : clearance.allApproved ? (
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<CheckCircle2 size={13} />}
>
All approved
</Badge>
) : (
<Badge
variant="light"
color="gray"
radius="sm"
leftSection={<Clock size={13} />}
>
Review pending
</Badge>
)
}
/>
<ClearanceHero contract={contract} stats={stats} />
{ready ? (
<Alert
color="edr-green"
radius="md"
icon={<PackageCheck size={16} />}
title="Clearance finalized"
>
Customs clearance is complete. The customer can now create the
shipment booking from the portal no further action is needed here.
</Alert>
) : null}
<Grid gap="lg">
<Grid.Col span={{ base: 12, lg: 8 }}>
<ContractClearanceReviewSection
contractId={id!}
hideSummary
selfClear={false}
readOnly={ready}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 4 }}>
<Box style={{ position: "sticky", top: 24 }}>
<SectionCard
icon={PackageCheck}
title="Review progress"
accent="edr-green"
>
<Stack align="center" gap="sm">
<RingProgress
size={140}
thickness={12}
roundCaps
sections={[{ value: stats.pct, color: "edr-green" }]}
label={
<Stack gap={0} align="center">
<Text fw={800} fz={26} lh={1}>
{stats.pct}%
</Text>
<Text size="xs" c="dimmed">
approved
</Text>
</Stack>
}
/>
<Group gap="lg" justify="center">
<ProgressStat
color="edr-green"
label="Approved"
value={stats.approved}
/>
<ProgressStat
color="red"
label="Queried"
value={stats.queried}
/>
<ProgressStat
color="gray"
label="Pending"
value={stats.pending}
/>
</Group>
</Stack>
</SectionCard>
</Box>
</Grid.Col>
</Grid>
</Stack>
</PageContainer>
);
}
function ClearanceHero({
contract,
stats,
}: {
contract: ReturnType<typeof useContractDetail>["data"];
stats: { pct: number; approved: number; total: number };
}) {
const direction = contract?.tradeDirection ?? "—";
const serviceName = contract?.serviceType?.serviceName ?? null;
const customs =
contract?.serviceType?.includesCustoms ??
contract?.customsClearingEnabled ??
false;
const routes = [...(contract?.routes ?? [])].sort(
(a, b) => a.sortOrder - b.sortOrder,
);
const origin =
routes[0]?.originYard?.label ?? routes[0]?.originYard?.code ?? "Origin";
const last = routes[routes.length - 1] ?? routes[0];
const destination =
last?.destinationYard?.label ??
last?.destinationYard?.code ??
"Destination";
return (
<Paper withBorder radius="md" p="lg">
<Group justify="space-between" align="flex-start" wrap="wrap" gap="lg">
<Group gap="md" wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon variant="light" color="edr-green" radius="md" size={52}>
<ShieldCheck size={26} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Group gap={8} wrap="nowrap">
<Text fw={800} fz={20} c="edr-text" truncate>
{contract?.reference ?? "Clearance"}
</Text>
<Badge
size="sm"
variant="light"
color={direction === "IMPORT" ? "edr-green" : "gray"}
radius="sm"
>
{direction}
</Badge>
{customs ? (
<Badge
size="sm"
variant="light"
color="edr-green"
radius="sm"
leftSection={<ShieldCheck size={12} />}
>
Customs
</Badge>
) : (
<Badge size="sm" variant="light" color="gray" radius="sm">
No customs
</Badge>
)}
</Group>
{serviceName && (
<Text size="sm" fw={600} c="edr-text" mt={6} truncate maw={280}>
{serviceName}
</Text>
)}
<Group gap={8} mt={6} wrap="nowrap">
<Text size="sm" fw={600} truncate maw={160}>
{origin}
</Text>
<ArrowRight size={15} className="shrink-0 text-muted-foreground" />
<Text size="sm" fw={600} truncate maw={160}>
{destination}
</Text>
</Group>
</Box>
</Group>
<Box style={{ minWidth: 200, flex: 1, maxWidth: 320 }}>
<Group justify="space-between" mb={6}>
<Text size="xs" c="dimmed" fw={600}>
Document review
</Text>
<Text size="xs" c="dimmed">
{stats.approved}/{stats.total}
</Text>
</Group>
<Progress value={stats.pct} color="edr-green" radius="xl" size="md" />
</Box>
</Group>
</Paper>
);
}
function ProgressStat({
color,
label,
value,
}: {
color: string;
label: string;
value: number;
}) {
return (
<Stack gap={2} align="center">
<Text fw={700} fz={18} c="edr-text">
{value}
</Text>
<Group gap={4} wrap="nowrap">
<Box
style={{
width: 7,
height: 7,
borderRadius: 999,
background: `var(--mantine-color-${color}-6)`,
}}
/>
<Text fz="11px" c="dimmed">
{label}
</Text>
</Group>
</Stack>
);
}

View File

@@ -0,0 +1,615 @@
import { useCallback, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
ActionIcon,
Badge,
Box,
Button,
Card,
Group,
SegmentedControl,
Stack,
Text,
TextInput,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import {
ArrowRight,
ChevronRight,
FileText,
Inbox,
LayoutGrid,
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 { 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 { useContractClearanceQueue } from "@/hooks/contracts/useContracts";
type ViewMode = "table" | "cards";
interface ClearanceRow {
id: string;
reference: string;
customerLabel: string;
tradeDirection: string;
freightType: string;
originLabel: string;
destinationLabel: string;
contractKind: string;
serviceTypeName: string;
customs: boolean;
status: string;
/** true once GL has finalized clearance — customer now books in the portal. */
ready: boolean;
}
function yardLabel(
yard?: { label?: string; code?: string; name?: string } | null,
fallback = "—",
): string {
if (!yard) return fallback;
return yard.label ?? yard.name ?? yard.code ?? fallback;
}
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,
customerLabel: contract.isGovernment
? (contract.governmentInstitution ?? "Government")
: (contract.companyId ?? "—"),
tradeDirection: contract.tradeDirection ?? "—",
freightType: contract.freightType ?? "—",
originLabel: yardLabel(first?.originYard),
destinationLabel: yardLabel(last?.destinationYard),
contractKind: contract.contractKind,
serviceTypeName: contract.serviceType?.serviceName ?? "—",
customs:
contract.serviceType?.includesCustoms ?? contract.customsClearingEnabled,
status: contract.status,
ready: contract.status === "CLEARANCE_READY_FOR_BOOKING",
};
}
function CustomsBadge({ customs }: { customs: boolean }) {
return 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>
);
}
function DirectionIcon({ direction }: { direction: string }) {
const isImport = direction === "IMPORT";
const Icon = isImport ? Truck : ShipWheel;
const label = isImport ? "Import" : direction === "EXPORT" ? "Export" : "—";
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 }) {
if (row.ready) {
return (
<Tooltip
label="Clearance finalized — the customer creates the booking in the portal"
withArrow
>
<Badge
size="sm"
variant="light"
color="edr-green"
radius="sm"
leftSection={<PackageCheck size={12} />}
>
Clearance finalized
</Badge>
</Tooltip>
);
}
return (
<Badge size="sm" variant="light" color="yellow" radius="sm">
Under review
</Badge>
);
}
/**
* Document Clearance hub. Lists every customs (Path B) contract that still needs
* customs clearance — awaiting documents, under GL review, or finalized and
* waiting for the customer to create the booking in the portal. A single list,
* no queue/history/direction tabs.
*/
export default function ContractClearanceListPage() {
const navigate = useNavigate();
const [query, setQuery] = useState("");
const [view, setView] = useState<ViewMode>("table");
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const { data, isLoading, isError, isFetching, refetch } =
useContractClearanceQueue(true);
const allRows = useMemo(
() => (data?.items ?? []).map(toClearanceRow),
[data?.items],
);
const counts = useMemo(
() => ({
all: allRows.length,
ready: allRows.filter((r) => r.ready).length,
review: allRows.filter((r) => !r.ready).length,
}),
[allRows],
);
const rows = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return allRows;
return allRows.filter(
(r) =>
r.reference.toLowerCase().includes(q) ||
r.customerLabel.toLowerCase().includes(q) ||
r.originLabel.toLowerCase().includes(q) ||
r.destinationLabel.toLowerCase().includes(q),
);
}, [allRows, query]);
const total = 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 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="nowrap">
<Text size="sm" fw={500} truncate maw={120}>
{r.originLabel}
</Text>
<ArrowRight size={14} className="shrink-0 text-muted-foreground" />
<Text size="sm" fw={500} truncate maw={120}>
{r.destinationLabel}
</Text>
</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 ? (
<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>
) : (
<Group justify="flex-end" pr="xs">
<ChevronRight size={16} className="text-muted-foreground" />
</Group>
),
},
],
[navigate],
);
return (
<PageContainer>
<Stack gap="lg">
<PageHeader
title="Document Clearance"
subtitle="Review pre-booking customs documents on contracts and finalize clearance. Once finalized, the customer creates the shipment booking in the portal."
meta={
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<ShieldCheck size={13} />}
>
{counts.all} need clearance
</Badge>
}
action={
<ActionIcon
variant="default"
size="lg"
radius="md"
onClick={() => refetch()}
loading={isFetching}
aria-label="Refresh"
>
<RefreshCw size={16} />
</ActionIcon>
}
/>
<KpiStrip
loading={isLoading}
items={[
{
label: "Need clearance",
value: counts.all,
icon: Inbox,
color: "edr-green",
},
{
label: "Awaiting review",
value: counts.review,
icon: ShieldCheck,
color: "yellow",
},
{
label: "Ready — customer books",
value: counts.ready,
icon: PackageCheck,
color: "edr-green",
},
]}
/>
<Card p={0} withBorder shadow="sm" radius="lg">
<Stack gap={0}>
<Box px="md" pt="md" pb="sm">
<Group justify="space-between" gap="md" wrap="wrap">
<TextInput
placeholder="Search reference, customer, or route…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => {
setQuery(e.currentTarget.value);
setPagination({
pageIndex: 0,
pageSize: pagination.pageSize,
});
}}
rightSection={
query ? (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => setQuery("")}
>
<X size={16} />
</ActionIcon>
) : null
}
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>
</Group>
</Box>
{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}
/>
)}
</Stack>
</Card>
</Stack>
</PageContainer>
);
}
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

@@ -0,0 +1,475 @@
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import {
ArrowLeft,
ArrowRight,
Box as BoxIcon,
Building2,
Calendar,
CalendarClock,
FileText,
Flame,
Package,
Receipt,
RefreshCw,
Route as RouteIcon,
ShieldCheck,
Snowflake,
} from "lucide-react";
import {
Badge,
Box,
Button,
Center,
Container,
Grid,
Group,
Loader,
Paper,
Stack,
Tabs,
Text,
Title,
} from "@mantine/core";
import "@/components/overview/overview.css";
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 { ContractWorkflowStepper } from "@/components/contracts/ContractWorkflowStepper";
import { ContractActionsToolbar } from "@/components/contracts/ContractActionsToolbar";
import { ContractApprovalStepsCard } from "@/components/contracts/ContractApprovalStepsCard";
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
import { getContractStatusMeta } from "@/features/contracts/contract-status.config";
import {
useContractDetail,
useContractMutations,
} from "@/hooks/contracts/useContracts";
// Clearance phase — staff can still ACT (approve / query / finalize).
const CLEARANCE_ACTIVE_STATUSES = [
"AWAITING_CLEARANCE_DOCUMENTS",
"CLEARANCE_UNDER_REVIEW",
"CLEARANCE_READY_FOR_BOOKING",
];
// Clearance is done — the tab stays visible but READ-ONLY so staff/customer can
// see which documents were approved, by whom, and when.
const CLEARANCE_DONE_STATUSES = [
"ACTIVE_SHIPMENT_IN_PROGRESS",
"FULLY_EXECUTED",
"CONTRACT_ACTIVE",
"CONTRACT_CLOSED",
"EXPIRED",
];
// Show the Clearance Review tab in either phase (active or done).
const CLEARANCE_REVIEW_STATUSES = [
...CLEARANCE_ACTIVE_STATUSES,
...CLEARANCE_DONE_STATUSES,
];
function formatDate(value: string | null | undefined): string {
if (!value) return "—";
const d = new Date(value);
return Number.isNaN(d.getTime())
? "—"
: d.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
export default function ContractRequestDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const {
data: contract,
isLoading,
isError,
refetch,
isFetching,
} = useContractDetail(id);
const mutations = useContractMutations(id ?? "");
const [searchParams, setSearchParams] = useSearchParams();
const activeTab = searchParams.get("tab") === "clearance" ? "clearance" : "details";
const setTab = (tab: string) =>
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
if (tab === "details") next.delete("tab");
else next.set("tab", tab);
return next;
},
{ replace: true },
);
if (isLoading) {
return (
<PageContainer>
<Center mih="60vh">
<Stack align="center" gap="md">
<Loader color="gray" />
<Text size="sm" c="dimmed" fw={500}>
Loading contract
</Text>
</Stack>
</Center>
</PageContainer>
);
}
if (isError || !contract) {
return (
<PageContainer>
<Container size="sm" py="xl">
<Paper radius="md" withBorder p="xl" ta="center" style={detailStyles.card}>
<Center>
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 64,
height: 64,
borderRadius: 16,
background: "var(--mantine-color-gray-1)",
color: "var(--mantine-color-gray-6)",
}}
>
<FileText size={32} />
</Box>
</Center>
<Text fw={700} size="lg" mt="lg">
Contract not found
</Text>
<Text size="sm" c="dimmed" mt={4}>
This request may have been removed or the link is invalid.
</Text>
<Button
variant="default"
mt="lg"
leftSection={<ArrowLeft size={16} />}
onClick={() => navigate("/dashboard/contract-requests")}
>
Back to contract requests
</Button>
</Paper>
</Container>
</PageContainer>
);
}
const statusMeta = getContractStatusMeta(contract.status);
const routes = [...(contract.routes ?? [])].sort(
(a, b) => a.sortOrder - b.sortOrder,
);
const showApprovalCard =
contract.status === "PENDING_APPROVAL" ||
contract.status === "APPROVED" ||
contract.status === "APPROVED_PENDING_SIGNATURE";
const showClearanceTab = CLEARANCE_REVIEW_STATUSES.includes(contract.status);
// Once clearance is finalized the tab is informational only — no approve/query.
const clearanceReadOnly = CLEARANCE_DONE_STATUSES.includes(contract.status);
// Path A (no customs) → Operations reviews; Path B (customs) → GL reviews.
const selfClear = !contract.customsClearingEnabled;
// If the tab param points at clearance but the contract isn't in a clearance
// phase, fall back to details so we never show an empty tab.
const currentTab = activeTab === "clearance" && showClearanceTab ? "clearance" : "details";
const customerLabel = contract.isGovernment
? (contract.governmentInstitution ?? "Government")
: (contract.companyId ?? "—");
return (
<PageContainer>
<Breadcrumbs
items={[
{ label: "Contract requests", href: "/dashboard/contract-requests" },
{ label: contract.reference },
]}
/>
<Stack gap="lg">
{/* Hero */}
<Paper radius="xl" p="xl" style={{ position: "relative", overflow: "hidden" }}>
<Stack gap="lg">
<Group justify="space-between" align="flex-start" wrap="wrap">
<Button
variant="default"
size="compact-sm"
radius="lg"
leftSection={<ArrowLeft size={16} />}
onClick={() => navigate("/dashboard/contract-requests")}
>
Back to list
</Button>
<Button
variant="light"
color="edr-green"
size="compact-sm"
radius="lg"
leftSection={<RefreshCw size={15} />}
loading={isFetching}
onClick={() => refetch()}
>
Refresh
</Button>
</Group>
<Stack gap="sm">
<Text
size="xs"
fw={700}
tt="uppercase"
style={{ letterSpacing: 1, color: "#B26C09" }}
>
Contract reference
</Text>
<Group gap="sm" align="center" wrap="wrap">
<Title order={2} fw={700} style={{ letterSpacing: "-0.4px" }}>
{contract.reference}
</Title>
<ContractStatusBadge
status={contract.status}
isRenewal={Boolean(contract.renewalOfId)}
/>
<Badge variant="light" color="gray" radius="sm" tt="uppercase">
{contract.contractKind === "GENERAL" ? "General" : "One-time"}
</Badge>
</Group>
<Group gap="lg" mt={4}>
<MetaItem icon={Building2} text={customerLabel} />
<MetaItem
icon={Calendar}
text={`Created ${formatDate(contract.createdAt)}`}
/>
{contract.contractValidUntil ? (
<MetaItem
icon={CalendarClock}
text={`Valid until ${formatDate(contract.contractValidUntil)}`}
/>
) : null}
</Group>
</Stack>
</Stack>
</Paper>
<ContractWorkflowStepper
status={contract.status}
title={statusMeta.title}
description={statusMeta.description}
/>
{showClearanceTab && (
<Tabs
value={currentTab}
onChange={(v) => setTab(v ?? "details")}
variant="pills"
color="edr-green"
classNames={{ list: "ov-tablist", tab: "ov-tab" }}
>
<Tabs.List>
<Tabs.Tab value="details" leftSection={<FileText size={16} />}>
Details
</Tabs.Tab>
<Tabs.Tab
value="clearance"
leftSection={<ShieldCheck size={16} />}
>
Clearance Review
</Tabs.Tab>
</Tabs.List>
</Tabs>
)}
<Grid gap="lg">
{/* LEFT — primary content */}
<Grid.Col span={{ base: 12, lg: 8 }}>
{currentTab === "clearance" ? (
<ContractClearanceReviewSection
contractId={id!}
selfClear={selfClear}
readOnly={clearanceReadOnly}
onChanged={() => refetch()}
/>
) : (
<Stack gap="lg">
<SectionCard icon={RouteIcon} title="Routes">
{routes.length === 0 ? (
<Text size="sm" c="dimmed">
No routes on this contract.
</Text>
) : (
<Stack gap="sm">
{routes.map((r) => (
<Group
key={r.id}
justify="space-between"
wrap="nowrap"
px="sm"
py="xs"
style={{
borderRadius: 8,
border: "1px solid var(--mantine-color-gray-2)",
}}
>
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
<Text size="sm" fw={600} truncate maw={160}>
{r.originYard?.label ??
r.originYard?.code ??
"Origin"}
</Text>
<ArrowRight
size={15}
className="shrink-0 text-muted-foreground"
/>
<Text size="sm" fw={600} truncate maw={160}>
{r.destinationYard?.label ??
r.destinationYard?.code ??
"Destination"}
</Text>
</Group>
{r.km != null ? (
<Badge variant="light" color="gray" radius="sm">
{r.km} km
</Badge>
) : null}
</Group>
))}
</Stack>
)}
</SectionCard>
<SectionCard icon={Package} title="Cargo scope">
<Group gap="sm" mb="md">
<Badge variant="light" color="gray" radius="sm" tt="uppercase">
{contract.tradeDirection}
</Badge>
<Badge variant="light" color="gray" radius="sm" tt="uppercase">
{contract.freightType}
</Badge>
{contract.isHazardous ? (
<Badge
variant="light"
color="orange"
radius="sm"
leftSection={<Flame size={12} />}
>
Hazardous
</Badge>
) : null}
{contract.isReefer ? (
<Badge
variant="light"
color="cyan"
radius="sm"
leftSection={<Snowflake size={12} />}
>
Reefer
</Badge>
) : null}
</Group>
{(contract.cargoScope ?? []).length === 0 ? (
<Text size="sm" c="dimmed">
No cargo scope lines.
</Text>
) : (
<Stack gap="xs">
{(contract.cargoScope ?? []).map((s) => (
<Group key={s.id} gap={8} wrap="nowrap">
<BoxIcon
size={15}
color="var(--mantine-color-edr-green-6)"
/>
<Text size="sm">
{s.containerSize ??
s.cargoFreeText ??
s.cargoTypeId ??
"Cargo"}
</Text>
</Group>
))}
</Stack>
)}
</SectionCard>
{contract.pricingBreakdown?.lineItems?.length ? (
<SectionCard icon={Receipt} title="Unit rates">
<Stack gap="xs">
{contract.pricingBreakdown.lineItems.map((li) => (
<Group
key={li.code}
justify="space-between"
wrap="nowrap"
>
<Text size="sm" truncate>
{li.label}
{li.containerSize ? ` · ${li.containerSize}` : ""}
</Text>
<Text size="sm" fw={600}>
{contract.pricingBreakdown?.currency} {li.unitPrice} /{" "}
{li.unit}
</Text>
</Group>
))}
</Stack>
</SectionCard>
) : null}
{contract.contractSummary ? (
<SectionCard icon={FileText} title="Contract summary">
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
{contract.contractSummary}
</Text>
</SectionCard>
) : null}
</Stack>
)}
</Grid.Col>
{/* RIGHT — sticky action rail */}
<Grid.Col span={{ base: 12, lg: 4 }}>
<Box style={{ position: "sticky", top: 24 }}>
<Stack gap="lg">
<ContractActionsToolbar
contract={contract}
mutations={mutations}
onReviewClearance={
showClearanceTab ? () => setTab("clearance") : undefined
}
/>
{showApprovalCard && (
<ContractApprovalStepsCard
contract={contract}
mutations={mutations}
/>
)}
</Stack>
</Box>
</Grid.Col>
</Grid>
</Stack>
</PageContainer>
);
}
function MetaItem({
icon: Icon,
text,
}: {
icon: typeof Building2;
text: string;
}) {
return (
<Group gap={6} wrap="nowrap">
<Icon size={14} color="var(--mantine-color-gray-5)" />
<Text size="sm" fw={600} c="dark">
{text}
</Text>
</Group>
);
}

View File

@@ -0,0 +1,410 @@
import {
ActionIcon,
Box,
Button,
Card,
Group,
Stack,
Text,
TextInput,
ThemeIcon,
} from "@mantine/core";
import {
AlertTriangle,
ArrowRight,
CalendarClock,
CheckCircle2,
Clock,
FileText,
Inbox,
LayoutList,
RefreshCw,
Repeat,
Search,
User,
X,
} from "lucide-react";
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 {
ContractStatusTabs,
type ContractStatusTabKey,
} from "@/components/contracts/ContractStatusTabs";
import { bookingTable } from "@/components/bookings/booking-ui.styles";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { CONTRACT_LIST_TABS } from "@/features/contracts/contract-status.config";
import {
getStaffRowAction,
toContractListRow,
type ContractListRow,
} from "@/features/contracts/mapContractListRow";
import {
useContractList,
useContractListSummary,
} from "@/hooks/contracts/useContracts";
import type { ContractListFilter } from "@/services/contracts.service";
import {
Badge,
DataTable,
DataTableFooter,
usePagination,
type ColumnDef,
} from "@edr/ui-common";
function getStatusesForTab(tab: ContractStatusTabKey): string | undefined {
const match = CONTRACT_LIST_TABS.find((t) => t.key === tab);
if (!match?.statuses?.length) return undefined;
return match.statuses.join(",");
}
function formatDate(value: string | null | undefined): string {
if (!value) return "—";
const d = new Date(value);
return Number.isNaN(d.getTime())
? "—"
: d.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
export default function ContractRequestsPage() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [activeTab, setActiveTab] = useState<ContractStatusTabKey>("all");
const tabStatuses = getStatusesForTab(activeTab);
const filter: ContractListFilter = useMemo(
() => ({
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
sortBy: "createdAt",
sortOrder: "DESC",
tab: activeTab,
...(tabStatuses ? { statuses: tabStatuses } : {}),
}),
[pagination.pageIndex, pagination.pageSize, activeTab, tabStatuses],
);
const { data, isLoading, isError, refetch, isFetching } =
useContractList(filter);
const {
data: summary,
isLoading: summaryLoading,
refetch: refetchSummary,
} = useContractListSummary(filter);
const rows = useMemo(() => {
const items = (data?.items ?? []).map(toContractListRow);
const q = query.trim().toLowerCase();
if (!q) return items;
return items.filter(
(c) =>
c.reference.toLowerCase().includes(q) ||
c.customerLabel.toLowerCase().includes(q),
);
}, [data?.items, query]);
const total = data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const showEmpty = !isLoading && !isError && rows.length === 0;
const metrics = summary?.metrics;
const tabCounts = summary?.tabs;
const handleRefresh = useCallback(() => {
void refetch();
void refetchSummary();
}, [refetch, refetchSummary]);
const handleRowClick = useCallback(
(row: ContractListRow) => {
navigate(`/dashboard/contract-requests/${row.id}`);
},
[navigate],
);
const columns: ColumnDef<ContractListRow>[] = [
{
id: "contract",
header: () => <span className={bookingTable.headerCell}>Contract</span>,
cell: ({ row }) => {
const c = row.original;
return (
<div className="flex items-center gap-3 py-1.5">
<div className={bookingTable.rowIcon}>
<FileText className="size-4" strokeWidth={1.75} />
</div>
<div className="min-w-0">
<p className="truncate font-medium text-foreground">
{c.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" />
{c.customerLabel}
</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"
>
{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: "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]",
},
},
{
id: "approval",
header: () => <span className={bookingTable.headerCell}>Approval</span>,
cell: ({ row }) => <ContractApprovalProgressCell row={row.original} />,
},
{
id: "validity",
header: () => <span className={bookingTable.headerCell}>Validity</span>,
cell: ({ row }) => {
const c = row.original;
return (
<Stack gap={2}>
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
<CalendarClock className="size-3.5" />
{c.validUntil
? `Until ${formatDate(c.validUntil)}`
: c.validityDays
? `${c.validityDays} days`
: "—"}
</span>
{c.validFrom ? (
<Text size="xs" c="dimmed">
From {formatDate(c.validFrom)}
</Text>
) : null}
</Stack>
);
},
},
{
id: "kind",
header: () => <span className={bookingTable.headerCell}>Kind</span>,
cell: ({ row }) => {
const isGeneral = row.original.contractKind === "GENERAL";
return (
<Badge
variant="outline"
className="h-5 border-border/50 bg-background/50 px-1.5 text-[10px] font-medium uppercase"
>
{isGeneral ? (
<span className="inline-flex items-center gap-1">
<Repeat className="size-3" /> General
</span>
) : (
"One-time"
)}
</Badge>
);
},
},
{
id: "actions",
header: () => <span className={bookingTable.headerCell}>Action</span>,
cell: ({ row }) => {
const action = getStaffRowAction(row.original);
if (!action) return null;
return (
<Button
size="compact-sm"
radius="md"
variant={action.variant === "filled" ? "filled" : action.variant}
color="edr-green"
onClick={(e) => {
// Don't let the row-click navigation fire as well.
e.stopPropagation();
navigate(action.to(row.original.id));
}}
>
{action.label}
</Button>
);
},
},
];
return (
<PageContainer>
<Stack gap="lg">
<PageHeader
title="Contract requests"
subtitle="Review, approve, and execute freight contract requests."
action={
<ActionIcon
variant="default"
size="lg"
radius="md"
loading={isFetching}
onClick={handleRefresh}
aria-label="Refresh"
>
<RefreshCw size={16} />
</ActionIcon>
}
/>
<KpiStrip
loading={summaryLoading}
items={[
{
label: "In queue",
value: metrics?.inQueue ?? 0,
icon: LayoutList,
color: "edr-green",
},
{
label: "Needs action",
value: metrics?.needsAction ?? 0,
icon: Clock,
color: "yellow",
},
{
label: "Urgent",
value: metrics?.urgent ?? 0,
icon: AlertTriangle,
color: "red",
},
{
label: "Closed",
value: tabCounts?.closed ?? 0,
icon: CheckCircle2,
color: "edr-green",
},
]}
/>
<ContractStatusTabs
active={activeTab}
onChange={(tab) => {
setActiveTab(tab);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
counts={tabCounts}
/>
<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="Search reference or customer…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => setQuery(e.target.value)}
rightSection={
query && (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => setQuery("")}
>
<X size={16} />
</ActionIcon>
)
}
style={{ flex: 1, minWidth: "200px" }}
radius="lg"
/>
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
</Group>
</Box>
{showEmpty ? (
<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 match this view.</Text>
</Stack>
) : (
<Box style={{ overflowX: "auto" }} w="100%">
<DataTable
columns={columns}
data={rows}
status={
isLoading ? "loading" : isError ? "error" : "success"
}
onRowClick={handleRowClick}
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>
</Card>
</Stack>
</PageContainer>
);
}

View File

@@ -0,0 +1,222 @@
import { useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Box,
Button,
Group,
Image,
Loader,
Modal,
Paper,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { ArrowLeft, FileSignature, Printer } from "lucide-react";
import toast from "react-hot-toast";
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
import { contractsService } from "@/services/contracts.service";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
/**
* Staff contract preview + sign. Staff must open and read the generated
* contract here before signing — there is no sign action on the detail page or
* the list table. Signing as STAFF is only possible once the contract has been
* generated and is in CONTRACT_READY / SIGNED_CUSTOMER.
*/
export default function ContractViewPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const qc = useQueryClient();
const iframeRef = useRef<HTMLIFrameElement>(null);
const [signOpen, setSignOpen] = useState(false);
const [signerName, setSignerName] = useState("");
const [signatureData, setSignatureData] = useState<string | null>(null);
// Offer the staff member's saved signature first; they can draw a fresh one.
const [drawNew, setDrawNew] = useState(false);
const { data, isLoading, isError, refetch } = useQuery({
queryKey: [...QUERY_KEYS.CONTRACTS.byId(id ?? ""), "contract-view"],
queryFn: () => contractsService.getContractView(id!),
enabled: Boolean(id),
});
const savedSignatureImage = data?.savedSignature?.signatureImageUrl ?? null;
const usingSaved = Boolean(savedSignatureImage) && !drawNew;
const signMutation = useMutation({
mutationFn: () =>
contractsService.signContract(id!, {
role: "STAFF",
signatureImageBase64: usingSaved
? (savedSignatureImage as string)
: (signatureData ?? ""),
signerDisplayName: signerName.trim(),
consentText: "I confirm this contract on behalf of EDR.",
}),
onSuccess: () => {
toast.success("Contract signed");
setSignOpen(false);
void refetch();
void qc.invalidateQueries({ queryKey: QUERY_KEYS.CONTRACTS.byId(id!) });
void qc.invalidateQueries({ queryKey: QUERY_KEYS.CONTRACTS.ROOT });
},
onError: () => toast.error("Failed to sign contract"),
});
const handlePrint = () => iframeRef.current?.contentWindow?.print();
const openSign = () => {
setSignerName(data?.savedSignature?.signerDisplayName ?? "");
setSignatureData(null);
setDrawNew(false);
setSignOpen(true);
};
const confirmSign = () => {
if (!signerName.trim()) return;
const image = usingSaved ? savedSignatureImage : signatureData;
if (!image) return;
signMutation.mutate();
};
if (isLoading) {
return (
<Group justify="center" mih="40vh" align="center">
<Loader color="edr-green" />
</Group>
);
}
if (isError || !data) {
return (
<Box p="xl">
<Text c="dimmed">Could not load contract.</Text>
<Button variant="default" mt="md" onClick={() => navigate(-1)}>
Go back
</Button>
</Box>
);
}
return (
<Box p={{ base: "md", md: "xl" }}>
<Box maw={920} mx="auto">
<Group justify="space-between" wrap="wrap" gap="sm" mb="md">
<Button
variant="subtle"
color="gray"
leftSection={<ArrowLeft size={16} />}
onClick={() =>
navigate(`/dashboard/contract-requests/${data.contractId}`)
}
>
Back to contract
</Button>
<Group gap="sm">
<Button
variant="default"
leftSection={<Printer size={16} />}
onClick={handlePrint}
>
Print
</Button>
{data.canSignStaff && (
<Button
color="edr-green"
leftSection={<FileSignature size={16} />}
onClick={openSign}
>
{usingSaved ? "Approve & sign" : "Sign as staff"}
</Button>
)}
</Group>
</Group>
<Paper withBorder radius="lg" p={0} style={{ overflow: "hidden" }}>
<iframe
ref={iframeRef}
srcDoc={data.html}
title="Contract document"
style={{
width: "100%",
minHeight: "80vh",
border: "none",
background: "white",
}}
/>
</Paper>
</Box>
<Modal
opened={signOpen}
onClose={() => setSignOpen(false)}
title="Sign contract as staff"
centered
radius="lg"
>
<Stack gap="md">
<Text size="sm" c="dimmed">
{data.reference} your signature is stored securely on the
contract.
</Text>
<TextInput
label="Full name"
value={signerName}
onChange={(e) => setSignerName(e.currentTarget.value)}
/>
{usingSaved ? (
<Stack gap="xs">
<Paper
withBorder
radius="md"
p="xs"
style={{ borderStyle: "dashed" }}
>
<Image
src={savedSignatureImage ?? undefined}
alt="Saved signature"
fit="contain"
h={140}
/>
</Paper>
<Button
variant="subtle"
size="compact-xs"
color="edr-green"
onClick={() => {
setDrawNew(true);
setSignatureData(null);
}}
>
Draw a new signature instead
</Button>
</Stack>
) : (
<ContractSignaturePad onChange={setSignatureData} />
)}
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setSignOpen(false)}>
Cancel
</Button>
<Button
color="edr-green"
loading={signMutation.isPending}
disabled={
signMutation.isPending ||
!signerName.trim() ||
(!usingSaved && !signatureData)
}
onClick={confirmSign}
>
{usingSaved ? "Approve & sign" : "Confirm signature"}
</Button>
</Group>
</Stack>
</Modal>
</Box>
);
}

View File

@@ -0,0 +1,247 @@
import { useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Alert,
Badge,
Box,
Button,
Group,
Loader,
Modal,
Stack,
Text,
Textarea,
} from "@mantine/core";
import {
AlertCircle,
CalendarDays,
PackagePlus,
XCircle,
} from "lucide-react";
import type { Freight } from "@edr/types";
import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { contractsService } from "@/services/contracts.service";
const fmtDate = (iso?: string | null) =>
iso
? new Intl.DateTimeFormat("en-GB", {
weekday: "short",
day: "2-digit",
month: "short",
year: "numeric",
}).format(new Date(iso))
: "—";
function lineRows(lines: Freight.RequestedShipmentLines) {
if (lines.containers?.length) {
return lines.containers.map(
(c) =>
`${c.quantity} × ${c.containerSize}` +
(c.hazardousQuantity ? ` · ${c.hazardousQuantity} hazardous` : "") +
(c.reeferQuantity ? ` · ${c.reeferQuantity} reefer` : ""),
);
}
if (lines.bulk) {
const b = lines.bulk;
const parts: string[] = [];
if (b.cargoWeightTons) parts.push(`${b.cargoWeightTons} tons`);
if (b.itemCount) parts.push(`${b.itemCount} items`);
if (b.hazardousQuantity) parts.push(`${b.hazardousQuantity} hazardous`);
return [parts.join(" · ") || "Bulk cargo"];
}
return ["—"];
}
export default function ShipmentRequestDetailPage() {
const { id: reqId } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [rejectOpen, setRejectOpen] = useState(false);
const [rejectNote, setRejectNote] = useState("");
const { data: request, isLoading } = useQuery({
queryKey: ["shipment-request", reqId],
queryFn: () => contractsService.getBookingRequest(reqId!),
enabled: Boolean(reqId),
});
const reject = useMutation({
mutationFn: () => contractsService.rejectBookingRequest(reqId!, rejectNote),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["shipment-request-queue"] });
navigate("/dashboard/shipment-requests");
},
});
if (isLoading) {
return (
<PageContainer>
<Group justify="center" py={80}>
<Loader color="edr-green" />
</Group>
</PageContainer>
);
}
if (!request) {
return (
<PageContainer>
<PageHeader title="Request not found" backTo="/dashboard/shipment-requests" />
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
We couldn't load this shipment request.
</Alert>
</PageContainer>
);
}
const isPending = request.status === "PENDING";
const contractRef = request.contract?.reference ?? request.contractId;
return (
<PageContainer>
<Stack gap="lg">
<PageHeader
title={`Shipment request ${request.reference}`}
subtitle={`On contract ${contractRef}`}
backTo="/dashboard/shipment-requests"
breadcrumbs={[
{ label: "Shipment Requests", href: "/dashboard/shipment-requests" },
{ label: request.reference },
]}
meta={
<Badge
variant="light"
radius="sm"
color={
request.status === "PENDING"
? "edr-green"
: request.status === "ACCEPTED"
? "blue"
: "gray"
}
>
{request.status}
</Badge>
}
action={
isPending ? (
<Group gap="sm">
<Button
variant="light"
color="red"
radius="md"
leftSection={<XCircle size={16} />}
onClick={() => setRejectOpen(true)}
>
Reject
</Button>
<Button
color="edr-green"
radius="md"
leftSection={<PackagePlus size={16} />}
onClick={() =>
navigate(
`/dashboard/contracts/${request.contractId}/create-booking?requestId=${request.id}`,
)
}
>
Accept &amp; create booking
</Button>
</Group>
) : request.status === "ACCEPTED" && request.createdBookingId ? (
<Button
variant="light"
color="edr-green"
radius="md"
onClick={() =>
navigate(`/dashboard/clearance/${request.createdBookingId}`)
}
>
View booking clearance
</Button>
) : undefined
}
/>
<SectionCard icon={CalendarDays} title="Requested shipment">
<Stack gap="sm">
<Group justify="space-between">
<Text size="sm" c="dimmed">
Preferred date (informational)
</Text>
<Text size="sm" fw={600}>
{fmtDate(request.scheduledDate)}
</Text>
</Group>
<Box>
<Text size="sm" c="dimmed" mb={6}>
Quantities
</Text>
<Stack gap={4}>
{lineRows(request.requestedLines ?? {}).map((l, i) => (
<Badge
key={i}
variant="light"
color="edr-green"
radius="sm"
size="lg"
>
{l}
</Badge>
))}
</Stack>
</Box>
{request.notes ? (
<Box>
<Text size="sm" c="dimmed" mb={4}>
Customer note
</Text>
<Text size="sm">{request.notes}</Text>
</Box>
) : null}
{request.reviewNote ? (
<Alert color="red" variant="light" radius="md" mt="sm">
Rejected: {request.reviewNote}
</Alert>
) : null}
</Stack>
</SectionCard>
</Stack>
<Modal
opened={rejectOpen}
onClose={() => setRejectOpen(false)}
centered
radius="md"
title="Reject shipment request"
>
<Stack gap="md">
<Textarea
label="Reason"
placeholder="Tell the customer why this request can't proceed"
autosize
minRows={3}
value={rejectNote}
onChange={(e) => setRejectNote(e.currentTarget.value)}
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setRejectOpen(false)}>
Cancel
</Button>
<Button
color="red"
loading={reject.isPending}
onClick={() => reject.mutate()}
>
Reject request
</Button>
</Group>
</Stack>
</Modal>
</PageContainer>
);
}

View File

@@ -0,0 +1,208 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
ActionIcon,
Badge,
Box,
Group,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { ChevronRight, Inbox, PackageSearch, RefreshCw, Search } from "lucide-react";
import { DataTable, 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 { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { contractsService } from "@/services/contracts.service";
const cellMeta = {
headerClassName: ruleEngineTable.headerCell,
cellClassName: ruleEngineTable.bodyCell,
};
const fmtDate = (iso?: string | null) =>
iso
? new Intl.DateTimeFormat("en-GB", {
day: "2-digit",
month: "short",
year: "numeric",
}).format(new Date(iso))
: "—";
/** Summarize requested quantities for the list row. */
function summarizeLines(lines: Freight.RequestedShipmentLines): string {
if (lines.containers?.length) {
return lines.containers
.map((c) => `${c.quantity}× ${c.containerSize}`)
.join(", ");
}
if (lines.bulk) {
const b = lines.bulk;
if (b.cargoWeightTons) return `${b.cargoWeightTons} t bulk`;
if (b.itemCount) return `${b.itemCount} items`;
return "Bulk";
}
return "—";
}
interface RequestRow {
id: string;
reference: string;
contractReference: string;
scheduledDate?: string | null;
summary: string;
}
export default function ShipmentRequestsPage() {
const navigate = useNavigate();
const [query, setQuery] = useState("");
const { data, isLoading, isError, isFetching, refetch } = useQuery({
queryKey: ["shipment-request-queue"],
queryFn: () => contractsService.getBookingRequestQueue(),
refetchInterval: 30_000,
});
const rows = useMemo<RequestRow[]>(() => {
const all = (data ?? []).map((r) => ({
id: r.id,
reference: r.reference || r.id.slice(0, 8),
contractReference: r.contract?.reference ?? r.contractId,
scheduledDate: r.scheduledDate,
summary: summarizeLines(r.requestedLines ?? {}),
}));
const q = query.trim().toLowerCase();
if (!q) return all;
return all.filter(
(r) =>
r.reference.toLowerCase().includes(q) ||
r.contractReference.toLowerCase().includes(q) ||
r.summary.toLowerCase().includes(q),
);
}, [data, query]);
const columns = useMemo<ColumnDef<RequestRow>[]>(
() => [
{
id: "reference",
header: "Request",
meta: cellMeta,
cell: ({ row }) => (
<Text size="sm" fw={700} c="dark.5">
{row.original.reference}
</Text>
),
},
{
id: "contract",
header: "Contract",
meta: cellMeta,
cell: ({ row }) => (
<Text size="sm" c="gray.7">
{row.original.contractReference}
</Text>
),
},
{
id: "summary",
header: "Requested",
meta: cellMeta,
cell: ({ row }) => (
<Badge variant="light" color="edr-green" radius="sm">
{row.original.summary}
</Badge>
),
},
{
id: "date",
header: "Preferred date",
meta: cellMeta,
cell: ({ row }) => (
<Text size="sm">{fmtDate(row.original.scheduledDate)}</Text>
),
},
{
id: "go",
size: 56,
cell: () => (
<Group justify="flex-end" pr="xs">
<ChevronRight size={16} className="text-muted-foreground" />
</Group>
),
},
],
[],
);
return (
<PageContainer>
<Stack gap="lg">
<PageHeader
title="Shipment Requests"
subtitle="Customer requests to ship under general customs contracts. Accept one to create the booking and start its clearance."
meta={
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<PackageSearch size={13} />}
>
{rows.length} pending
</Badge>
}
action={
<ActionIcon
variant="default"
size="lg"
radius="md"
onClick={() => refetch()}
loading={isFetching}
aria-label="Refresh"
>
<RefreshCw size={16} />
</ActionIcon>
}
/>
<TextInput
radius="md"
maw={360}
placeholder="Search request, contract, cargo…"
leftSection={<Search size={15} />}
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
/>
{rows.length === 0 && !isLoading ? (
<Box
py={56}
style={{
borderRadius: 14,
border: "1px dashed var(--mantine-color-gray-3)",
textAlign: "center",
}}
>
<Inbox size={26} className="text-muted-foreground" />
<Text c="dimmed" mt="sm">
No pending shipment requests.
</Text>
</Box>
) : (
<DataTable
columns={columns}
data={rows}
status={isLoading ? "loading" : isError ? "error" : "success"}
onRowClick={(row) =>
navigate(`/dashboard/shipment-requests/${row.id}`)
}
containerClassName="overflow-x-auto rounded-lg border border-edr-border"
/>
)}
</Stack>
</PageContainer>
);
}

View File

@@ -42,6 +42,7 @@ import {
humanize,
} from "@/components/customers";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { fileViewUrl } from "@/constants/apiConfig";
import { api } from "@/services/api";
import type {
CompanyProfile,
@@ -286,7 +287,7 @@ export default function CustomerDetailPage() {
cell: ({ row }) => (
<ActionIcon
component="a"
href={row.original.url ?? "#"}
href={fileViewUrl(row.original.id, true)}
variant="subtle"
color="gray"
aria-label="Download"

View File

@@ -2,6 +2,7 @@ import { useState } from "react";
import {
AlertCircle,
Banknote,
FileSignature,
FileText,
Train,
UserCheck,
@@ -31,7 +32,13 @@ const TAB_ITEMS: Array<{
value: OverviewTabKey;
label: string;
icon: typeof FileText;
kpiKey: "bookings" | "billing" | "operations" | "customers" | "staff";
kpiKey:
| "bookings"
| "contracts"
| "billing"
| "operations"
| "customers"
| "staff";
metricKey: string;
}> = [
{
@@ -41,6 +48,13 @@ const TAB_ITEMS: Array<{
kpiKey: "bookings",
metricKey: "totalActive",
},
{
value: "contracts",
label: "Contracts",
icon: FileSignature,
kpiKey: "contracts",
metricKey: "totalActive",
},
{
value: "billing",
label: "Billing",

View File

@@ -246,7 +246,6 @@ const FleetResourcePage = () => {
meta: { headerClassName, cellClassName },
cell: ({ row }) => {
const value = (row.original as unknown as Record<string, unknown>)[col.accessorKey];
console.log(`${col.accessorKey}:`, value, 'format:', col.format);
return formatFleetCell(value, col.format, col.accessorKey);
},
}));

View File

@@ -147,8 +147,8 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
{ id: "maxPullWeightTons", header: "Max pull (tons)", accessorKey: "maxPullWeightTons", format: "number" },
{ id: "maxTrainLengthMeters", header: "Max length (m)", accessorKey: "maxTrainLengthMeters", format: "number" },
],
// Code is auto-generated server-side (LOCO-NNN) — omitted from the form.
formFields: [
{ name: "code", label: "Code", type: "text", required: true },
{ name: "name", label: "Name", type: "text" },
{ name: "locomotiveType", label: "Locomotive type", type: "select", required: true, options: LOCOMOTIVE_TYPE_OPTIONS },
{ name: "status", label: "Status", type: "select", required: true, options: LOCOMOTIVE_STATUS_OPTIONS },
@@ -160,12 +160,11 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
{ name: "maxSpeedKmh", label: "Max speed (km/h)", type: "number" },
],
emptyValues: {
code: "",
name: "",
locomotiveType: "DIESEL",
status: "AVAILABLE",
currentYardId: "",
maxPullWeightTons: 0,
maxPullWeightTons: 2500,
maxTrainLengthMeters: 760,
powerKw: "",
tractionForceKn: "",

View File

@@ -183,7 +183,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
slug: "container-types",
label: "Container Types",
category: "configuration",
subtitle: "Configure container sizes and wagon capacity",
subtitle: "Configure container sizes",
searchPlaceholder: "Search container types...",
orderConfig: { field: "displayOrder", label: "Display order" },
columns: [
@@ -191,14 +191,11 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
{ id: "label", header: "Label", accessorKey: "label" },
{ id: "displayOrder", header: "#", accessorKey: "displayOrder", format: "number" },
{ id: "sizeFt", header: "Size (ft)", accessorKey: "sizeFt", format: "number" },
{ id: "wagonsPerUnit", header: "Wagons / unit", accessorKey: "wagonsPerUnit", format: "number" },
activeColumn,
],
formFields: [
{ name: "label", label: "Label", type: "text", required: true },
{ name: "sizeFt", label: "Size (ft)", type: "number", required: true },
{ name: "wagonsPerUnit", label: "Wagons per unit", type: "number", required: true },
{ name: "isReefer", label: "Reefer", type: "boolean" },
{ name: "isOpenTop", label: "Open top", type: "boolean" },
{ name: "isActive", label: "Active", type: "boolean" },
],

View File

@@ -55,6 +55,7 @@ import {
WindowStatusPill,
} from "@/components/trainScheduling/batchVisuals";
import { RouteCorridor } from "@/components/trainScheduling/scheduleVisuals";
import { BookingsManager } from "./BookingsManager";
import { useMutation, useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
@@ -482,18 +483,32 @@ export default function BatchScheduleDetailPage() {
}),
);
// Batch bookings by state for the composition side panel (payment / expired lists).
const batchBookings = useMemo(() => {
if (!data) return { awaitingPayment: [], expired: [] };
const all = [
// Every booking on this schedule, flattened across windows + pending-contract,
// de-duplicated (a booking only appears once). Feeds the management table.
const allBookings = useMemo(() => {
if (!data) return [] as BatchBoardBookingDetail[];
const merged = [
...data.windows.flatMap((w) => w.bookings),
...data.pendingContract.bookings,
];
const byId = new Map<string, BatchBoardBookingDetail>();
for (const b of merged) if (!byId.has(b.id)) byId.set(b.id, b);
return [...byId.values()];
}, [data]);
// Batch bookings by state for the composition side panel (payment / expired lists).
const batchBookings = useMemo(() => {
const all = allBookings;
return {
awaitingPayment: all.filter((b) => b.state === "SELECTED_FOR_BATCH"),
expired: all.filter((b) => b.state === "EXPIRED"),
};
}, [data]);
}, [allBookings]);
const bookingsReadOnly = useMemo(
() => ["DISPATCHED", "ARRIVED"].includes(data?.status ?? ""),
[data?.status],
);
// Group the flat window list into per-day sections (one per EAT calendar date).
const dayGroups = useMemo(() => {
@@ -822,6 +837,40 @@ export default function BatchScheduleDetailPage() {
</Alert>
) : null}
{/* Manage bookings — search, filter, remove / re-assign (bulk too) */}
<Paper
radius="lg"
withBorder
p="lg"
mt="lg"
style={{ borderColor: "var(--mantine-color-gray-2)" }}
>
<Group gap="sm" mb="md" wrap="nowrap" align="flex-start">
<ThemeIcon
size={38}
radius="md"
variant="light"
color="#F2A516"
>
<Package size={19} />
</ThemeIcon>
<Box>
<Title order={4}>Manage bookings</Title>
<Text size="sm" c="dimmed">
Search and filter every booking on this train. Remove an
allocated booking to free its wagons, or re-assign one that
is not yet allocated individually or in bulk.
</Text>
</Box>
</Group>
<BookingsManager
scheduleId={scheduleId ?? ""}
bookings={allBookings}
onChanged={() => void refetch()}
readOnly={bookingsReadOnly}
/>
</Paper>
{/* Batch windows */}
<Paper
radius="lg"

View File

@@ -0,0 +1,625 @@
import { useMemo, useState } from "react";
import {
ActionIcon,
Badge,
Box,
Button,
Checkbox,
Group,
Menu,
Modal,
Paper,
Select,
Stack,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
import {
AlertTriangle,
MoreVertical,
PackagePlus,
Search,
Trash2,
X,
} from "lucide-react";
import { useMutation } from "@tanstack/react-query";
import { DataTable, type ColumnDef } from "@edr/ui-common";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
import type {
BatchBoardBookingDetail,
BatchBoardBookingState,
BookingAllocationStatus,
} from "@/types/trainScheduling";
const cellMeta = {
headerClassName: ruleEngineTable.headerCell,
cellClassName: ruleEngineTable.bodyCell,
};
const fmtTons = (n: number) =>
`${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} t`;
const fmtDateTime = (iso: string | null) =>
iso
? new Intl.DateTimeFormat("en-GB", {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone: "Africa/Addis_Ababa",
}).format(new Date(iso))
: "—";
const initials = (name: string) =>
name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((w) => w[0])
.join("")
.toUpperCase() || "?";
const STATE_META: Record<
BatchBoardBookingState,
{ label: string; color: string }
> = {
ALLOCATED: { label: "Allocated", color: "edr-green" },
SELECTED_FOR_BATCH: { label: "Selected for batch", color: "orange" },
READY: { label: "Ready for batch", color: "teal" },
WAITING: { label: "Paid · waiting", color: "blue" },
PENDING_CONTRACT: { label: "Pending contract", color: "gray" },
EXPIRED: { label: "Expired", color: "red" },
};
const ALLOC_META: Record<
BookingAllocationStatus,
{ label: string; color: string }
> = {
ASSIGNED: { label: "Wagons assigned", color: "edr-green" },
NOT_ATTEMPTED: { label: "Not allocated", color: "gray" },
DEFERRED: { label: "Deferred", color: "orange" },
FAILED: { label: "Allocation failed", color: "red" },
};
const STATE_FILTERS = [
{ value: "ALL", label: "All states" },
...Object.entries(STATE_META).map(([value, m]) => ({
value,
label: m.label,
})),
];
const ALLOC_FILTERS = [
{ value: "ALL", label: "All allocations" },
...Object.entries(ALLOC_META).map(([value, m]) => ({
value,
label: m.label,
})),
];
export interface BookingsManagerProps {
scheduleId: string;
bookings: BatchBoardBookingDetail[];
/** Re-pull the batch-board detail after a remove / re-assign mutation. */
onChanged: () => void;
/** Read-only when the schedule can no longer be edited (dispatched / arrived). */
readOnly?: boolean;
}
/**
* Searchable, filterable, bulk-manageable booking table for the batch board.
* Staff can search by reference / customer, filter by batch state and wagon
* allocation status, and remove or re-assign bookings individually or in bulk.
* Wraps the shared DataTable; selection + actions are handled locally so the
* surrounding accordion / tab layout stays untouched.
*/
export function BookingsManager({
scheduleId,
bookings,
onChanged,
readOnly = false,
}: BookingsManagerProps) {
const { toast } = useToast();
const [query, setQuery] = useState("");
const [stateFilter, setStateFilter] = useState<string>("ALL");
const [allocFilter, setAllocFilter] = useState<string>("ALL");
const [selected, setSelected] = useState<Set<string>>(new Set());
const [confirm, setConfirm] = useState<
| { kind: "remove"; ids: string[]; label: string }
| { kind: "reassign"; ids: string[]; label: string }
| null
>(null);
const unassign = useMutation(
api.trainScheduling.unassignBooking.mutationOptions(),
);
const reassign = useMutation(
api.trainScheduling.assignUnassignedBooking.mutationOptions(),
);
const busy = unassign.isPending || reassign.isPending;
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return bookings.filter((b) => {
if (stateFilter !== "ALL" && b.state !== stateFilter) return false;
if (allocFilter !== "ALL" && b.allocationStatus !== allocFilter)
return false;
if (!q) return true;
return (
b.reference.toLowerCase().includes(q) ||
b.company.toLowerCase().includes(q)
);
});
}, [bookings, query, stateFilter, allocFilter]);
// Selection is bounded to whatever is currently visible (filtered) to avoid
// acting on rows the user can't see.
const visibleIds = useMemo(() => filtered.map((b) => b.id), [filtered]);
const selectedVisible = useMemo(
() => visibleIds.filter((id) => selected.has(id)),
[visibleIds, selected],
);
const allVisibleSelected =
visibleIds.length > 0 && selectedVisible.length === visibleIds.length;
const someVisibleSelected =
selectedVisible.length > 0 && !allVisibleSelected;
const toggleAll = () =>
setSelected((prev) => {
const next = new Set(prev);
if (allVisibleSelected) {
visibleIds.forEach((id) => next.delete(id));
} else {
visibleIds.forEach((id) => next.add(id));
}
return next;
});
const toggleOne = (id: string) =>
setSelected((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
const clearSelection = () => setSelected(new Set());
const runRemove = async (ids: string[]) => {
let ok = 0;
let failed = 0;
// Sequential — each unassign mutates the schedule graph; parallel would race.
for (const id of ids) {
try {
await unassign.mutateAsync({ id: scheduleId, bookingId: id });
ok += 1;
} catch {
failed += 1;
}
}
toast({
title: "Bookings removed",
description: `${ok} removed${failed ? ` · ${failed} failed` : ""}`,
variant: failed ? "destructive" : "default",
});
clearSelection();
onChanged();
};
const runReassign = async (ids: string[]) => {
let ok = 0;
let failed = 0;
for (const id of ids) {
try {
await reassign.mutateAsync({ id: scheduleId, bookingId: id });
ok += 1;
} catch {
failed += 1;
}
}
toast({
title: "Re-assignment run",
description: `${ok} re-assigned${failed ? ` · ${failed} failed` : ""}`,
variant: failed ? "destructive" : "default",
});
clearSelection();
onChanged();
};
const confirmAction = async () => {
if (!confirm) return;
const ids = confirm.ids;
setConfirm(null);
if (confirm.kind === "remove") await runRemove(ids);
else await runReassign(ids);
};
const columns = useMemo<ColumnDef<BatchBoardBookingDetail>[]>(() => {
const cols: ColumnDef<BatchBoardBookingDetail>[] = [];
if (!readOnly) {
cols.push({
id: "select",
meta: cellMeta,
header: () => (
<Checkbox
size="xs"
aria-label="Select all"
checked={allVisibleSelected}
indeterminate={someVisibleSelected}
onChange={toggleAll}
/>
),
cell: ({ row }) => (
<Checkbox
size="xs"
aria-label={`Select ${row.original.reference}`}
checked={selected.has(row.original.id)}
onChange={() => toggleOne(row.original.id)}
/>
),
});
}
cols.push(
{
id: "reference",
header: "Reference",
meta: cellMeta,
cell: ({ row }) => {
const b = row.original;
return (
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={700} c="dark.5">
{b.reference}
</Text>
{b.isGovernment ? (
<Badge size="xs" variant="light" color="grape">
Gov
</Badge>
) : null}
</Group>
);
},
},
{
id: "customer",
header: "Customer",
meta: cellMeta,
cell: ({ row }) => {
const b = row.original;
return (
<Group gap={8} wrap="nowrap">
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 26,
height: 26,
borderRadius: 8,
flexShrink: 0,
background: "#FEF1D5",
border: "1px solid #FBD171",
}}
>
<Text size="10px" fw={800} style={{ color: "#B26C09" }}>
{initials(b.company)}
</Text>
</Box>
<Text size="sm" c="gray.7" truncate>
{b.company}
</Text>
</Group>
);
},
},
{
id: "selectedForBatch",
header: "Selected for batch",
meta: cellMeta,
cell: ({ row }) => {
const b = row.original;
if (!b.selectedForBatchAt)
return (
<Text size="sm" c="dimmed">
</Text>
);
return (
<>
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
{fmtDateTime(b.selectedForBatchAt)} EAT
</Text>
{b.paymentDeadline ? (
<Text size="xs" c="orange.7" fw={600}>
Pay by {fmtDateTime(b.paymentDeadline)} EAT
</Text>
) : null}
</>
);
},
},
{
id: "capacity",
header: "Capacity",
meta: cellMeta,
cell: ({ row }) => {
const b = row.original;
return (
<Group gap={4} wrap="nowrap">
<Badge variant="default" radius="sm" size="sm">
{b.wagons}w
</Badge>
<Badge variant="default" radius="sm" size="sm">
{fmtTons(b.weightTons)}
</Badge>
</Group>
);
},
},
{
id: "state",
header: "Batch state",
meta: cellMeta,
cell: ({ row }) => {
const m = STATE_META[row.original.state];
return (
<Badge variant="light" color={m.color} radius="sm">
{m.label}
</Badge>
);
},
},
{
id: "allocation",
header: "Wagon allocation",
meta: cellMeta,
cell: ({ row }) => {
const b = row.original;
const m = ALLOC_META[b.allocationStatus];
const badge = (
<Badge variant="light" color={m.color} radius="sm">
{m.label}
</Badge>
);
if (!b.allocationIssue) return badge;
return (
<Tooltip label={b.allocationIssue} multiline maw={320} withArrow>
<Group gap={4} wrap="nowrap">
{badge}
<AlertTriangle size={14} color="var(--mantine-color-red-6)" />
</Group>
</Tooltip>
);
},
},
);
if (!readOnly) {
cols.push({
id: "actions",
header: "",
meta: cellMeta,
cell: ({ row }) => {
const b = row.original;
const isAssigned = b.allocationStatus === "ASSIGNED";
return (
<Group justify="flex-end" gap={4} wrap="nowrap">
<Menu position="bottom-end" withinPortal shadow="md">
<Menu.Target>
<ActionIcon variant="subtle" color="gray" aria-label="Actions">
<MoreVertical size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<PackagePlus size={14} />}
disabled={isAssigned || busy}
onClick={() =>
setConfirm({
kind: "reassign",
ids: [b.id],
label: b.reference,
})
}
>
Re-assign to wagons
</Menu.Item>
<Menu.Item
color="red"
leftSection={<Trash2 size={14} />}
disabled={!isAssigned || busy}
onClick={() =>
setConfirm({
kind: "remove",
ids: [b.id],
label: b.reference,
})
}
>
Remove from train
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
);
},
});
}
return cols;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
readOnly,
selected,
allVisibleSelected,
someVisibleSelected,
visibleIds,
busy,
]);
return (
<Stack gap="sm">
{/* Toolbar: search + filters */}
<Group gap="sm" wrap="wrap">
<TextInput
flex={1}
miw={220}
radius="md"
placeholder="Search reference or customer…"
leftSection={<Search size={15} />}
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
rightSection={
query ? (
<ActionIcon
variant="subtle"
color="gray"
size="sm"
onClick={() => setQuery("")}
aria-label="Clear search"
>
<X size={14} />
</ActionIcon>
) : null
}
/>
<Select
radius="md"
w={170}
data={STATE_FILTERS}
value={stateFilter}
onChange={(v) => setStateFilter(v ?? "ALL")}
aria-label="Filter by batch state"
/>
<Select
radius="md"
w={180}
data={ALLOC_FILTERS}
value={allocFilter}
onChange={(v) => setAllocFilter(v ?? "ALL")}
aria-label="Filter by allocation"
/>
<Text size="xs" c="dimmed">
{filtered.length} of {bookings.length}
</Text>
</Group>
{/* Bulk action bar */}
{!readOnly && selectedVisible.length > 0 ? (
<Paper
withBorder
radius="md"
px="md"
py="xs"
style={{
background: "var(--mantine-color-edr-green-0)",
borderColor: "var(--mantine-color-edr-green-2)",
}}
>
<Group justify="space-between" wrap="wrap" gap="sm">
<Group gap="xs" wrap="nowrap">
<Badge color="edr-green" radius="sm">
{selectedVisible.length} selected
</Badge>
<Button
size="compact-xs"
variant="subtle"
color="gray"
onClick={clearSelection}
>
Clear
</Button>
</Group>
<Group gap="xs" wrap="nowrap">
<Button
size="compact-sm"
variant="light"
color="edr-green"
leftSection={<PackagePlus size={14} />}
loading={reassign.isPending}
disabled={busy}
onClick={() =>
setConfirm({
kind: "reassign",
ids: selectedVisible,
label: `${selectedVisible.length} booking(s)`,
})
}
>
Re-assign
</Button>
<Button
size="compact-sm"
variant="light"
color="red"
leftSection={<Trash2 size={14} />}
loading={unassign.isPending}
disabled={busy}
onClick={() =>
setConfirm({
kind: "remove",
ids: selectedVisible,
label: `${selectedVisible.length} booking(s)`,
})
}
>
Remove
</Button>
</Group>
</Group>
</Paper>
) : null}
<DataTable
columns={columns}
data={filtered}
status="success"
emptyMessage={
bookings.length
? "No bookings match the current search / filters."
: "No bookings in this batch window."
}
containerClassName="overflow-x-auto rounded-lg border border-edr-border"
/>
<Modal
opened={Boolean(confirm)}
onClose={() => setConfirm(null)}
centered
radius="md"
title={
confirm?.kind === "remove"
? "Remove from train"
: "Re-assign to wagons"
}
>
<Text size="sm" mb="lg">
{confirm?.kind === "remove"
? `Remove ${confirm?.label} from this train? Their wagon allocation will be released.`
: `Re-assign ${confirm?.label} to available wagons on this train?`}
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setConfirm(null)}>
Cancel
</Button>
<Button
color={confirm?.kind === "remove" ? "red" : "edr-green"}
loading={busy}
onClick={confirmAction}
>
{confirm?.kind === "remove" ? "Remove" : "Re-assign"}
</Button>
</Group>
</Modal>
</Stack>
);
}
export default BookingsManager;

View File

@@ -53,7 +53,6 @@ import {
PreviewSummary,
ScheduleWarningsAlert,
} from "@/components/trainScheduling/ScheduleWarningsAlert";
import { shouldShowContainerPlacementStep } from "@/components/trainScheduling/schedulingContainerStep.util";
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
import { WagonPlanGrid } from "@/components/trainScheduling/WagonPlanGrid";
import { WorkflowRail, WorkflowStep } from "@/components/trainScheduling/WorkflowStep";
@@ -146,26 +145,10 @@ export default function TrainScheduleV2DetailPage() {
const containerUnits = previewResult?.containerUnits ?? [];
const containerSlots = previewResult?.containerSlotSequenceNos ?? [];
const hasContainerStep = useMemo(
() =>
shouldShowContainerPlacementStep({
containerUnitCount: containerUnits.length,
scheduleFreightType: freightType,
bookingFreightTypes: [
...(schedule?.bookings ?? []).map((b) => b.freightType),
...(eligibleQuery.data?.items ?? [])
.filter((item) => allSelectedIds.includes(item.id))
.map((item) => item.freightType),
],
}),
[
allSelectedIds,
containerUnits.length,
eligibleQuery.data?.items,
freightType,
schedule?.bookings,
],
);
// Container-number placement step removed — the customer enters container
// numbers when booking, so scheduling skips straight from the wagon plan to
// finalize. Steps: select bookings → review wagons → finalize.
const hasContainerStep = false;
const displayWagonPlan = useMemo(() => {
const savedWagons = schedule?.trainSet?.wagons ?? [];