Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight/feature/first_mile_invoice

This commit is contained in:
natib21
2026-07-07 08:33:25 +00:00
167 changed files with 9826 additions and 1225 deletions

View File

@@ -19,6 +19,7 @@ import {
} from "lucide-react";
import { CountdownTimer } from "@edr/ui-common";
import { useBookingWindowSocket } from "@/features/bookingWindows/useBookingWindowSocket";
import { api } from "@/services/api";
import type { StaffBookingWindow } from "@/types/trainScheduling";
@@ -224,6 +225,9 @@ function WindowCard({ w }: { w: StaffBookingWindow }) {
* Hidden when nothing is pending.
*/
export function GlUpcomingWindowsSection() {
// Live pushes flip cards the moment the window engine transitions a phase;
// the 60s poll below stays only as a fallback.
useBookingWindowSocket();
const { data, isLoading } = useQuery(
api.trainScheduling.allBookingWindows.queryOptions({
refetchInterval: 60_000,

View File

@@ -39,6 +39,8 @@ export interface FreightDashboardHeaderProps {
onToggleTheme: () => void;
mobileOpened: boolean;
onToggleMobile: () => void;
/** Hide the mobile burger when the shell has no sidebar to open. */
hideSidebarBurger?: boolean;
}
// Every header control is a consistent 36px frosted chip — same language as the
@@ -57,6 +59,7 @@ const FreightDashboardHeader = ({
onToggleTheme,
mobileOpened,
onToggleMobile,
hideSidebarBurger = false,
}: FreightDashboardHeaderProps) => {
const navigate = useNavigate();
@@ -88,13 +91,15 @@ const FreightDashboardHeader = ({
{/* Left: burger (mobile) + search — the search now occupies the slot
the page title used to hold; each page owns its own title. */}
<Group gap={12} wrap="nowrap" align="center" style={{ minWidth: 0 }}>
<Burger
opened={mobileOpened}
onClick={onToggleMobile}
hiddenFrom="sm"
size="sm"
aria-label="Toggle sidebar"
/>
{!hideSidebarBurger && (
<Burger
opened={mobileOpened}
onClick={onToggleMobile}
hiddenFrom="sm"
size="sm"
aria-label="Toggle sidebar"
/>
)}
<Group
gap={8}
align="center"

View File

@@ -23,6 +23,8 @@ function getInitialTheme(): Theme {
export interface FreightDashboardLayoutProps {
sidebarSections: SidebarSection[];
/** Render the shell with no navbar at all (used by GL clearance-only users). */
hideSidebar?: boolean;
activeHref?: string;
onNavigate?: (href: string) => void;
headerRight?: ReactNode;
@@ -36,6 +38,7 @@ export interface FreightDashboardLayoutProps {
const FreightDashboardLayout = ({
sidebarSections,
hideSidebar = false,
activeHref = "",
onNavigate,
headerRight,
@@ -75,11 +78,17 @@ const FreightDashboardLayout = ({
padding={0}
className="bg-edr-bg"
header={{ height: HEADER_HEIGHT }}
navbar={{
width: NAVBAR_WIDTH,
breakpoint: "sm",
collapsed: { mobile: !mobileOpened },
}}
// When the sidebar is hidden the navbar slot is dropped entirely so Main
// spans the full viewport width (GL clearance-only users).
navbar={
hideSidebar
? undefined
: {
width: NAVBAR_WIDTH,
breakpoint: "sm",
collapsed: { mobile: !mobileOpened },
}
}
>
<FreightDashboardHeader
pageMeta={pageMeta}
@@ -93,14 +102,17 @@ const FreightDashboardLayout = ({
onToggleTheme={toggleTheme}
mobileOpened={mobileOpened}
onToggleMobile={toggleMobile}
hideSidebarBurger={hideSidebar}
/>
<FreightSidebar
sections={sidebarSections}
activeHref={activeHref}
onNavigate={navigate}
onClose={closeMobile}
/>
{!hideSidebar && (
<FreightSidebar
sections={sidebarSections}
activeHref={activeHref}
onNavigate={navigate}
onClose={closeMobile}
/>
)}
<AppShell.Main>
{/* Internal scroll keeps the fixed-viewport model the dashboard pages

View File

@@ -32,6 +32,7 @@ const DEFAULTS = {
docReviewMinutes: 30,
paymentWindowMinutes: 60,
importWindowLeadDays: 3,
exportBookingLeadHours: 24,
};
/** 12-hour label for an EAT hour 023, e.g. 8 → "8:00 AM", 17 → "5:00 PM". */
@@ -53,6 +54,7 @@ interface FormState {
docReviewMinutes: number | "";
paymentWindowMinutes: number | "";
importWindowLeadDays: number | "";
exportBookingLeadHours: number | "";
}
function parseError(error: unknown, fallback: string): string {
@@ -112,6 +114,8 @@ export default function BookingWindowSettingsModal({
r?.paymentWindowMinutes ?? DEFAULTS.paymentWindowMinutes,
importWindowLeadDays:
r?.importWindowLeadDays ?? DEFAULTS.importWindowLeadDays,
exportBookingLeadHours:
r?.exportBookingLeadHours ?? DEFAULTS.exportBookingLeadHours,
});
}, [opened, schedule]);
@@ -142,15 +146,20 @@ export default function BookingWindowSettingsModal({
const doc = Number(form.docReviewMinutes);
const pay = Number(form.paymentWindowMinutes);
const lead = Number(form.importWindowLeadDays);
const exportLead = Number(form.exportBookingLeadHours);
const leadInvalid = isExport
? form.exportBookingLeadHours === "" ||
!Number.isFinite(exportLead) ||
exportLead < 1
: form.importWindowLeadDays === "" || !Number.isFinite(lead);
if (
form.windowDurationHours === "" ||
form.docReviewMinutes === "" ||
form.paymentWindowMinutes === "" ||
form.importWindowLeadDays === "" ||
!Number.isFinite(duration) ||
!Number.isFinite(doc) ||
!Number.isFinite(pay) ||
!Number.isFinite(lead)
leadInvalid
) {
toast({
title: "Fill every field before saving",
@@ -164,7 +173,9 @@ export default function BookingWindowSettingsModal({
windowDurationHours: duration,
docReviewMinutes: doc,
paymentWindowMinutes: pay,
importWindowLeadDays: lead,
...(isExport
? { exportBookingLeadHours: exportLead }
: { importWindowLeadDays: lead }),
};
try {
@@ -223,8 +234,10 @@ export default function BookingWindowSettingsModal({
<Stack gap="lg">
{isExport ? (
<Alert variant="light" color="blue" icon={<Info size={16} />}>
Export schedules use a single FCFS lead window the daily desk
hours below don't apply, only the lead time does.
Export schedules use a single first-come-first-served window: it
opens the export lead time before departure shifted to the next
desk opening if that lands outside desk hours and stays open
until departure. Cycle timing below doesn't apply.
</Alert>
) : null}
@@ -263,7 +276,6 @@ export default function BookingWindowSettingsModal({
}
allowDeselect={false}
comboboxProps={{ withinPortal: true }}
disabled={isExport}
/>
<Select
label="Closes"
@@ -275,7 +287,6 @@ export default function BookingWindowSettingsModal({
}
allowDeselect={false}
comboboxProps={{ withinPortal: true }}
disabled={isExport}
/>
</Group>
{isOvernight && !is24h ? (
@@ -290,7 +301,6 @@ export default function BookingWindowSettingsModal({
color="grape"
label="Run 24 hours a day (never pause overnight)"
checked={is24h}
disabled={isExport}
onChange={(e) => {
const checked = e.currentTarget.checked;
setForm((f) => {
@@ -304,12 +314,11 @@ export default function BookingWindowSettingsModal({
});
}}
/>
{!isExport ? (
<Text size="xs" c="dimmed" mt={6}>
A not-yet-full train pauses at the close hour and resumes the next
morning at the open hour, every day until it fills or departs.
</Text>
) : null}
<Text size="xs" c="dimmed" mt={6}>
{isExport
? "If the export lead time lands while the desk is shut, booking opens at the next desk opening instead."
: "A not-yet-full train pauses at the close hour and resumes the next morning at the open hour, every day until it fills or departs."}
</Text>
</Box>
<Divider />
@@ -367,27 +376,43 @@ export default function BookingWindowSettingsModal({
<Divider />
{/* ── Lead time ────────────────────────────────────────────────── */}
<NumberInput
label={isExport ? "Booking lead (days)" : "Window lead (days)"}
description={
isExport
? "How many days before departure export booking opens"
: "How many days before departure the booking window starts"
}
value={form.importWindowLeadDays}
onChange={(v) =>
setForm(
(f) =>
f && {
...f,
importWindowLeadDays: v === "" ? "" : Number(v),
},
)
}
min={0}
clampBehavior="none"
allowDecimal={false}
/>
{isExport ? (
<NumberInput
label="Export booking lead (hours)"
description="How many hours before departure the export booking window opens"
value={form.exportBookingLeadHours}
onChange={(v) =>
setForm(
(f) =>
f && {
...f,
exportBookingLeadHours: v === "" ? "" : Number(v),
},
)
}
min={1}
clampBehavior="none"
allowDecimal={false}
/>
) : (
<NumberInput
label="Window lead (days)"
description="How many days before departure the booking window starts"
value={form.importWindowLeadDays}
onChange={(v) =>
setForm(
(f) =>
f && {
...f,
importWindowLeadDays: v === "" ? "" : Number(v),
},
)
}
min={0}
clampBehavior="none"
allowDecimal={false}
/>
)}
<Group justify="flex-end" mt="xs">
<Button variant="default" onClick={onClose} disabled={save.isPending}>

View File

@@ -0,0 +1,376 @@
import { useState } from "react";
import {
Alert,
Badge,
Button,
Checkbox,
Group,
Loader,
Paper,
Stack,
Table,
Text,
Tooltip,
} from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertCircle, ArrowRight, PackageCheck, PackageOpen, TrainFront } from "lucide-react";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
import type {
IntercityBookingRow,
IntercityCapacity,
} from "@/types/trainScheduling";
const parseError = (error: unknown, fallback: string) => {
const message = (error as { response?: { data?: { message?: string | string[] } } })
?.response?.data?.message;
if (Array.isArray(message)) return message.join("; ");
return message || (error as Error)?.message || fallback;
};
function fmt(n: number): string {
return Number.isInteger(n) ? String(n) : n.toFixed(1);
}
function CapacityBadges({ capacity }: { capacity: IntercityCapacity | null }) {
if (!capacity) {
return (
<Text size="sm" c="dimmed">
Capacity unknown schedule has no locomotive/train set yet.
</Text>
);
}
return (
<Group gap="xs">
<Badge variant="light" color={capacity.wagons > 0 ? "teal" : "red"}>
{fmt(capacity.wagons)} wagons free
</Badge>
<Badge variant="light" color={capacity.weightTons > 0 ? "teal" : "red"}>
{fmt(capacity.weightTons)} t free
</Badge>
<Badge variant="light" color={capacity.lengthMeters > 0 ? "teal" : "red"}>
{fmt(capacity.lengthMeters)} m free
</Badge>
</Group>
);
}
function NeedCells({ need }: { need: IntercityCapacity | null }) {
if (!need) return <Table.Td colSpan={3}></Table.Td>;
return (
<>
<Table.Td>{fmt(need.wagons)}</Table.Td>
<Table.Td>{fmt(need.weightTons)} t</Table.Td>
<Table.Td>{fmt(need.lengthMeters)} m</Table.Td>
</>
);
}
function CorridorCell({ row }: { row: IntercityBookingRow }) {
return (
<Group gap={6} wrap="nowrap">
<Text size="sm">{row.origin}</Text>
<ArrowRight size={13} />
<Text size="sm">{row.destination}</Text>
</Group>
);
}
/**
* Intercity ride-along desk for one import/export schedule: waiting intercity
* bookings whose corridor lies on this train's route, checked against the
* remaining wagon/weight/length budget. Accepting opens the customer's pay
* window; after payment the booking is allocated. Loading/unloading is
* confirmed manually when the train is physically at the booking's origin /
* destination yard (the server validates against recorded checkpoints).
*/
export function IntercityRideAlongPanel({
scheduleId,
direction,
}: {
scheduleId: string;
direction: string | null | undefined;
}) {
const { toast } = useToast();
const queryClient = useQueryClient();
const [selected, setSelected] = useState<string[]>([]);
const candidatesQuery = useQuery(
api.trainScheduling.intercityCandidates.queryOptions({
input: { scheduleId },
refetchInterval: 60_000,
}),
);
const invalidate = () =>
queryClient.invalidateQueries({
queryKey: api.trainScheduling.intercityCandidates.queryKey({ scheduleId }),
});
const accept = useMutation(
api.trainScheduling.acceptIntercityBookings.mutationOptions({
onSuccess: (result) => {
setSelected([]);
void invalidate();
if (result.accepted.length > 0) {
toast({
title: `${result.accepted.length} intercity booking(s) accepted`,
description: "Customers have been asked to pay.",
});
}
for (const r of result.rejected) {
toast({
title: "Booking skipped",
description: r.reason,
variant: "destructive",
});
}
},
onError: (err) =>
toast({
title: "Accept failed",
description: parseError(err, "Could not accept intercity bookings"),
variant: "destructive",
}),
}),
);
const load = useMutation(
api.trainScheduling.loadIntercityBooking.mutationOptions({
onSuccess: () => {
void invalidate();
toast({ title: "Cargo loaded" });
},
onError: (err) =>
toast({
title: "Load failed",
description: parseError(err, "Could not confirm loading"),
variant: "destructive",
}),
}),
);
const unload = useMutation(
api.trainScheduling.unloadIntercityBooking.mutationOptions({
onSuccess: () => {
void invalidate();
toast({ title: "Cargo unloaded — booking completed" });
},
onError: (err) =>
toast({
title: "Unload failed",
description: parseError(err, "Could not confirm unloading"),
variant: "destructive",
}),
}),
);
// Intercity bookings only ride import/export trains.
if (direction !== "IMPORT" && direction !== "EXPORT") return null;
const data = candidatesQuery.data;
const candidates = data?.candidates ?? [];
const accepted = data?.accepted ?? [];
if (candidatesQuery.isLoading) {
return (
<Paper withBorder radius="lg" p="lg" mt="md">
<Group gap="xs">
<Loader size="xs" />
<Text size="sm" c="dimmed">
Loading intercity ride-along bookings
</Text>
</Group>
</Paper>
);
}
if (candidates.length === 0 && accepted.length === 0) return null;
return (
<Paper withBorder radius="lg" p="lg" mt="md">
<Stack gap="md">
<Group justify="space-between" wrap="wrap">
<Group gap="xs">
<TrainFront size={18} />
<Text fw={700}>Intercity ride-along</Text>
</Group>
<CapacityBadges capacity={data?.remaining ?? null} />
</Group>
{candidates.length > 0 && (
<>
<Text size="sm" c="dimmed">
Waiting intercity bookings whose corridor lies on this train's
route. Accepting opens the customer's payment window against the
free capacity above.
</Text>
<Table.ScrollContainer minWidth={720}>
<Table verticalSpacing="xs" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th w={36} />
<Table.Th>Booking</Table.Th>
<Table.Th>Customer</Table.Th>
<Table.Th>Corridor</Table.Th>
<Table.Th>Wagons</Table.Th>
<Table.Th>Weight</Table.Th>
<Table.Th>Length</Table.Th>
<Table.Th>Fits</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{candidates.map((row) => (
<Table.Tr key={row.id}>
<Table.Td>
<Checkbox
size="xs"
checked={selected.includes(row.id)}
onChange={(e) =>
setSelected((prev) =>
e.currentTarget.checked
? [...prev, row.id]
: prev.filter((id) => id !== row.id),
)
}
/>
</Table.Td>
<Table.Td>
<Group gap={6}>
<Text size="sm" fw={600}>
{row.reference ?? row.id.slice(0, 8)}
</Text>
{row.isGovernment && (
<Badge size="xs" variant="light" color="grape">
GOV
</Badge>
)}
</Group>
</Table.Td>
<Table.Td>
<Text size="sm">{row.customer}</Text>
</Table.Td>
<Table.Td>
<CorridorCell row={row} />
</Table.Td>
<NeedCells need={row.need} />
<Table.Td>
{row.fits ? (
<Badge size="sm" variant="light" color="teal">
Fits
</Badge>
) : (
<Tooltip label="Exceeds the remaining wagon/weight/length budget">
<Badge size="sm" variant="light" color="red">
No room
</Badge>
</Tooltip>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
<Group justify="flex-end">
<Button
size="xs"
color="edr-green"
loading={accept.isPending}
disabled={selected.length === 0}
onClick={() => accept.mutate({ scheduleId, bookingIds: selected })}
>
Accept {selected.length > 0 ? `${selected.length} ` : ""}onto this train
</Button>
</Group>
</>
)}
{accepted.length > 0 && (
<>
<Text size="sm" fw={600}>
On this train
</Text>
<Table.ScrollContainer minWidth={680}>
<Table verticalSpacing="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Booking</Table.Th>
<Table.Th>Customer</Table.Th>
<Table.Th>Corridor</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{accepted.map((row) => (
<Table.Tr key={row.id}>
<Table.Td>
<Text size="sm" fw={600}>
{row.reference ?? row.id.slice(0, 8)}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{row.customer}</Text>
</Table.Td>
<Table.Td>
<CorridorCell row={row} />
</Table.Td>
<Table.Td>
<Badge size="sm" variant="light">
{row.status}
</Badge>
</Table.Td>
<Table.Td>
<Group gap="xs" justify="flex-end">
{row.status === "PAID" && (
<Tooltip label="Train must be at the booking's origin yard">
<Button
size="compact-xs"
variant="light"
leftSection={<PackageCheck size={13} />}
loading={load.isPending}
onClick={() =>
load.mutate({ scheduleId, bookingId: row.id })
}
>
Load
</Button>
</Tooltip>
)}
{row.status === "IN_TRANSIT" && (
<Tooltip label="Train must be at the booking's destination yard">
<Button
size="compact-xs"
variant="light"
color="orange"
leftSection={<PackageOpen size={13} />}
loading={unload.isPending}
onClick={() =>
unload.mutate({ scheduleId, bookingId: row.id })
}
>
Unload
</Button>
</Tooltip>
)}
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
</>
)}
{candidatesQuery.isError && (
<Alert color="red" variant="light" icon={<AlertCircle size={16} />}>
{parseError(candidatesQuery.error, "Could not load intercity candidates")}
</Alert>
)}
</Stack>
</Paper>
);
}

View File

@@ -0,0 +1,211 @@
import {
Alert,
Badge,
Button,
Checkbox,
Group,
Loader,
Modal,
Select,
Stack,
Table,
Tabs,
Text,
} from '@mantine/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { FileText } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useToast } from '@/hooks/use-toast';
import {
warehouseService,
type ContainerItem,
type ContainerItemStage,
} from '@/services/warehouse.service';
import { extractErrorMessage } from './options';
import { openPdfBlob } from './pdf';
interface ContainerItemsModalProps {
opened: boolean;
onClose: () => void;
bookingId: string | null;
bookingReference?: string | null;
}
const STAGE_TABS: Array<{ value: string; label: string }> = [
{ value: 'ALL', label: 'All' },
{ value: 'RECEIVED', label: 'Received' },
{ value: 'GRN', label: "GRN'd" },
{ value: 'LOADED', label: 'Loaded' },
{ value: 'LEFT', label: 'Left' },
{ value: 'DELIVERED', label: 'Delivered' },
];
const STAGE_COLOR: Record<ContainerItemStage, string> = {
PENDING: 'gray',
RECEIVED: 'blue',
GRN: 'teal',
LOADED: 'grape',
LEFT: 'orange',
DELIVERED: 'green',
};
/** Loadable = not yet on a truck (before LOADED). */
const isLoadable = (i: ContainerItem) => i.stage === 'PENDING' || i.stage === 'RECEIVED' || i.stage === 'GRN';
export function ContainerItemsModal({ opened, onClose, bookingId, bookingReference }: ContainerItemsModalProps) {
const { toast } = useToast();
const queryClient = useQueryClient();
const [tab, setTab] = useState('ALL');
const [selected, setSelected] = useState<string[]>([]);
const [truckId, setTruckId] = useState<string | null>(null);
const itemsKey = ['container-items', bookingId];
const { data: items = [], isLoading } = useQuery({
queryKey: itemsKey,
queryFn: () => warehouseService.getContainerItems(bookingId as string),
enabled: opened && Boolean(bookingId),
});
const { data: trucks = [] } = useQuery({
queryKey: ['ci-trucks', bookingId],
queryFn: () => warehouseService.getCustomerTrucks(bookingId as string),
enabled: opened && Boolean(bookingId),
});
const visible = useMemo(
() => (tab === 'ALL' ? items : items.filter((i) => i.stage === tab)),
[items, tab],
);
const truckOptions = trucks
.filter((t) => !(t as { departedAt?: string }).departedAt)
.map((t) => ({ value: t.id, label: `${t.plateNumber} · ${t.driverName}` }));
const loadMutation = useMutation({
mutationFn: () => warehouseService.loadTruck(bookingId as string, truckId as string, selected),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: itemsKey });
setSelected([]);
toast({ title: 'Containers loaded onto truck' });
},
onError: (e) => toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(e) }),
});
const openExitPaper = async (assignmentId: string, plate: string) => {
try {
const res = await warehouseService.downloadTruckExitPaper(assignmentId);
openPdfBlob(res.data, `exit-${plate}.pdf`);
} catch (e) {
toast({ variant: 'destructive', title: 'Exit paper not ready', description: extractErrorMessage(e) });
}
};
const toggle = (n: string) => setSelected((s) => (s.includes(n) ? s.filter((x) => x !== n) : [...s, n]));
return (
<Modal
opened={opened}
onClose={onClose}
centered
size="90%"
title={<Text fw={700}>Container / bulk items {bookingReference ? `· ${bookingReference}` : ''}</Text>}
>
<Tabs value={tab} onChange={(v) => setTab(v ?? 'ALL')} mb="sm">
<Tabs.List>
{STAGE_TABS.map((t) => {
const count = t.value === 'ALL' ? items.length : items.filter((i) => i.stage === t.value).length;
return (
<Tabs.Tab key={t.value} value={t.value} rightSection={<Badge size="xs" variant="light">{count}</Badge>}>
{t.label}
</Tabs.Tab>
);
})}
</Tabs.List>
</Tabs>
{isLoading ? (
<Group justify="center" py="lg">
<Loader />
</Group>
) : items.length === 0 ? (
<Alert color="gray" variant="light">No container or bulk items on this booking.</Alert>
) : (
<Stack gap="sm">
<Table.ScrollContainer minWidth={900}>
<Table striped highlightOnHover verticalSpacing="xs">
<Table.Thead>
<Table.Tr>
<Table.Th />
<Table.Th>Container</Table.Th>
<Table.Th>Goods</Table.Th>
<Table.Th>Stage</Table.Th>
<Table.Th>Truck</Table.Th>
<Table.Th>Booking</Table.Th>
<Table.Th>Contract</Table.Th>
<Table.Th>Last mile</Table.Th>
<Table.Th ta="right">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{visible.map((i) => (
<Table.Tr key={i.containerNumber}>
<Table.Td>
<Checkbox
checked={selected.includes(i.containerNumber)}
onChange={() => toggle(i.containerNumber)}
disabled={!isLoadable(i)}
/>
</Table.Td>
<Table.Td><Text fw={600}>{i.containerNumber}</Text></Table.Td>
<Table.Td>{i.goods ?? '—'}</Table.Td>
<Table.Td><Badge color={STAGE_COLOR[i.stage]} variant="light">{i.stage}</Badge></Table.Td>
<Table.Td>{i.truckPlate ?? '—'}</Table.Td>
<Table.Td>{i.bookingReference ?? '—'}</Table.Td>
<Table.Td>{i.contractId ? <Badge variant="outline" color="indigo">Contract</Badge> : '—'}</Table.Td>
<Table.Td>{i.hasLastMile ? <Badge variant="light" color="cyan">EDR</Badge> : <Badge variant="light" color="gray">Self-haul</Badge>}</Table.Td>
<Table.Td ta="right">
{i.truckAssignmentId && (
<Button
size="compact-xs"
variant="light"
color="orange"
leftSection={<FileText size={13} />}
onClick={() => openExitPaper(i.truckAssignmentId as string, i.truckPlate ?? '')}
>
Exit Paper
</Button>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
{/* Multiselect → load onto a truck */}
<Group justify="space-between" align="flex-end">
<Text size="sm" c="dimmed">{selected.length} selected</Text>
<Group gap="sm" align="flex-end">
<Select
label="Load onto truck"
placeholder={truckOptions.length ? 'Select truck' : 'No arrived truck'}
data={truckOptions}
value={truckId}
onChange={setTruckId}
disabled={truckOptions.length === 0}
w={260}
/>
<Button
color="edr-green"
disabled={selected.length === 0 || !truckId}
loading={loadMutation.isPending}
onClick={() => loadMutation.mutate()}
>
Load selected
</Button>
</Group>
</Group>
</Stack>
)}
</Modal>
);
}

View File

@@ -28,6 +28,8 @@ interface FeePreviewModalProps {
const LABELS: Record<string, { label: string; color: string }> = {
DEMURRAGE_FEE: { label: 'Demurrage', color: 'orange' },
STORAGE_FEE: { label: 'Storage', color: 'teal' },
DOUBLE_HANDLING_FEE: { label: 'Double Handling', color: 'grape' },
TRUCK_DETENTION_FEE: { label: 'Truck Detention Cost', color: 'blue' },
};
function fmtDate(iso: string | null) {

View File

@@ -0,0 +1,252 @@
import {
Alert,
Badge,
Button,
Checkbox,
Group,
Loader,
Select,
Stack,
Table,
Tabs,
Text,
} from '@mantine/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { TrainFront } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useToast } from '@/hooks/use-toast';
import { warehouseService, type TrainLoadableItem } from '@/services/warehouse.service';
import { extractErrorMessage } from './options';
const STAGE_COLOR: Record<string, string> = {
RECEIVED: 'blue',
STORED: 'gray',
RESERVED: 'grape',
READY_FOR_LOADING: 'teal',
LOADED: 'green',
};
const weight = (w: number | null) => (w == null ? '—' : `${Number(w).toLocaleString()} kg`);
/**
* Load to Train — pick an allocated EXPORT train, see the arrived containers/cargoes
* assigned to it (stage tabs), multiselect the ready ones and load them onto their
* already-allocated wagons. Loading follows train + wagon allocation: only items
* that are READY_FOR_LOADING and have an allocated wagon are selectable.
*/
export function LoadToTrainPanel() {
const { toast } = useToast();
const queryClient = useQueryClient();
const [scheduleId, setScheduleId] = useState<string | null>(null);
const [tab, setTab] = useState('received');
const [selected, setSelected] = useState<string[]>([]);
const trainsKey = ['loadable-trains'];
const { data: trains = [], isLoading: trainsLoading } = useQuery({
queryKey: trainsKey,
queryFn: () => warehouseService.getLoadableTrains(),
});
const itemsKey = ['train-loadable-items', scheduleId];
const { data: items = [], isLoading } = useQuery({
queryKey: itemsKey,
queryFn: () => warehouseService.getTrainLoadableItems(scheduleId as string),
enabled: Boolean(scheduleId),
});
const received = useMemo(() => items.filter((i) => i.status !== 'LOADED'), [items]);
const loaded = useMemo(() => items.filter((i) => i.status === 'LOADED'), [items]);
const visible = tab === 'loaded' ? loaded : received;
const trainOptions = trains.map((t) => ({
value: t.scheduleId,
label:
`${t.trainNumber ?? t.scheduleId.slice(0, 8)}` +
(t.origin || t.destination ? ` · ${t.origin ?? '?'}${t.destination ?? '?'}` : '') +
` · ${t.readyCount} ready / ${t.loadedCount} loaded`,
}));
const selectableVisible = visible.filter((i) => i.loadable);
const allSelected =
selectableVisible.length > 0 && selectableVisible.every((i) => selected.includes(i.id));
const toggle = (id: string) =>
setSelected((s) => (s.includes(id) ? s.filter((x) => x !== id) : [...s, id]));
const toggleAll = () =>
setSelected((s) =>
allSelected
? s.filter((id) => !selectableVisible.some((i) => i.id === id))
: Array.from(new Set([...s, ...selectableVisible.map((i) => i.id)])),
);
const loadMutation = useMutation({
mutationFn: () => warehouseService.loadItemsOntoTrain(scheduleId as string, selected),
onSuccess: (r) => {
queryClient.invalidateQueries({ queryKey: itemsKey });
queryClient.invalidateQueries({ queryKey: trainsKey });
setSelected([]);
toast({
title: 'Loaded onto train',
description: `Loaded ${r.loadedCount} item(s); skipped ${r.skippedCount}.`,
});
},
onError: (e) =>
toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(e) }),
});
const renderRow = (i: TrainLoadableItem) => (
<Table.Tr key={i.id}>
<Table.Td>
<Checkbox
checked={selected.includes(i.id)}
onChange={() => toggle(i.id)}
disabled={!i.loadable}
/>
</Table.Td>
<Table.Td>
<Text fw={600}>{i.containerNumber ?? i.cargoType ?? '—'}</Text>
</Table.Td>
<Table.Td>{i.cargoType ?? '—'}</Table.Td>
<Table.Td>{weight(i.weight)}</Table.Td>
<Table.Td>
<Badge color={STAGE_COLOR[i.status] ?? 'gray'} variant="light">
{i.status.replace(/_/g, ' ')}
</Badge>
</Table.Td>
<Table.Td>
{i.wagonNumber ? (
<Badge variant="outline" color="indigo">
{i.wagonNumber}
</Badge>
) : (
<Text size="xs" c="red">
Not allocated
</Text>
)}
</Table.Td>
<Table.Td>{i.bookingReference ?? '—'}</Table.Td>
<Table.Td>{i.customerName ?? '—'}</Table.Td>
<Table.Td>
{i.inspectionStatus ? (
<Badge size="xs" variant="light" color={i.inspectionStatus === 'PASSED' ? 'green' : 'orange'}>
{i.inspectionStatus}
</Badge>
) : (
'—'
)}
</Table.Td>
</Table.Tr>
);
return (
<Stack gap="md">
<Group align="flex-end" justify="space-between">
<Select
label="Train"
description="Allocated EXPORT trains awaiting loading"
placeholder={trainsLoading ? 'Loading trains…' : trainOptions.length ? 'Select a train' : 'No trains to load'}
data={trainOptions}
value={scheduleId}
onChange={(v) => {
setScheduleId(v);
setSelected([]);
setTab('received');
}}
disabled={trainOptions.length === 0}
leftSection={<TrainFront size={16} />}
w={460}
searchable
/>
</Group>
{!scheduleId ? (
<Alert color="gray" variant="light">
Pick a train to see the arrived containers/cargoes allocated to it. Items appear here only
after train and wagon allocation.
</Alert>
) : (
<>
<Tabs value={tab} onChange={(v) => setTab(v ?? 'received')}>
<Tabs.List>
<Tabs.Tab
value="received"
rightSection={
<Badge size="xs" variant="light" color="blue">
{received.length}
</Badge>
}
>
Received
</Tabs.Tab>
<Tabs.Tab
value="loaded"
rightSection={
<Badge size="xs" variant="light" color="green">
{loaded.length}
</Badge>
}
>
Loaded
</Tabs.Tab>
</Tabs.List>
</Tabs>
{isLoading ? (
<Group justify="center" py="lg">
<Loader />
</Group>
) : visible.length === 0 ? (
<Alert color="gray" variant="light">
{tab === 'loaded' ? 'Nothing loaded onto this train yet.' : 'No arrived items ready for this train.'}
</Alert>
) : (
<Table.ScrollContainer minWidth={900}>
<Table striped highlightOnHover verticalSpacing="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>
{tab === 'received' && (
<Checkbox
checked={allSelected}
indeterminate={!allSelected && selected.length > 0}
onChange={toggleAll}
disabled={selectableVisible.length === 0}
/>
)}
</Table.Th>
<Table.Th>Container / Cargo</Table.Th>
<Table.Th>Goods</Table.Th>
<Table.Th>Weight</Table.Th>
<Table.Th>Stage</Table.Th>
<Table.Th>Wagon</Table.Th>
<Table.Th>Booking</Table.Th>
<Table.Th>Customer</Table.Th>
<Table.Th>Inspection</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{visible.map(renderRow)}</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
{tab === 'received' && (
<Group justify="space-between" align="center">
<Text size="sm" c="dimmed">
{selected.length} selected · only READY_FOR_LOADING items with an allocated wagon can be loaded
</Text>
<Button
color="edr-green"
leftSection={<TrainFront size={16} />}
disabled={selected.length === 0}
loading={loadMutation.isPending}
onClick={() => loadMutation.mutate()}
>
Load {selected.length || ''} onto train
</Button>
</Group>
)}
</>
)}
</Stack>
);
}

View File

@@ -61,6 +61,8 @@ import type {
} from '@/types/warehouse';
import { BookingSelect } from './BookingSelect';
import { DeliverInventoryModal } from './DeliverInventoryModal';
import { TruckDispatchModal } from './TruckDispatchModal';
import { ContainerItemsModal } from './ContainerItemsModal';
import { FeePreviewModal } from './FeePreviewModal';
import { InspectionReportModal } from './InspectionReportModal';
import { InventoryDetailModal } from './InventoryDetailModal';
@@ -644,9 +646,15 @@ function TruckEntranceFields({
function LocationSelects({
value,
onChange,
allowedYardTypes,
allowedZoneTypes,
}: {
value: Location;
onChange: (next: Location) => void;
/** When non-empty, only yards of these types are offered (matched to freight). */
allowedYardTypes?: string[];
/** When non-empty, only zones of these types are offered. */
allowedZoneTypes?: string[];
}) {
const warehousesQuery = useQuery(
api.warehouses.list.queryOptions({ input: { filter: { status: 'ACTIVE' } } }),
@@ -672,15 +680,17 @@ function LocationSelects({
() =>
(yardsQuery.data ?? [])
.filter((y) => y.status === 'ACTIVE')
.filter((y) => !allowedYardTypes?.length || allowedYardTypes.includes((y as { type?: string }).type ?? ''))
.map((y) => ({ value: y.id, label: `${y.name} (${y.code})` })),
[yardsQuery.data],
[yardsQuery.data, allowedYardTypes],
);
const zoneOptions = useMemo(
() =>
(zonesQuery.data ?? [])
.filter((z) => z.status === 'ACTIVE')
.filter((z) => !allowedZoneTypes?.length || allowedZoneTypes.includes((z as { type?: string }).type ?? ''))
.map((z) => ({ value: z.id, label: `${z.name} (${z.code})` })),
[zonesQuery.data],
[zonesQuery.data, allowedZoneTypes],
);
return (
@@ -1758,6 +1768,29 @@ const importLocationTypesForFreight = (freightType: string | null | undefined) =
const isImportContainerFreight = (freightType: string | null | undefined) =>
(freightType ?? '').toUpperCase() === 'CONTAINER';
/**
* Yard/zone types valid for the freight being received — used to filter the receive
* location pickers so the yard list matches the cargo. Container freight → container
* yards only; bulk / break-bulk → bulk, general-cargo, hazardous, or cold-storage.
* Union across the given freight types; empty input → no restriction (show all).
*/
const yardZoneTypesForFreights = (freightTypes: Array<string | null | undefined>) => {
const yardTypes = new Set<string>();
const zoneTypes = new Set<string>();
for (const freightType of freightTypes) {
const normalized = (freightType ?? '').toUpperCase();
if (!normalized) continue;
if (normalized === 'CONTAINER') {
yardTypes.add('CONTAINER_YARD');
zoneTypes.add('CONTAINER_ZONE');
} else {
['BULK_YARD', 'GENERAL_CARGO_YARD', 'HAZARDOUS_YARD', 'COLD_STORAGE_YARD'].forEach((t) => yardTypes.add(t));
['BULK_ZONE', 'GENERAL_CARGO_ZONE', 'HAZARDOUS_ZONE', 'COLD_STORAGE_ZONE'].forEach((t) => zoneTypes.add(t));
}
}
return { yardTypes: [...yardTypes], zoneTypes: [...zoneTypes] };
};
const isImportUnloadPending = (item: ImportTrainItem) =>
!item.currentStatus || item.currentStatus === 'RECEIVED';
@@ -2151,7 +2184,6 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
);
const storeMutation = useMutation(api.warehouses.store.mutationOptions());
const readyMutation = useMutation(api.warehouses.markReadyForPickup.mutationOptions());
const dispatchMutation = useMutation(api.warehouses.dispatch.mutationOptions());
const [selected, setSelected] = useState<Set<string>>(new Set());
const [inspectId, setInspectId] = useState<string | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
@@ -2160,6 +2192,8 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
const [feeItem, setFeeItem] = useState<WarehouseInventoryItem | null>(null);
const [releaseItem, setReleaseItem] = useState<WarehouseInventoryItem | null>(null);
const [deliverItem, setDeliverItem] = useState<WarehouseInventoryItem | null>(null);
const [loadTruckItem, setLoadTruckItem] = useState<WarehouseInventoryItem | null>(null);
const [containerItemsItem, setContainerItemsItem] = useState<WarehouseInventoryItem | null>(null);
const allSelected = rows.length > 0 && selected.size === rows.length;
const someSelected = selected.size > 0 && !allSelected;
@@ -2375,7 +2409,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
<Table.Td ta="right">
<Group gap="xs" justify="flex-end" wrap="nowrap">
<Tooltip label="View details" withArrow>
<ActionIcon variant="subtle" color="gray" onClick={() => setViewItem(toInventoryItem(r))}>
<ActionIcon variant="subtle" color="gray" onClick={() => setContainerItemsItem(toInventoryItem(r))}>
<Eye size={16} />
</ActionIcon>
</Tooltip>
@@ -2419,9 +2453,9 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
variant="light"
color="green"
loading={busyId === r.id}
onClick={() => runRowAction(r, 'Inventory dispatched', () => dispatchMutation.mutateAsync(r.id))}
onClick={() => setLoadTruckItem(toInventoryItem(r))}
>
Dispatch
Truck_dispatch
</Button>
)}
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
@@ -2493,6 +2527,18 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
/>
<ReleaseOrderModal opened={Boolean(releaseItem)} onClose={() => setReleaseItem(null)} item={releaseItem} />
<DeliverInventoryModal opened={Boolean(deliverItem)} onClose={() => setDeliverItem(null)} item={deliverItem} />
<TruckDispatchModal
opened={Boolean(loadTruckItem)}
onClose={() => setLoadTruckItem(null)}
bookingId={loadTruckItem?.booking?.id ?? null}
bookingReference={loadTruckItem?.booking?.reference ?? null}
/>
<ContainerItemsModal
opened={Boolean(containerItemsItem)}
onClose={() => setContainerItemsItem(null)}
bookingId={containerItemsItem?.booking?.id ?? null}
bookingReference={containerItemsItem?.booking?.reference ?? null}
/>
</Stack>
);
}
@@ -2873,6 +2919,24 @@ export function WarehouseFlowWorkbench({
);
const activeDirection = direction === 'BOTH' ? tab : direction;
// Match the yard/zone list to the freight being received (container → container
// yards, etc). Same query key as the export tab, so React Query dedupes it.
const { data: eligibleForLocation = [] } = useQuery(
api.warehouses.eligibleBookings.queryOptions({
input: { direction: activeDirection },
enabled: enabled && activeDirection === 'EXPORT',
}),
);
const { yardTypes: allowedYardTypes, zoneTypes: allowedZoneTypes } = useMemo(
() =>
yardZoneTypesForFreights(
eligibleForLocation
.filter((r) => r.direction === activeDirection)
.map((r) => r.freightType),
),
[eligibleForLocation, activeDirection],
);
useEffect(() => {
if (enabled) setLocation({ warehouseId: '', yardId: '', zoneId: '' });
}, [enabled, direction]);
@@ -2880,7 +2944,12 @@ export function WarehouseFlowWorkbench({
return (
<Stack gap="md">
{activeDirection === 'EXPORT' && (
<LocationSelects value={location} onChange={setLocation} />
<LocationSelects
value={location}
onChange={setLocation}
allowedYardTypes={allowedYardTypes}
allowedZoneTypes={allowedZoneTypes}
/>
)}
{direction === 'BOTH' ? (

View File

@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
import { Alert, Button, Group, Modal, NumberInput, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
import { Info, Scale } from 'lucide-react';
import { useMutation } from '@tanstack/react-query';
import { useMutation, useQuery } from '@tanstack/react-query';
import { api } from '@/services/api';
import { useToast } from '@/hooks/use-toast';
@@ -134,6 +134,13 @@ const parseInspectionNote = (notes: string | null | undefined) => {
export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: ReleaseOrderModalProps) {
const { toast } = useToast();
const releaseMutation = useMutation(api.warehouses.release.mutationOptions());
const bookingId = item?.booking?.id;
// Customer self-haul trucks assigned to this booking via the portal.
const { data: customerTrucks = [] } = useQuery({
queryKey: ['release-customer-trucks', bookingId],
queryFn: () => warehouseService.getCustomerTrucks(bookingId as string),
enabled: opened && Boolean(bookingId),
});
const [reference, setReference] = useState('');
const [truckPlateNumber, setTruckPlateNumber] = useState('');
const [trailerPlateNumber, setTrailerPlateNumber] = useState('');
@@ -178,6 +185,44 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
const isEntranceLocked = isExitStep;
const isCustomerAssignedTruck = Boolean(item?.booking?.customerTruckAssignedAt);
const hasLastMileTruckPrefill = Boolean(truckPrefill?.truckPlateNumber || truckPrefill?.trailerPlateNumber);
// Registered trucks for THIS booking, from both sources: EDR last-mile
// (truckPrefill) and the customer portal (customer_truck_assignments).
const assignedTruckOptions = [
...(truckPrefill?.truckPlateNumber
? [
{
value: truckPrefill.truckPlateNumber,
label: `Last-mile · ${truckPrefill.truckPlateNumber}`,
trailerPlate: truckPrefill.trailerPlateNumber ?? '',
driverName: truckPrefill.driverName ?? '',
driverPhone: truckPrefill.driverPhone ?? '',
truckType: truckPrefill.truckType ?? '',
},
]
: []),
...customerTrucks.map((t) => ({
value: t.plateNumber,
label: `Customer · ${t.plateNumber}${t.driverName}`,
trailerPlate: '',
driverName: t.driverName,
driverPhone: '',
truckType: t.truckType,
})),
];
const truckSelectOptions = [
...assignedTruckOptions,
...REGISTERED_FIRST_LAST_MILE_TRUCKS.map((t) => ({
value: t.value,
label: t.label,
trailerPlate: t.trailerPlate,
driverName: '',
driverPhone: '',
truckType: '',
})),
];
// Neither a last-mile truck nor a customer truck has been assigned yet.
const noTruckAssigned = assignedTruckOptions.length === 0 && !isCustomerAssignedTruck;
const isTruckIdentityLocked = isEntranceLocked || isCustomerAssignedTruck || hasLastMileTruckPrefill;
const isDriverNameLocked = isEntranceLocked || isCustomerAssignedTruck || Boolean(truckPrefill?.driverName);
const systemNetWeight = item?.weight == null ? netWeight : Number(item.weight);
@@ -286,18 +331,26 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
onChange={(e) => setReference(e.currentTarget.value)}
readOnly={isEntranceLocked}
/>
{noTruckAssigned && (
<Alert color="orange" variant="light" icon={<Info size={16} />}>
Truck is not assigned yet assign a last-mile or customer truck, or enter the plate manually below.
</Alert>
)}
<Select
label="Registered first / last-mile truck"
placeholder="Select truck or type plate manually below"
searchable
clearable
data={REGISTERED_FIRST_LAST_MILE_TRUCKS}
data={truckSelectOptions}
disabled={isTruckIdentityLocked}
value={REGISTERED_FIRST_LAST_MILE_TRUCKS.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null}
value={truckSelectOptions.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null}
onChange={(value) => {
const truck = REGISTERED_FIRST_LAST_MILE_TRUCKS.find((row) => row.value === value);
const truck = truckSelectOptions.find((row) => row.value === value);
setTruckPlateNumber(truck?.value ?? '');
setTrailerPlateNumber(truck?.trailerPlate ?? '');
if (truck?.driverName) setDriverName(truck.driverName);
if (truck?.driverPhone) setDriverPhone(truck.driverPhone);
if (truck?.truckType) setTruckType(truck.truckType);
}}
/>
<Group grow>

View File

@@ -0,0 +1,147 @@
import { Alert, Badge, Button, Group, Loader, Modal, MultiSelect, Stack, Text } from '@mantine/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { FileText, Truck } from 'lucide-react';
import { useState } from 'react';
import { useToast } from '@/hooks/use-toast';
import { warehouseService } from '@/services/warehouse.service';
import { extractErrorMessage } from './options';
import { openPdfBlob } from './pdf';
interface TruckDispatchModalProps {
opened: boolean;
onClose: () => void;
bookingId: string | null;
bookingReference?: string | null;
}
/**
* Truck_dispatch: after a self-haul truck arrives, staff select which of the
* booking's containers ride each truck. The loaded set drives the truck's gross
* weight; the truck is weighed for real on departure.
*/
export function TruckDispatchModal({ opened, onClose, bookingId, bookingReference }: TruckDispatchModalProps) {
const { toast } = useToast();
const queryClient = useQueryClient();
const [selectedByTruck, setSelectedByTruck] = useState<Record<string, string[]>>({});
const trucksKey = ['td-customer-trucks', bookingId];
const loadableKey = ['td-loadable', bookingId];
const { data: trucks = [], isLoading: trucksLoading } = useQuery({
queryKey: trucksKey,
queryFn: () => warehouseService.getCustomerTrucks(bookingId as string),
enabled: opened && Boolean(bookingId),
});
const { data: loadable = [], isLoading: loadableLoading } = useQuery({
queryKey: loadableKey,
queryFn: () => warehouseService.getLoadableContainers(bookingId as string),
enabled: opened && Boolean(bookingId),
});
const loadMutation = useMutation({
mutationFn: ({ assignmentId, containerNumbers }: { assignmentId: string; containerNumbers: string[] }) =>
warehouseService.loadTruck(bookingId as string, assignmentId, containerNumbers),
onSuccess: (_res, vars) => {
queryClient.invalidateQueries({ queryKey: trucksKey });
queryClient.invalidateQueries({ queryKey: loadableKey });
setSelectedByTruck((s) => ({ ...s, [vars.assignmentId]: [] }));
toast({ title: 'Truck loaded' });
},
onError: (e) => toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(e) }),
});
const openTruckExitPaper = async (assignmentId: string, plate: string) => {
try {
const res = await warehouseService.downloadTruckExitPaper(assignmentId);
openPdfBlob(res.data, `exit-${plate}.pdf`);
} catch (e) {
toast({ variant: 'destructive', title: 'Exit paper not ready', description: extractErrorMessage(e) });
}
};
return (
<Modal
opened={opened}
onClose={onClose}
centered
size="lg"
title={
<Group gap={8}>
<Truck size={18} />
<Text fw={700}>Truck_dispatch load containers {bookingReference ? `· ${bookingReference}` : ''}</Text>
</Group>
}
>
{trucksLoading || loadableLoading ? (
<Group justify="center" py="lg">
<Loader />
</Group>
) : trucks.length === 0 ? (
<Alert color="orange" variant="light">
No customer truck is assigned to this booking yet.
</Alert>
) : (
<Stack gap="md">
{trucks.map((t) => {
const alreadyLoaded = (t.containers ?? []).map((c) => c.containerNumber);
// Options = still-loadable + this truck's own already-loaded (so they stay visible).
const options = Array.from(new Set([...loadable, ...alreadyLoaded]));
const selected = selectedByTruck[t.id] ?? alreadyLoaded;
const departed = Boolean(t.arrivedAt) && Boolean((t as { departedAt?: string }).departedAt);
return (
<Stack
key={t.id}
gap={8}
style={{ border: '1px solid #EEF2F6', borderRadius: 12, padding: 14 }}
>
<Group justify="space-between">
<Text fw={700}>{t.plateNumber}</Text>
<Group gap={6}>
<Text size="sm" c="dimmed">{t.driverName} · {t.truckType}</Text>
{t.arrivedAt ? <Badge color="green" variant="light">Arrived</Badge> : <Badge color="orange" variant="light">Not arrived</Badge>}
</Group>
</Group>
<MultiSelect
label="Containers on this truck"
placeholder="Select containers"
data={options}
value={selected}
onChange={(v) => setSelectedByTruck((s) => ({ ...s, [t.id]: v }))}
searchable
disabled={departed || !t.arrivedAt}
nothingFoundMessage="No loadable containers"
/>
<Group justify="flex-end" gap="xs">
<Button
size="xs"
variant="light"
color="orange"
leftSection={<FileText size={14} />}
onClick={() => openTruckExitPaper(t.id, t.plateNumber)}
>
Exit Paper
</Button>
<Button
size="xs"
color="edr-green"
disabled={departed || !t.arrivedAt || (selectedByTruck[t.id] ?? alreadyLoaded).length === 0}
loading={loadMutation.isPending}
onClick={() =>
loadMutation.mutate({
assignmentId: t.id,
containerNumbers: selectedByTruck[t.id] ?? alreadyLoaded,
})
}
>
Load truck
</Button>
</Group>
</Stack>
);
})}
</Stack>
)}
</Modal>
);
}