mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 12:18:11 +00:00
Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight/feature/first_mile_invoice
This commit is contained in:
@@ -618,6 +618,9 @@ const DashboardShell = () => {
|
||||
return (
|
||||
<FreightDashboardLayout
|
||||
sidebarSections={sidebarSections}
|
||||
// GL Ethiopia / GL Djibouti are locked to a single clearance page — no
|
||||
// sidebar (or mobile burger) at all; the page renders full width.
|
||||
hideSidebar={Boolean(glClearanceHome)}
|
||||
activeHref={location.pathname}
|
||||
onNavigate={navigate}
|
||||
enableThemeToggle
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -324,6 +324,14 @@ export const URL_CONSTANTS = {
|
||||
PIN_WAGONS: (id: string) => `/train-scheduling/schedules/${id}/pin-wagons`,
|
||||
FINALIZE: (id: string) => `/train-scheduling/schedules/${id}/finalize`,
|
||||
DISPATCH: (id: string) => `/train-scheduling/schedules/${id}/dispatch`,
|
||||
INTERCITY_CANDIDATES: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/intercity-candidates`,
|
||||
INTERCITY_ACCEPT: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/intercity/accept`,
|
||||
INTERCITY_LOAD: (id: string, bookingId: string) =>
|
||||
`/train-scheduling/schedules/${id}/intercity/${bookingId}/load`,
|
||||
INTERCITY_UNLOAD: (id: string, bookingId: string) =>
|
||||
`/train-scheduling/schedules/${id}/intercity/${bookingId}/unload`,
|
||||
IMPORT_LOADING_BOOKINGS: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/import-loading-bookings`,
|
||||
IMPORT_LOADING_STATUS: (id: string) =>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
BOOKING_WINDOW_WS_EVENTS,
|
||||
BOOKING_WINDOW_WS_NAMESPACE,
|
||||
type BookingWindowPhaseEvent,
|
||||
} from "@edr/types";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
import { io } from "socket.io-client";
|
||||
|
||||
import { API_BASE_URL } from "@/constants/apiConfig";
|
||||
import { AUTH_TOKEN_COOKIE, getCookie } from "@/auth/cookies";
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
|
||||
// The socket namespace lives at the server root, not under the `/api` REST
|
||||
// prefix — strip a trailing `/api` if the base URL carries one.
|
||||
const SOCKET_ORIGIN = String(API_BASE_URL ?? "").replace(/\/api\/?$/, "");
|
||||
|
||||
/**
|
||||
* Subscribes to live booking-window pushes for staff. Every phase transition
|
||||
* the window engine applies invalidates the GL windows carousel and the batch
|
||||
* board, so both flip the moment the backend does — polling stays only as a
|
||||
* fallback.
|
||||
*/
|
||||
export function useBookingWindowSocket(enabled: boolean = true) {
|
||||
const qc = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const token = getCookie(AUTH_TOKEN_COOKIE);
|
||||
if (!token) return;
|
||||
|
||||
const socket = io(`${SOCKET_ORIGIN}/${BOOKING_WINDOW_WS_NAMESPACE}`, {
|
||||
auth: { token },
|
||||
transports: ["websocket"],
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
// Deliberate console breadcrumbs: "live updates not arriving" is only
|
||||
// diagnosable from the browser when connect/reject outcomes are visible.
|
||||
socket.on("connect", () =>
|
||||
console.debug("[booking-windows] socket connected", socket.id),
|
||||
);
|
||||
socket.on("connect_error", (err) =>
|
||||
console.warn("[booking-windows] socket connect failed:", err.message),
|
||||
);
|
||||
socket.on("disconnect", (reason) =>
|
||||
console.debug("[booking-windows] socket disconnected:", reason),
|
||||
);
|
||||
|
||||
socket.on(
|
||||
BOOKING_WINDOW_WS_EVENTS.PHASE,
|
||||
(_event: BookingWindowPhaseEvent) => {
|
||||
qc.invalidateQueries({
|
||||
queryKey: ["train-scheduling", "all-booking-windows"],
|
||||
});
|
||||
qc.invalidateQueries({
|
||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoard(),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
socket.off();
|
||||
socket.disconnect();
|
||||
};
|
||||
}, [enabled, qc]);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NotificationType } from "@edr/types";
|
||||
import type { NotificationItemData, NotificationVisual } from "@edr/ui-common";
|
||||
import { Bell, ClipboardCheck, Inbox, Wallet } from "lucide-react";
|
||||
import { Bell, ClipboardCheck, FileSignature, Inbox, Wallet } from "lucide-react";
|
||||
|
||||
const ICON_SIZE = 17;
|
||||
|
||||
@@ -19,6 +19,8 @@ export function resolveNotificationVisual(
|
||||
return { icon: <Wallet size={ICON_SIZE} />, color: "teal" };
|
||||
case NotificationType.CLEARANCE_REVIEW:
|
||||
return { icon: <ClipboardCheck size={ICON_SIZE} />, color: "orange" };
|
||||
case NotificationType.CONTRACT_STATUS:
|
||||
return { icon: <FileSignature size={ICON_SIZE} />, color: "indigo" };
|
||||
default:
|
||||
return { icon: <Bell size={ICON_SIZE} />, color: "edr-green" };
|
||||
}
|
||||
@@ -39,14 +41,32 @@ export function resolveNotificationHref(
|
||||
if (item.link) return item.link;
|
||||
const data = item.data ?? {};
|
||||
switch (item.type) {
|
||||
case NotificationType.REQUEST_SUBMITTED:
|
||||
case NotificationType.REQUEST_SUBMITTED: {
|
||||
const bookingId = asId(data.bookingId);
|
||||
if (bookingId) return `/dashboard/booking-requests/${bookingId}`;
|
||||
const contractId = asId(data.contractId);
|
||||
if (contractId) return `/dashboard/contract-requests/${contractId}`;
|
||||
return "/dashboard/booking-requests";
|
||||
}
|
||||
case NotificationType.PAYMENT_RECEIVED: {
|
||||
const bookingId = asId(data.bookingId);
|
||||
if (bookingId) return `/dashboard/bookings/${bookingId}/clearance`;
|
||||
const id = asId(data.customerId);
|
||||
return id ? `/dashboard/customers/${id}` : "/dashboard/customers";
|
||||
}
|
||||
case NotificationType.CLEARANCE_REVIEW:
|
||||
case NotificationType.CLEARANCE_REVIEW: {
|
||||
const bookingId = asId(data.bookingId);
|
||||
if (bookingId) return `/dashboard/bookings/${bookingId}/clearance`;
|
||||
const contractId = asId(data.contractId);
|
||||
if (contractId) return `/dashboard/contracts/clearance/${contractId}`;
|
||||
return "/dashboard/arrival-queue";
|
||||
}
|
||||
case NotificationType.CONTRACT_STATUS: {
|
||||
const contractId = asId(data.contractId);
|
||||
return contractId
|
||||
? `/dashboard/contract-requests/${contractId}`
|
||||
: "/dashboard/contract-requests";
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { RuleEngineResourceSlug } from "@/types/rule-engine";
|
||||
export const FREIGHT_PERMS = {
|
||||
bookings: {
|
||||
view: "edr_freight_app:bookings:view",
|
||||
create: "edr_freight_app:bookings:create",
|
||||
clearanceView: "edr_freight_app:bookings:clearance_view",
|
||||
staffAccept: "edr_freight_app:bookings:staff_accept",
|
||||
requestChanges: "edr_freight_app:bookings:request_changes",
|
||||
@@ -41,6 +42,11 @@ export const FREIGHT_PERMS = {
|
||||
trainScheduling: {
|
||||
view: "edr_freight_app:train_scheduling:view",
|
||||
manage: "edr_freight_app:train_scheduling:manage",
|
||||
create: "edr_freight_app:train_scheduling:create",
|
||||
update: "edr_freight_app:train_scheduling:update",
|
||||
cancel: "edr_freight_app:train_scheduling:cancel",
|
||||
reschedule: "edr_freight_app:train_scheduling:reschedule",
|
||||
rulesManage: "edr_freight_app:train_scheduling:rules_manage",
|
||||
},
|
||||
fleet: {
|
||||
view: "edr_freight_app:fleet:view",
|
||||
@@ -50,6 +56,243 @@ export const FREIGHT_PERMS = {
|
||||
allocation: {
|
||||
manage: "edr_freight_app:allocation:manage",
|
||||
},
|
||||
customers: {
|
||||
view: "edr_freight_app:customers:view",
|
||||
create: "edr_freight_app:customers:create",
|
||||
update: "edr_freight_app:customers:update",
|
||||
deactivate: "edr_freight_app:customers:deactivate",
|
||||
verify: "edr_freight_app:customers:verify",
|
||||
},
|
||||
payments: {
|
||||
view: "edr_freight_app:payments:view",
|
||||
verify: "edr_freight_app:payments:verify",
|
||||
refund: "edr_freight_app:payments:refund",
|
||||
},
|
||||
invoices: {
|
||||
view: "edr_freight_app:invoices:view",
|
||||
create: "edr_freight_app:invoices:create",
|
||||
cancel: "edr_freight_app:invoices:cancel",
|
||||
export: "edr_freight_app:invoices:export",
|
||||
},
|
||||
firstMile: {
|
||||
view: "edr_freight_app:first_mile:view",
|
||||
accept: "edr_freight_app:first_mile:accept",
|
||||
create: "edr_freight_app:first_mile:create",
|
||||
update: "edr_freight_app:first_mile:update",
|
||||
delete: "edr_freight_app:first_mile:delete",
|
||||
assignVehicles: "edr_freight_app:first_mile:assign_vehicles",
|
||||
setDistances: "edr_freight_app:first_mile:set_distances",
|
||||
generateInvoice: "edr_freight_app:first_mile:generate_invoice",
|
||||
},
|
||||
lastMile: {
|
||||
view: "edr_freight_app:last_mile:view",
|
||||
accept: "edr_freight_app:last_mile:accept",
|
||||
create: "edr_freight_app:last_mile:create",
|
||||
update: "edr_freight_app:last_mile:update",
|
||||
delete: "edr_freight_app:last_mile:delete",
|
||||
assignVehicles: "edr_freight_app:last_mile:assign_vehicles",
|
||||
setDistances: "edr_freight_app:last_mile:set_distances",
|
||||
generateInvoice: "edr_freight_app:last_mile:generate_invoice",
|
||||
},
|
||||
locomotives: {
|
||||
view: "edr_freight_app:locomotives:view",
|
||||
create: "edr_freight_app:locomotives:create",
|
||||
update: "edr_freight_app:locomotives:update",
|
||||
delete: "edr_freight_app:locomotives:delete",
|
||||
},
|
||||
wagons: {
|
||||
view: "edr_freight_app:wagons:view",
|
||||
create: "edr_freight_app:wagons:create",
|
||||
update: "edr_freight_app:wagons:update",
|
||||
delete: "edr_freight_app:wagons:delete",
|
||||
},
|
||||
trains: {
|
||||
view: "edr_freight_app:trains:view",
|
||||
create: "edr_freight_app:trains:create",
|
||||
update: "edr_freight_app:trains:update",
|
||||
delete: "edr_freight_app:trains:delete",
|
||||
assignWagons: "edr_freight_app:trains:assign_wagons",
|
||||
},
|
||||
routes: {
|
||||
view: "edr_freight_app:routes:view",
|
||||
create: "edr_freight_app:routes:create",
|
||||
update: "edr_freight_app:routes:update",
|
||||
delete: "edr_freight_app:routes:delete",
|
||||
},
|
||||
containers: {
|
||||
view: "edr_freight_app:containers:view",
|
||||
create: "edr_freight_app:containers:create",
|
||||
update: "edr_freight_app:containers:update",
|
||||
delete: "edr_freight_app:containers:delete",
|
||||
},
|
||||
cargoes: {
|
||||
view: "edr_freight_app:cargoes:view",
|
||||
create: "edr_freight_app:cargoes:create",
|
||||
update: "edr_freight_app:cargoes:update",
|
||||
delete: "edr_freight_app:cargoes:delete",
|
||||
},
|
||||
vehicles: {
|
||||
view: "edr_freight_app:vehicles:view",
|
||||
create: "edr_freight_app:vehicles:create",
|
||||
update: "edr_freight_app:vehicles:update",
|
||||
delete: "edr_freight_app:vehicles:delete",
|
||||
},
|
||||
drivers: {
|
||||
view: "edr_freight_app:drivers:view",
|
||||
create: "edr_freight_app:drivers:create",
|
||||
update: "edr_freight_app:drivers:update",
|
||||
delete: "edr_freight_app:drivers:delete",
|
||||
},
|
||||
tracking: {
|
||||
view: "edr_freight_app:tracking:view",
|
||||
},
|
||||
fuel: {
|
||||
view: "edr_freight_app:fuel:view",
|
||||
create: "edr_freight_app:fuel:create",
|
||||
update: "edr_freight_app:fuel:update",
|
||||
delete: "edr_freight_app:fuel:delete",
|
||||
approve: "edr_freight_app:fuel:approve",
|
||||
},
|
||||
maintenance: {
|
||||
view: "edr_freight_app:maintenance:view",
|
||||
create: "edr_freight_app:maintenance:create",
|
||||
update: "edr_freight_app:maintenance:update",
|
||||
delete: "edr_freight_app:maintenance:delete",
|
||||
complete: "edr_freight_app:maintenance:complete",
|
||||
},
|
||||
fleetReports: {
|
||||
view: "edr_freight_app:fleet_reports:view",
|
||||
export: "edr_freight_app:fleet_reports:export",
|
||||
},
|
||||
fleetDashboard: {
|
||||
view: "edr_freight_app:fleet_dashboard:view",
|
||||
},
|
||||
warehouseDashboard: {
|
||||
view: "edr_freight_app:warehouse_dashboard:view",
|
||||
},
|
||||
warehouses: {
|
||||
view: "edr_freight_app:warehouses:view",
|
||||
create: "edr_freight_app:warehouses:create",
|
||||
update: "edr_freight_app:warehouses:update",
|
||||
delete: "edr_freight_app:warehouses:delete",
|
||||
},
|
||||
warehouseYards: {
|
||||
view: "edr_freight_app:warehouse_yards:view",
|
||||
create: "edr_freight_app:warehouse_yards:create",
|
||||
update: "edr_freight_app:warehouse_yards:update",
|
||||
delete: "edr_freight_app:warehouse_yards:delete",
|
||||
},
|
||||
warehouseZones: {
|
||||
view: "edr_freight_app:warehouse_zones:view",
|
||||
create: "edr_freight_app:warehouse_zones:create",
|
||||
update: "edr_freight_app:warehouse_zones:update",
|
||||
},
|
||||
warehouseAllocationRules: {
|
||||
view: "edr_freight_app:warehouse_allocation_rules:view",
|
||||
create: "edr_freight_app:warehouse_allocation_rules:create",
|
||||
update: "edr_freight_app:warehouse_allocation_rules:update",
|
||||
delete: "edr_freight_app:warehouse_allocation_rules:delete",
|
||||
},
|
||||
warehouseFeeRules: {
|
||||
view: "edr_freight_app:warehouse_fee_rules:view",
|
||||
create: "edr_freight_app:warehouse_fee_rules:create",
|
||||
update: "edr_freight_app:warehouse_fee_rules:update",
|
||||
delete: "edr_freight_app:warehouse_fee_rules:delete",
|
||||
},
|
||||
warehouseInspectionReports: {
|
||||
view: "edr_freight_app:warehouse_inspection_reports:view",
|
||||
create: "edr_freight_app:warehouse_inspection_reports:create",
|
||||
update: "edr_freight_app:warehouse_inspection_reports:update",
|
||||
},
|
||||
warehouseInventory: {
|
||||
view: "edr_freight_app:warehouse_inventory:view",
|
||||
receive: "edr_freight_app:warehouse_inventory:receive",
|
||||
move: "edr_freight_app:warehouse_inventory:move",
|
||||
load: "edr_freight_app:warehouse_inventory:load",
|
||||
unload: "edr_freight_app:warehouse_inventory:unload",
|
||||
dispatch: "edr_freight_app:warehouse_inventory:dispatch",
|
||||
gatePass: "edr_freight_app:warehouse_inventory:gate_pass",
|
||||
release: "edr_freight_app:warehouse_inventory:release",
|
||||
deliver: "edr_freight_app:warehouse_inventory:deliver",
|
||||
inspect: "edr_freight_app:warehouse_inventory:inspect",
|
||||
},
|
||||
interchangeDocuments: {
|
||||
view: "edr_freight_app:interchange_documents:view",
|
||||
generate: "edr_freight_app:interchange_documents:generate",
|
||||
acknowledge: "edr_freight_app:interchange_documents:acknowledge",
|
||||
dispute: "edr_freight_app:interchange_documents:dispute",
|
||||
cancel: "edr_freight_app:interchange_documents:cancel",
|
||||
},
|
||||
warehouseFeeInvoices: {
|
||||
view: "edr_freight_app:warehouse_fee_invoices:view",
|
||||
generate: "edr_freight_app:warehouse_fee_invoices:generate",
|
||||
cancel: "edr_freight_app:warehouse_fee_invoices:cancel",
|
||||
pay: "edr_freight_app:warehouse_fee_invoices:pay",
|
||||
},
|
||||
config: {
|
||||
contractValidity: {
|
||||
view: "edr_freight_app:config:contract_validity:view",
|
||||
manage: "edr_freight_app:config:contract_validity:manage",
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
fileUpload: {
|
||||
view: "edr_freight_app:settings:file_upload:view",
|
||||
manage: "edr_freight_app:settings:file_upload:manage",
|
||||
},
|
||||
dropdown: {
|
||||
view: "edr_freight_app:settings:dropdown:view",
|
||||
manage: "edr_freight_app:settings:dropdown:manage",
|
||||
},
|
||||
},
|
||||
staff: {
|
||||
roles: {
|
||||
view: "edr_freight_app:staff:roles:view",
|
||||
create: "edr_freight_app:staff:roles:create",
|
||||
update: "edr_freight_app:staff:roles:update",
|
||||
delete: "edr_freight_app:staff:roles:delete",
|
||||
},
|
||||
permissions: {
|
||||
view: "edr_freight_app:staff:permissions:view",
|
||||
assign: "edr_freight_app:staff:permissions:assign",
|
||||
},
|
||||
employeeRegistration: {
|
||||
view: "edr_freight_app:employee_registration:view",
|
||||
create: "edr_freight_app:employee_registration:create",
|
||||
update: "edr_freight_app:employee_registration:update",
|
||||
activate: "edr_freight_app:employee_registration:activate",
|
||||
deactivate: "edr_freight_app:employee_registration:deactivate",
|
||||
},
|
||||
roleAssignment: {
|
||||
view: "edr_freight_app:role_assignment:view",
|
||||
assign: "edr_freight_app:role_assignment:assign",
|
||||
replace: "edr_freight_app:role_assignment:replace",
|
||||
},
|
||||
hierarchyUnits: {
|
||||
view: "edr_freight_app:hierarchy_units:view",
|
||||
create: "edr_freight_app:hierarchy_units:create",
|
||||
update: "edr_freight_app:hierarchy_units:update",
|
||||
delete: "edr_freight_app:hierarchy_units:delete",
|
||||
},
|
||||
hierarchyPositions: {
|
||||
view: "edr_freight_app:hierarchy_positions:view",
|
||||
create: "edr_freight_app:hierarchy_positions:create",
|
||||
update: "edr_freight_app:hierarchy_positions:update",
|
||||
delete: "edr_freight_app:hierarchy_positions:delete",
|
||||
changeParent: "edr_freight_app:hierarchy_positions:change_parent",
|
||||
},
|
||||
hierarchyEmployeeAssignment: {
|
||||
view: "edr_freight_app:hierarchy_employee_assignment:view",
|
||||
invite: "edr_freight_app:hierarchy_employee_assignment:invite",
|
||||
assign: "edr_freight_app:hierarchy_employee_assignment:assign",
|
||||
},
|
||||
positionTypes: {
|
||||
view: "edr_freight_app:position_types:view",
|
||||
create: "edr_freight_app:position_types:create",
|
||||
update: "edr_freight_app:position_types:update",
|
||||
delete: "edr_freight_app:position_types:delete",
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const slugToResourceKey = (slug: RuleEngineResourceSlug): string =>
|
||||
|
||||
@@ -60,3 +60,5 @@ createRoot(rootElement).render(
|
||||
</MantineProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
// run
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
} from "@/components/trainScheduling/containerPlacement.util";
|
||||
import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPlacementGrid";
|
||||
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
|
||||
import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityRideAlongPanel";
|
||||
// import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
|
||||
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
|
||||
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
|
||||
@@ -1130,6 +1131,12 @@ export default function TrainScheduleV2DetailPage() {
|
||||
void detailQuery.refetch();
|
||||
}}
|
||||
/>
|
||||
{scheduleId ? (
|
||||
<IntercityRideAlongPanel
|
||||
scheduleId={scheduleId}
|
||||
direction={schedule.direction}
|
||||
/>
|
||||
) : null}
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
|
||||
@@ -111,7 +111,12 @@ export default function TrainScheduleV2ListPage() {
|
||||
const create = useMutation(api.trainScheduling.createSchedule.mutationOptions());
|
||||
const cancel = useMutation(api.trainScheduling.cancelSchedule.mutationOptions());
|
||||
|
||||
const activeRoutes = useMemo(() => routesQuery.data ?? [], [routesQuery.data]);
|
||||
// Intercity (same-country / DOMESTIC) routes cannot be scheduled yet — the
|
||||
// API rejects them, so keep them out of the picker entirely.
|
||||
const activeRoutes = useMemo(
|
||||
() => (routesQuery.data ?? []).filter((r) => r.direction !== "DOMESTIC"),
|
||||
[routesQuery.data],
|
||||
);
|
||||
|
||||
const selectedRoute = activeRoutes.find((r) => r.id === routeId);
|
||||
|
||||
@@ -380,7 +385,11 @@ export default function TrainScheduleV2ListPage() {
|
||||
}
|
||||
try {
|
||||
const created = await create.mutateAsync({
|
||||
payload: { routeId, scheduleDate, locomotiveIds },
|
||||
payload: {
|
||||
routeId,
|
||||
scheduleDate: new Date(scheduleDate).toISOString(),
|
||||
locomotiveIds,
|
||||
},
|
||||
});
|
||||
toast({ title: "Train schedule created" });
|
||||
showScheduleWarnings(created.warnings);
|
||||
@@ -570,11 +579,8 @@ export default function TrainScheduleV2ListPage() {
|
||||
<TextInput
|
||||
label="Departure date"
|
||||
type="datetime-local"
|
||||
value={scheduleDate ? scheduleDate.slice(0, 16) : ""}
|
||||
onChange={(e) => {
|
||||
const raw = e.currentTarget.value;
|
||||
setScheduleDate(raw ? new Date(raw).toISOString() : "");
|
||||
}}
|
||||
value={scheduleDate}
|
||||
onChange={(e) => setScheduleDate(e.currentTarget.value)}
|
||||
/>
|
||||
<MultiSelect
|
||||
label="Locomotives"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Badge, Button, Card, Group, Tabs, Text } from '@mantine/core';
|
||||
import { CreditCard, Eye, Truck } from 'lucide-react';
|
||||
import { CreditCard, Eye, Truck, TrainFront } from 'lucide-react';
|
||||
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
VisualEmptyState,
|
||||
formatNumber,
|
||||
} from '@/components/warehouses';
|
||||
import { LoadToTrainPanel } from '@/components/warehouses/LoadToTrainPanel';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { api } from '@/services/api';
|
||||
@@ -116,6 +117,9 @@ export default function LoadingQueuePage() {
|
||||
>
|
||||
Dispatch Queue
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="load-train" leftSection={<TrainFront size={14} />}>
|
||||
Load to Train
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
{/* Ready to Load — PAID bookings, can be marked Loaded */}
|
||||
@@ -169,6 +173,11 @@ export default function LoadingQueuePage() {
|
||||
<InventoryWorkbench items={loadedItems} isLoading={loadedLoading} />
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* Load to Train — per-train arrived containers/cargoes, multiselect → load onto wagons */}
|
||||
<Tabs.Panel value="load-train" pt="md">
|
||||
<LoadToTrainPanel />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Card>
|
||||
</PageContainer>
|
||||
|
||||
@@ -32,7 +32,21 @@ import {
|
||||
useFeeRules,
|
||||
} from '@/hooks/useWarehouses';
|
||||
import { api } from '@/services/api';
|
||||
import { FEE_RULE_TYPES, type FeeRuleType } from '@/types/warehouse';
|
||||
import {
|
||||
FEE_RULE_BASES,
|
||||
FEE_RULE_BASIS_LABELS,
|
||||
FEE_RULE_TYPES,
|
||||
FEE_RULE_TYPE_LABELS,
|
||||
type FeeRuleBasis,
|
||||
type FeeRuleType,
|
||||
} from '@/types/warehouse';
|
||||
|
||||
const RULE_TYPE_COLOR: Record<FeeRuleType, string> = {
|
||||
STORAGE_FEE: 'teal',
|
||||
DEMURRAGE_FEE: 'orange',
|
||||
DOUBLE_HANDLING_FEE: 'grape',
|
||||
TRUCK_DETENTION_FEE: 'blue',
|
||||
};
|
||||
import { extractErrorMessage, lettersOnly } from '@/components/warehouses/options';
|
||||
|
||||
const FREIGHT = [
|
||||
@@ -353,6 +367,7 @@ function FeeRules() {
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
ruleType: 'DEMURRAGE_FEE' as FeeRuleType,
|
||||
basis: 'PER_CONTAINER' as FeeRuleBasis,
|
||||
freightType: '',
|
||||
tradeDirection: '',
|
||||
cargoTypeCode: '',
|
||||
@@ -367,11 +382,15 @@ function FeeRules() {
|
||||
const containerTypeOptions = codeOptions(containerTypes);
|
||||
const isBulkRule = form.freightType === 'BULK';
|
||||
const isContainerRule = form.freightType === 'CONTAINER';
|
||||
// Double handling is a flat per-unit charge (basis × rate), not day-based:
|
||||
// no free days, no progressive tiers.
|
||||
const isDoubleHandling = form.ruleType === 'DOUBLE_HANDLING_FEE';
|
||||
|
||||
const resetForm = () =>
|
||||
setForm({
|
||||
name: '',
|
||||
ruleType: 'DEMURRAGE_FEE',
|
||||
basis: 'PER_CONTAINER',
|
||||
freightType: '',
|
||||
tradeDirection: '',
|
||||
cargoTypeCode: '',
|
||||
@@ -440,10 +459,12 @@ function FeeRules() {
|
||||
tradeDirection: clean(form.tradeDirection) ?? null,
|
||||
cargoTypeCode: isBulkRule ? (clean(form.cargoTypeCode) ?? null) : null,
|
||||
containerType: isContainerRule ? (clean(form.containerType) ?? null) : null,
|
||||
freeDays: form.freeDays,
|
||||
// Double handling: flat basis × rate — no free days, no tiers.
|
||||
freeDays: isDoubleHandling ? 0 : form.freeDays,
|
||||
ratePerDay: form.ratePerDay,
|
||||
currency: form.currency || 'USD',
|
||||
...(tiers.length ? { tiers } : {}),
|
||||
...(isDoubleHandling ? { basis: form.basis } : {}),
|
||||
...(!isDoubleHandling && tiers.length ? { tiers } : {}),
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -514,8 +535,8 @@ function FeeRules() {
|
||||
{rules.map((rule) => (
|
||||
<Table.Tr key={rule.id}>
|
||||
<Table.Td>
|
||||
<Badge color={rule.ruleType === 'DEMURRAGE_FEE' ? 'orange' : 'teal'} variant="light">
|
||||
{rule.ruleType === 'DEMURRAGE_FEE' ? 'Demurrage' : 'Storage'}
|
||||
<Badge color={RULE_TYPE_COLOR[rule.ruleType] ?? 'gray'} variant="light">
|
||||
{FEE_RULE_TYPE_LABELS[rule.ruleType] ?? rule.ruleType}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>{rule.name}</Table.Td>
|
||||
@@ -577,7 +598,7 @@ function FeeRules() {
|
||||
label="Rule type"
|
||||
data={FEE_RULE_TYPES.map((type) => ({
|
||||
value: type,
|
||||
label: type === 'DEMURRAGE_FEE' ? 'Demurrage' : 'Storage',
|
||||
label: FEE_RULE_TYPE_LABELS[type],
|
||||
}))}
|
||||
value={form.ruleType}
|
||||
onChange={(value) =>
|
||||
@@ -637,14 +658,26 @@ function FeeRules() {
|
||||
/>
|
||||
)}
|
||||
<Group grow>
|
||||
{isDoubleHandling ? (
|
||||
<Select
|
||||
label="Basis"
|
||||
data={FEE_RULE_BASES.map((b) => ({ value: b, label: FEE_RULE_BASIS_LABELS[b] }))}
|
||||
value={form.basis}
|
||||
onChange={(value) =>
|
||||
setForm((f) => ({ ...f, basis: selectValue(value, 'PER_CONTAINER') as FeeRuleBasis }))
|
||||
}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
) : (
|
||||
<NumberInput
|
||||
label="Free days"
|
||||
min={0}
|
||||
value={form.freeDays}
|
||||
onChange={(value) => setForm((f) => ({ ...f, freeDays: numberValue(value) }))}
|
||||
/>
|
||||
)}
|
||||
<NumberInput
|
||||
label="Free days"
|
||||
min={0}
|
||||
value={form.freeDays}
|
||||
onChange={(value) => setForm((f) => ({ ...f, freeDays: numberValue(value) }))}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Rate / day"
|
||||
label={isDoubleHandling ? 'Rate / unit' : 'Rate / day'}
|
||||
min={0}
|
||||
value={form.ratePerDay}
|
||||
onChange={(value) => setForm((f) => ({ ...f, ratePerDay: numberValue(value) }))}
|
||||
@@ -657,6 +690,13 @@ function FeeRules() {
|
||||
allowDeselect={false}
|
||||
/>
|
||||
</Group>
|
||||
{isDoubleHandling && (
|
||||
<Text size="xs" c="dimmed">
|
||||
Flat charge — the rate is multiplied by the selected basis (
|
||||
{FEE_RULE_BASIS_LABELS[form.basis]}). No free days or progressive tiers.
|
||||
</Text>
|
||||
)}
|
||||
{!isDoubleHandling && (
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" fw={600}>
|
||||
@@ -702,6 +742,7 @@ function FeeRules() {
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
|
||||
@@ -593,6 +593,52 @@ export const api = {
|
||||
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||
),
|
||||
|
||||
intercityCandidates: endpoint<
|
||||
{ scheduleId: string },
|
||||
import("@/types/trainScheduling").IntercityCandidatesResult
|
||||
>(
|
||||
"train-scheduling",
|
||||
"intercity-candidates",
|
||||
({ scheduleId }) => trainSchedulingService.getIntercityCandidates(scheduleId),
|
||||
({ scheduleId }) => ["train-scheduling", "intercity-candidates", scheduleId],
|
||||
),
|
||||
|
||||
acceptIntercityBookings: endpoint<
|
||||
{ scheduleId: string; bookingIds: string[] },
|
||||
import("@/types/trainScheduling").IntercityAcceptResult
|
||||
>(
|
||||
"train-scheduling",
|
||||
"intercity-accept",
|
||||
({ scheduleId, bookingIds }) =>
|
||||
trainSchedulingService.acceptIntercityBookings(scheduleId, bookingIds),
|
||||
undefined,
|
||||
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||
),
|
||||
|
||||
loadIntercityBooking: endpoint<
|
||||
{ scheduleId: string; bookingId: string },
|
||||
void
|
||||
>(
|
||||
"train-scheduling",
|
||||
"intercity-load",
|
||||
({ scheduleId, bookingId }) =>
|
||||
trainSchedulingService.loadIntercityBooking(scheduleId, bookingId),
|
||||
undefined,
|
||||
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||
),
|
||||
|
||||
unloadIntercityBooking: endpoint<
|
||||
{ scheduleId: string; bookingId: string },
|
||||
void
|
||||
>(
|
||||
"train-scheduling",
|
||||
"intercity-unload",
|
||||
({ scheduleId, bookingId }) =>
|
||||
trainSchedulingService.unloadIntercityBooking(scheduleId, bookingId),
|
||||
undefined,
|
||||
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||
),
|
||||
|
||||
cancelSchedule: endpoint<
|
||||
{ id: string; freightType?: FreightType },
|
||||
TrainScheduleDetail
|
||||
|
||||
@@ -4,6 +4,9 @@ import { URL_CONSTANTS } from '@/constants/URLS';
|
||||
|
||||
export type RouteStatus = 'AVAILABLE' | 'MAINTENANCE' | 'DAMAGED' | 'STOP_WORKING';
|
||||
|
||||
/** Frozen from yard countries: ET→DJ EXPORT, DJ→ET IMPORT, same country DOMESTIC (intercity, disabled). */
|
||||
export type RouteDirection = 'IMPORT' | 'EXPORT' | 'DOMESTIC';
|
||||
|
||||
export interface YardRef {
|
||||
id: string;
|
||||
code: string;
|
||||
@@ -23,6 +26,7 @@ export interface RouteMilestone {
|
||||
export interface RouteRecord {
|
||||
id: string;
|
||||
status: RouteStatus;
|
||||
direction?: RouteDirection;
|
||||
originYardId: string;
|
||||
destinationYardId: string;
|
||||
originYard?: YardRef | null;
|
||||
|
||||
@@ -17,6 +17,8 @@ import type {
|
||||
ImportDjiboutiLoadList,
|
||||
ImportDjiboutiOperation,
|
||||
ImportLoadingBookingsResponse,
|
||||
IntercityAcceptResult,
|
||||
IntercityCandidatesResult,
|
||||
LoadingStatus,
|
||||
LocomotiveRecord,
|
||||
PinWagonsPayload,
|
||||
@@ -328,6 +330,46 @@ export const trainSchedulingService = {
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
getIntercityCandidates: async (
|
||||
scheduleId: string,
|
||||
): Promise<IntercityCandidatesResult> => {
|
||||
const response = await client.get<IntercityCandidatesResult>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.INTERCITY_CANDIDATES(scheduleId),
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
acceptIntercityBookings: async (
|
||||
scheduleId: string,
|
||||
bookingIds: string[],
|
||||
): Promise<IntercityAcceptResult> => {
|
||||
const response = await client.post<IntercityAcceptResult>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.INTERCITY_ACCEPT(scheduleId),
|
||||
{ bookingIds },
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
loadIntercityBooking: async (
|
||||
scheduleId: string,
|
||||
bookingId: string,
|
||||
): Promise<void> => {
|
||||
await client.post(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.INTERCITY_LOAD(scheduleId, bookingId),
|
||||
{},
|
||||
);
|
||||
},
|
||||
|
||||
unloadIntercityBooking: async (
|
||||
scheduleId: string,
|
||||
bookingId: string,
|
||||
): Promise<void> => {
|
||||
await client.post(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.INTERCITY_UNLOAD(scheduleId, bookingId),
|
||||
{},
|
||||
);
|
||||
},
|
||||
|
||||
dispatchSchedule: async (
|
||||
scheduleId: string,
|
||||
): Promise<TrainScheduleDetail> => {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Freight } from '@edr/types';
|
||||
|
||||
import { api as apiClient } from '../auth/http';
|
||||
|
||||
import { URL_CONSTANTS } from '@/constants/URLS';
|
||||
@@ -61,12 +63,126 @@ import type {
|
||||
WarehouseZone,
|
||||
} from '@/types/warehouse';
|
||||
|
||||
export type ContainerItemStage = 'PENDING' | 'RECEIVED' | 'GRN' | 'LOADED' | 'LEFT' | 'DELIVERED';
|
||||
|
||||
export interface ContainerItem {
|
||||
containerNumber: string;
|
||||
goods: string | null;
|
||||
stage: ContainerItemStage;
|
||||
grnNumber: string | null;
|
||||
truckAssignmentId: string | null;
|
||||
truckPlate: string | null;
|
||||
truckArrived: boolean;
|
||||
truckLeft: boolean;
|
||||
bookingReference: string | null;
|
||||
contractId: string | null;
|
||||
hasLastMile: boolean;
|
||||
}
|
||||
|
||||
/** A pre-dispatch EXPORT train that has inventory waiting to be loaded. */
|
||||
export interface LoadableTrain {
|
||||
scheduleId: string;
|
||||
trainNumber: string | null;
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
status: string;
|
||||
departureTime: string | null;
|
||||
readyCount: number;
|
||||
loadedCount: number;
|
||||
}
|
||||
|
||||
/** A container/cargo inventory item assigned to a train, with its allocated wagon. */
|
||||
export interface TrainLoadableItem {
|
||||
id: string;
|
||||
bookingId: string | null;
|
||||
bookingReference: string | null;
|
||||
customerName: string | null;
|
||||
containerNumber: string | null;
|
||||
cargoType: string | null;
|
||||
weight: number | null;
|
||||
grnNumber: string | null;
|
||||
inspectionStatus: string | null;
|
||||
status: string;
|
||||
wagonId: string | null;
|
||||
wagonNumber: string | null;
|
||||
sequenceNo: number | null;
|
||||
loadable: boolean;
|
||||
}
|
||||
|
||||
export interface TrainLoadResult {
|
||||
loadedCount: number;
|
||||
skippedCount: number;
|
||||
results: { inventoryId: string; status: string; reason?: string }[];
|
||||
}
|
||||
|
||||
const cleanParams = (params: object) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(params).filter(([, value]) => value !== undefined && value !== '' && value !== null),
|
||||
);
|
||||
|
||||
export const warehouseService = {
|
||||
/** Customer self-haul trucks assigned to a booking (portal multi-truck). */
|
||||
getCustomerTrucks: async (bookingId: string): Promise<Freight.ICustomerTruck[]> => {
|
||||
const { data } = await apiClient.get(`/bookings/${bookingId}/customer-trucks`);
|
||||
return data?.data ?? data ?? [];
|
||||
},
|
||||
|
||||
/** Per-container/bulk items of a booking with lifecycle stage + refs. */
|
||||
getContainerItems: async (bookingId: string): Promise<ContainerItem[]> => {
|
||||
const { data } = await apiClient.get(
|
||||
`/warehouse-inventory/bookings/${bookingId}/container-items`,
|
||||
);
|
||||
return data?.data ?? data ?? [];
|
||||
},
|
||||
|
||||
/** Booking container numbers not yet loaded onto any truck. */
|
||||
getLoadableContainers: async (bookingId: string): Promise<string[]> => {
|
||||
const { data } = await apiClient.get(
|
||||
`/bookings/${bookingId}/customer-trucks/loadable-containers`,
|
||||
);
|
||||
return data?.data ?? data ?? [];
|
||||
},
|
||||
|
||||
/** Truck_dispatch: load selected containers onto a truck (after arrival). */
|
||||
loadTruck: async (
|
||||
bookingId: string,
|
||||
assignmentId: string,
|
||||
containerNumbers: string[],
|
||||
): Promise<Freight.ICustomerTruck[]> => {
|
||||
const { data } = await apiClient.post(
|
||||
`/bookings/${bookingId}/customer-trucks/${assignmentId}/load`,
|
||||
{ containerNumbers },
|
||||
);
|
||||
return data?.data ?? data ?? [];
|
||||
},
|
||||
|
||||
// ── Load to Train ─────────────────────────────────────────────────────────
|
||||
/** Pre-dispatch EXPORT trains with inventory waiting to be loaded. */
|
||||
getLoadableTrains: async (): Promise<LoadableTrain[]> => {
|
||||
const { data } = await apiClient.get('/warehouse-inventory/loadable-trains');
|
||||
return data?.data ?? data ?? [];
|
||||
},
|
||||
|
||||
/** Container/cargo items assigned to a train, with allocated wagon + stage. */
|
||||
getTrainLoadableItems: async (scheduleId: string): Promise<TrainLoadableItem[]> => {
|
||||
const { data } = await apiClient.get(
|
||||
`/warehouse-inventory/train/${scheduleId}/loadable-items`,
|
||||
);
|
||||
return data?.data ?? data ?? [];
|
||||
},
|
||||
|
||||
/** Load selected inventory items onto their allocated wagons for a train. */
|
||||
loadItemsOntoTrain: async (
|
||||
scheduleId: string,
|
||||
inventoryIds: string[],
|
||||
): Promise<TrainLoadResult> => {
|
||||
const { data } = await apiClient.post(
|
||||
`/warehouse-inventory/train/${scheduleId}/load`,
|
||||
{ inventoryIds },
|
||||
);
|
||||
return data?.data ?? data ?? { loadedCount: 0, skippedCount: 0, results: [] };
|
||||
},
|
||||
|
||||
// ── Warehouses ──────────────────────────────────────────────────────────
|
||||
list: (filter?: WarehouseFilter) =>
|
||||
apiClient.get<Warehouse[]>(URL_CONSTANTS.WAREHOUSES.BASE, {
|
||||
@@ -147,6 +263,11 @@ export const warehouseService = {
|
||||
apiClient.get<Blob>(URL_CONSTANTS.WAREHOUSE_INVENTORY.HANDOVER_DOCUMENT(id), {
|
||||
responseType: 'blob',
|
||||
}),
|
||||
/** Per-truck exit paper PDF (containers loaded on one customer truck). */
|
||||
downloadTruckExitPaper: (assignmentId: string) =>
|
||||
apiClient.get<Blob>(`/warehouse-inventory/customer-truck-exit-paper/${assignmentId}`, {
|
||||
responseType: 'blob',
|
||||
}),
|
||||
deliver: (id: string, payload: DeliverInventoryPayload) =>
|
||||
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.DELIVER(id), payload),
|
||||
|
||||
|
||||
@@ -407,6 +407,7 @@ export interface ScheduleWindowRule {
|
||||
windowDurationHours: number | null;
|
||||
reopenDelayMinutes: number | null;
|
||||
importWindowLeadDays: number | null;
|
||||
exportBookingLeadHours: number | null;
|
||||
/** Live global values (not snapshotted per schedule) — editor prefill baseline. */
|
||||
docReviewMinutes: number;
|
||||
paymentWindowMinutes: number;
|
||||
@@ -420,6 +421,7 @@ export interface UpdateScheduleWindowRulePayload {
|
||||
docReviewMinutes?: number;
|
||||
paymentWindowMinutes?: number;
|
||||
importWindowLeadDays?: number;
|
||||
exportBookingLeadHours?: number;
|
||||
}
|
||||
|
||||
export interface TrainScheduleDetail {
|
||||
@@ -746,3 +748,49 @@ export interface CompositionRemovalEntry {
|
||||
removedAt: string;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
// ── Intercity ride-along ─────────────────────────────────────────────────────
|
||||
// Intercity (DOMESTIC) bookings have no train of their own — they ride a
|
||||
// passing import/export schedule whose route milestones contain the booking's
|
||||
// origin before its destination. Staff accept them at finalize time against
|
||||
// the train's remaining wagon/weight/length capacity.
|
||||
|
||||
export interface IntercityCapacity {
|
||||
wagons: number;
|
||||
weightTons: number;
|
||||
lengthMeters: number;
|
||||
}
|
||||
|
||||
export interface IntercityBookingRow {
|
||||
id: string;
|
||||
reference: string | null;
|
||||
status: string;
|
||||
freightType: FreightType | null;
|
||||
isGovernment: boolean;
|
||||
customer: string;
|
||||
originYardId: string;
|
||||
destinationYardId: string;
|
||||
origin: string;
|
||||
destination: string;
|
||||
weightTons: number;
|
||||
paymentDeadline: string | null;
|
||||
need: IntercityCapacity | null;
|
||||
}
|
||||
|
||||
export interface IntercityCandidateRow extends IntercityBookingRow {
|
||||
fits: boolean;
|
||||
}
|
||||
|
||||
export interface IntercityCandidatesResult {
|
||||
scheduleId: string;
|
||||
routeId: string | null;
|
||||
remaining: IntercityCapacity | null;
|
||||
candidates: IntercityCandidateRow[];
|
||||
accepted: IntercityBookingRow[];
|
||||
}
|
||||
|
||||
export interface IntercityAcceptResult {
|
||||
accepted: string[];
|
||||
rejected: Array<{ bookingId: string; reason: string }>;
|
||||
remaining: IntercityCapacity;
|
||||
}
|
||||
|
||||
@@ -737,13 +737,38 @@ export interface AllocationRule {
|
||||
}
|
||||
export type SaveAllocationRulePayload = Omit<AllocationRule, 'id'>;
|
||||
|
||||
export const FEE_RULE_TYPES = ['STORAGE_FEE', 'DEMURRAGE_FEE'] as const;
|
||||
export const FEE_RULE_TYPES = [
|
||||
'STORAGE_FEE',
|
||||
'DEMURRAGE_FEE',
|
||||
'DOUBLE_HANDLING_FEE',
|
||||
'TRUCK_DETENTION_FEE',
|
||||
] as const;
|
||||
export type FeeRuleType = (typeof FEE_RULE_TYPES)[number];
|
||||
|
||||
/** Human labels for each fee rule type (dropdowns, badges). */
|
||||
export const FEE_RULE_TYPE_LABELS: Record<FeeRuleType, string> = {
|
||||
STORAGE_FEE: 'Storage',
|
||||
DEMURRAGE_FEE: 'Demurrage',
|
||||
DOUBLE_HANDLING_FEE: 'Double Handling',
|
||||
TRUCK_DETENTION_FEE: 'Truck Detention Cost',
|
||||
};
|
||||
|
||||
/** Charge basis for a Double Handling rule (flat rate × the chosen quantity). */
|
||||
export const FEE_RULE_BASES = ['PER_CONTAINER', 'PER_TON', 'PER_ITEM'] as const;
|
||||
export type FeeRuleBasis = (typeof FEE_RULE_BASES)[number];
|
||||
|
||||
export const FEE_RULE_BASIS_LABELS: Record<FeeRuleBasis, string> = {
|
||||
PER_CONTAINER: 'Per Container',
|
||||
PER_TON: 'Per Ton',
|
||||
PER_ITEM: 'Per Item',
|
||||
};
|
||||
|
||||
export interface FeeRule {
|
||||
id: string;
|
||||
name: string;
|
||||
ruleType: FeeRuleType;
|
||||
/** Double-handling charge basis; null for day-based fee types. */
|
||||
basis?: FeeRuleBasis | null;
|
||||
priority: number;
|
||||
freightType?: string | null;
|
||||
tradeDirection?: string | null;
|
||||
@@ -776,6 +801,7 @@ export interface FeePreviewTier extends FeeRuleTier {
|
||||
|
||||
export interface FeePreview {
|
||||
ruleType: FeeRuleType;
|
||||
basis?: FeeRuleBasis | null;
|
||||
ruleId: string | null;
|
||||
ruleName: string | null;
|
||||
freeDays: number;
|
||||
|
||||
Reference in New Issue
Block a user