mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 08:48:11 +00:00
Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight/feature/first_mile_invoice
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -32,6 +32,7 @@ const DEFAULTS = {
|
||||
docReviewMinutes: 30,
|
||||
paymentWindowMinutes: 60,
|
||||
importWindowLeadDays: 3,
|
||||
exportBookingLeadHours: 24,
|
||||
};
|
||||
|
||||
/** 12-hour label for an EAT hour 0–23, 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}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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' ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user