Merge pull request #890 from Tria-plc/freight_feature/usermanagement

Freight feature/usermanagement
This commit is contained in:
marshal
2026-07-22 02:17:00 +03:00
committed by GitHub
42 changed files with 1924 additions and 161 deletions

View File

@@ -23,6 +23,7 @@ const INCIDENT_OPTIONS: { value: Freight.IncidentType; label: string }[] = [
{ value: "CONTAINER_OPENED", label: "Container opened" },
{ value: "CONTAINER_DAMAGED", label: "Container damaged" },
{ value: "FLUID_LEAKING", label: "Fluid leaking" },
{ value: "OTHER", label: "Other" },
];
const LABEL: Record<Freight.IncidentType, string> = {
@@ -30,6 +31,7 @@ const LABEL: Record<Freight.IncidentType, string> = {
CONTAINER_OPENED: "Container opened",
CONTAINER_DAMAGED: "Container damaged",
FLUID_LEAKING: "Fluid leaking",
OTHER: "Other",
};
export function IncidentReportCard({ bookingId }: { bookingId: string }) {

View File

@@ -97,6 +97,15 @@ function CorridorCell({ row }: { row: IntercityBookingRow }) {
* confirmed manually when the train is physically at the booking's origin /
* destination yard (the server validates against recorded checkpoints).
*/
/** Plain-language journey states for the accepted ride-along table. */
const INTERCITY_STATUS_META: Record<string, { label: string; color: string }> = {
SELECTED_FOR_BATCH: { label: "Awaiting payment", color: "yellow" },
APPROVED: { label: "Ready to load (gov)", color: "edr-green" },
PAID: { label: "Paid — ready to load", color: "edr-green" },
IN_TRANSIT: { label: "Loaded — in transit", color: "indigo" },
COMPLETED: { label: "Delivered", color: "teal" },
};
export function IntercityRideAlongPanel({
scheduleId,
direction,
@@ -115,10 +124,20 @@ export function IntercityRideAlongPanel({
}),
);
const invalidate = () =>
queryClient.invalidateQueries({
// Accepting/loading/unloading a ride-along changes the schedule's booking
// list, the yard worklists AND this panel — refresh all three so the
// workspace board and yard-work tables never show a stale picture.
const invalidate = () => {
void queryClient.invalidateQueries({
queryKey: api.trainScheduling.intercityCandidates.queryKey({ scheduleId }),
});
void queryClient.invalidateQueries({
queryKey: api.trainScheduling.yardWork.queryKey({ scheduleId }),
});
void queryClient.invalidateQueries({
queryKey: api.trainScheduling.scheduleDetail.queryKey({ id: scheduleId }),
});
};
const accept = useMutation(
api.trainScheduling.acceptIntercityBookings.mutationOptions({
@@ -330,8 +349,14 @@ export function IntercityRideAlongPanel({
<CorridorCell row={row} />
</Table.Td>
<Table.Td>
<Badge size="sm" variant="light">
{row.status}
<Badge
size="sm"
variant="light"
color={
INTERCITY_STATUS_META[row.status ?? ""]?.color ?? "gray"
}
>
{INTERCITY_STATUS_META[row.status ?? ""]?.label ?? row.status}
</Badge>
</Table.Td>
<Table.Td>

View File

@@ -584,6 +584,15 @@ export function ScheduleWorkspacePanel({
customer={b.customer}
weightTons={b.weightTons}
status={b.status}
intercity={b.tradeDirection === "DOMESTIC"}
leg={
b.origin &&
b.destination &&
(b.originYardId !== schedule.originStation?.id ||
b.destinationYardId !== schedule.destinationStation?.id)
? `${b.origin}${b.destination}`
: null
}
loadingStatus={b.wagonAssigned ? b.loadingStatus ?? "UNLOADED" : undefined}
right={
canManage ? (
@@ -840,6 +849,8 @@ function BookingCard({
status,
loadingStatus,
waitingForWagon,
intercity,
leg,
right,
}: {
reference: string;
@@ -849,6 +860,10 @@ function BookingCard({
loadingStatus?: "LOADED" | "UNLOADED";
/** Paid, but no wagon of the required type was free — waiting for one. */
waitingForWagon?: boolean;
/** DOMESTIC ride-along riding only part of this train's corridor. */
intercity?: boolean;
/** "Origin → Destination" when the booking rides a sub-corridor leg. */
leg?: string | null;
right?: React.ReactNode;
}) {
return (
@@ -874,6 +889,16 @@ function BookingCard({
{reference}
</Text>
{status ? <BookingStatusBadge status={status} /> : null}
{intercity ? (
<Tooltip
label="Intercity ride-along — rides only its own leg of this train's corridor"
withArrow
>
<Badge size="sm" radius="sm" variant="filled" color="indigo">
Intercity
</Badge>
</Tooltip>
) : null}
{waitingForWagon ? (
<Tooltip
label="Paid, but no wagon of the required type was free. Free a wagon or assign it to a same-day train that has one."
@@ -907,6 +932,11 @@ function BookingCard({
</Text>
</Group>
) : null}
{leg ? (
<Text size="xs" c="indigo.7" fw={600} style={{ whiteSpace: "nowrap" }}>
{leg}
</Text>
) : null}
</Group>
</Stack>
{right ? <Box style={{ flexShrink: 0 }}>{right}</Box> : null}

View File

@@ -2,7 +2,10 @@ import { Badge, Card, Group, Progress, SimpleGrid, Stack, Text, ThemeIcon } from
import { Box, Package } from "lucide-react";
import type { TrainScheduleWagonAllocation, WagonPlanRow } from "@/types/trainScheduling";
type WagonSlot = (WagonPlanRow & { physicalWagonNumber?: string | null }) | {
type WagonSlot = (WagonPlanRow & {
physicalWagonNumber?: string | null;
tareWeightTons?: number | null;
}) | {
sequenceNo: number;
capacityTons: number;
assignedWeightTons: number;

View File

@@ -201,10 +201,19 @@ export function YardWorkPanel({ scheduleId }: { scheduleId: string }) {
}),
);
const invalidate = () =>
queryClient.invalidateQueries({
// Loading/unloading changes booking status on the schedule detail and the
// intercity panel too — refresh all three so no surface shows a stale state.
const invalidate = () => {
void queryClient.invalidateQueries({
queryKey: api.trainScheduling.yardWork.queryKey({ scheduleId }),
});
void queryClient.invalidateQueries({
queryKey: api.trainScheduling.intercityCandidates.queryKey({ scheduleId }),
});
void queryClient.invalidateQueries({
queryKey: api.trainScheduling.scheduleDetail.queryKey({ id: scheduleId }),
});
};
const load = useMutation(
api.trainScheduling.loadScheduleBooking.mutationOptions({

View File

@@ -1,3 +1,4 @@
import { useState } from "react";
import { Badge, Box, Group, HoverCard, Stack, Text } from "@mantine/core";
import {
Building2,
@@ -14,6 +15,15 @@ import { freightBrand } from "@/theme/freight-brand";
type Wagon = NonNullable<TrainScheduleDetail["trainSet"]>["wagons"][number];
type Locomotive = NonNullable<TrainScheduleDetail["trainSet"]>["locomotive"];
export interface ContainerMove {
itemId: string;
targetWagonId: string;
/** Present when the drop landed on another container — swap the two. */
swapWithItemId?: string;
}
type DragState = { itemId: string; sourceWagonId: string } | null;
interface InteractiveTrainConsistProps {
wagons: Wagon[];
locomotive: Locomotive | null | undefined;
@@ -23,8 +33,16 @@ interface InteractiveTrainConsistProps {
onSelectWagon: (wagon: Wagon) => void;
/** Booking id to highlight across the train (e.g. selected in the side panel). */
highlightBookingId?: string | null;
/** Containers become draggable between wagons (drop on a container = swap). */
canRearrange?: boolean;
onMoveContainer?: (move: ContainerMove) => void;
}
const wagonItems = (wagon: Wagon) =>
(wagon.allocations ?? [])
.flatMap((a) => a.containerItems ?? [])
.sort((a, b) => (a.positionOnWagon ?? 99) - (b.positionOnWagon ?? 99));
const CONTAINER_GRADIENTS = [
"linear-gradient(180deg, var(--mantine-color-cyan-5), var(--mantine-color-cyan-7))",
"linear-gradient(180deg, var(--mantine-color-blue-5), var(--mantine-color-blue-7))",
@@ -151,16 +169,27 @@ function WagonCar({
selected,
highlighted,
onSelect,
drag,
onDragChange,
onMoveContainer,
canRearrange,
}: {
wagon: Wagon;
company: string | null;
selected: boolean;
highlighted: boolean;
onSelect: () => void;
drag: DragState;
onDragChange: (drag: DragState) => void;
onMoveContainer?: (move: ContainerMove) => void;
canRearrange: boolean;
}) {
const [dropHover, setDropHover] = useState(false);
const allocation = wagon.allocations?.[0];
const isEmpty = !allocation;
const isBulk = (allocation?.loadType ?? "").toUpperCase().includes("BULK");
const isBulk = (wagon.allocations ?? []).some((a) =>
(a.loadType ?? "").toUpperCase().includes("BULK"),
);
// GROSS on both sides: cargo + tare vs rated payload + tare.
const tare = wagon.tareWeightTons ?? 0;
const assigned =
@@ -170,10 +199,19 @@ function WagonCar({
const accent = isEmpty ? "gray" : isBulk ? "orange" : "cyan";
const accentVar = `var(--mantine-color-${accent}-6)`;
const containerNumbers = (allocation?.containerItems ?? []).map(
(c) => c.containerNumber?.trim() || "—",
const items = wagonItems(wagon);
const blocks = items.slice(0, 2);
const containerNumbers = items.map((c) => c.containerNumber?.trim() || "—");
// Where a dragged container may land: another wagon, not bulk-loaded, with a
// free half (the API re-checks TEU/weight — this only paints the hint).
const dropEligible = Boolean(
drag && drag.sourceWagonId !== wagon.id && !isBulk && items.length < 2,
);
const blocks = containerNumbers.slice(0, 2);
const endDrag = () => {
onDragChange(null);
setDropHover(false);
};
const ringColor = selected
? freightBrand.primary
@@ -189,6 +227,21 @@ function WagonCar({
style={{ width: 120, flexShrink: 0, cursor: "pointer" }}
>
<Box
onDragOver={(e) => {
if (dropEligible) {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setDropHover(true);
}
}}
onDragLeave={() => setDropHover(false)}
onDrop={(e) => {
if (dropEligible && drag) {
e.preventDefault();
onMoveContainer?.({ itemId: drag.itemId, targetWagonId: wagon.id });
}
endDrag();
}}
style={{
position: "relative",
height: 70,
@@ -205,10 +258,16 @@ function WagonCar({
: isEmpty
? "none"
: "0 3px 10px rgba(15,41,27,0.08)",
outline: dropHover
? "2px solid var(--mantine-color-cyan-6)"
: dropEligible
? "2px dashed var(--mantine-color-cyan-4)"
: "none",
outlineOffset: 2,
overflow: "hidden",
display: "flex",
flexDirection: "column",
transition: "box-shadow 120ms ease",
transition: "box-shadow 120ms ease, outline-color 120ms ease",
}}
>
{/* top accent strip */}
@@ -274,28 +333,84 @@ function WagonCar({
</Stack>
) : (
<Group gap={3} justify="center" wrap="nowrap" style={{ width: "100%" }}>
{(blocks.length ? blocks : ["—"]).map((cn, i) => (
{blocks.length ? (
blocks.map((item, i) => {
const isDragged = drag?.itemId === item.id;
const swapEligible = Boolean(drag && drag.itemId !== item.id);
return (
<Box
key={item.id}
draggable={canRearrange}
onDragStart={(e) => {
e.stopPropagation();
e.dataTransfer.effectAllowed = "move";
// Firefox needs data set for the drag to start.
e.dataTransfer.setData("text/plain", item.id);
onDragChange({ itemId: item.id, sourceWagonId: wagon.id });
}}
onDragEnd={endDrag}
onDragOver={(e) => {
if (swapEligible) {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = "move";
}
}}
onDrop={(e) => {
if (swapEligible && drag) {
e.preventDefault();
e.stopPropagation();
onMoveContainer?.({
itemId: drag.itemId,
targetWagonId: wagon.id,
swapWithItemId: item.id,
});
}
endDrag();
}}
style={{
flex: 1,
minWidth: 0,
height: 26,
borderRadius: 4,
background: CONTAINER_GRADIENTS[i % CONTAINER_GRADIENTS.length],
border: `1px solid ${CONTAINER_BORDERS[i % CONTAINER_BORDERS.length]}`,
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.3)",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "0 2px",
cursor: canRearrange ? "grab" : undefined,
opacity: isDragged ? 0.35 : 1,
transition: "opacity 120ms ease",
}}
>
<Text size="8px" fw={700} c="white" truncate style={{ maxWidth: "100%" }}>
{item.containerNumber?.trim() || "—"}
</Text>
</Box>
);
})
) : (
<Box
key={i}
style={{
flex: 1,
minWidth: 0,
height: 26,
borderRadius: 4,
background: CONTAINER_GRADIENTS[i % CONTAINER_GRADIENTS.length],
border: `1px solid ${CONTAINER_BORDERS[i % CONTAINER_BORDERS.length]}`,
background: CONTAINER_GRADIENTS[0],
border: `1px solid ${CONTAINER_BORDERS[0]}`,
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.3)",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "0 2px",
}}
>
<Text size="8px" fw={700} c="white" truncate style={{ maxWidth: "100%" }}>
{cn}
<Text size="8px" fw={700} c="white">
</Text>
</Box>
))}
)}
</Group>
)}
</Box>
@@ -447,7 +562,10 @@ export const InteractiveTrainConsist = ({
selectedWagonId,
onSelectWagon,
highlightBookingId,
canRearrange = false,
onMoveContainer,
}: InteractiveTrainConsistProps) => {
const [drag, setDrag] = useState<DragState>(null);
return (
<Box
style={{
@@ -477,6 +595,10 @@ export const InteractiveTrainConsist = ({
selected={selectedWagonId === wagon.id}
highlighted={Boolean(highlightBookingId && bookingId === highlightBookingId)}
onSelect={() => onSelectWagon(wagon)}
drag={drag}
onDragChange={setDrag}
onMoveContainer={onMoveContainer}
canRearrange={canRearrange}
/>
</Group>
);

View File

@@ -1,13 +1,15 @@
import { useMemo, useState } from "react";
import { Badge, Box, Group, Paper, Stack, Text, ThemeIcon } from "@mantine/core";
import { MousePointerClick, TrainFront } from "lucide-react";
import { isAxiosError } from "axios";
import { Hand, MousePointerClick, TrainFront } from "lucide-react";
import type { TrainScheduleDetail } from "@/types/trainScheduling";
import { TrainStatsBar } from "./TrainStatsBar";
import { WagonCard } from "./WagonCard";
import { InteractiveTrainConsist } from "./InteractiveTrainConsist";
import { InteractiveTrainConsist, type ContainerMove } from "./InteractiveTrainConsist";
import { RemoveBookingModal } from "./RemoveBookingModal";
import { useMutation } from "@tanstack/react-query";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
import { freightBrand } from "@/theme/freight-brand";
type Wagon = NonNullable<TrainScheduleDetail["trainSet"]>["wagons"][number];
@@ -47,6 +49,7 @@ export const TrainConsistView = ({
}: TrainConsistViewProps) => {
const [selectedWagonId, setSelectedWagonId] = useState<string | null>(null);
const [removeModalOpen, setRemoveModalOpen] = useState(false);
const { toast } = useToast();
const unassignMutation = useMutation(
api.trainScheduling.unassignBooking.mutationOptions(),
@@ -54,9 +57,37 @@ export const TrainConsistView = ({
const removeWagonMutation = useMutation(
api.trainScheduling.removeWagonSlot.mutationOptions(),
);
const moveContainerMutation = useMutation(
api.trainScheduling.moveContainerItem.mutationOptions(),
);
const trainSet = scheduleDetail.trainSet;
const wagons = trainSet?.wagons ?? [];
const canRearrange = !["DISPATCHED", "ARRIVED"].includes(scheduleDetail.status);
const handleMoveContainer = async (move: ContainerMove) => {
if (moveContainerMutation.isPending) return;
try {
await moveContainerMutation.mutateAsync({
scheduleId,
itemId: move.itemId,
targetTrainSetWagonId: move.targetWagonId,
swapWithItemId: move.swapWithItemId,
});
toast({ title: move.swapWithItemId ? "Containers swapped" : "Container moved" });
} catch (error) {
const message = isAxiosError(error)
? ((error.response?.data as { message?: string | string[] } | undefined)?.message ?? null)
: null;
toast({
title: "Could not move container",
description: Array.isArray(message)
? message.join(", ")
: (message ?? "The move was rejected — check the wagon's space and load."),
variant: "destructive",
});
}
};
// Join company/customer name from schedule bookings by booking id.
const companyByBooking = useMemo(() => {
@@ -152,13 +183,21 @@ export const TrainConsistView = ({
</div>
</Group>
<Group gap="md" wrap="nowrap" visibleFrom="sm">
{canRearrange ? (
<Group gap={5} wrap="nowrap">
<Hand size={12} color="var(--mantine-color-cyan-7)" />
<Text size="xs" c="dimmed">
Drag a container to move it drop on a container to swap
</Text>
</Group>
) : null}
<LegendDot color="cyan" label="Container" />
<LegendDot color="orange" label="Bulk" />
<LegendDot color="gray" label="Empty" dashed />
</Group>
</Group>
<Box p="md">
<Box p="md" style={{ opacity: moveContainerMutation.isPending ? 0.6 : 1 }}>
<InteractiveTrainConsist
wagons={wagons}
locomotive={trainSet?.locomotive}
@@ -166,6 +205,8 @@ export const TrainConsistView = ({
selectedWagonId={selectedWagonId}
onSelectWagon={(w) => setSelectedWagonId((prev) => (prev === w.id ? null : w.id))}
highlightBookingId={highlightBookingId}
canRearrange={canRearrange && !moveContainerMutation.isPending}
onMoveContainer={(move) => void handleMoveContainer(move)}
/>
</Box>
</Paper>
@@ -178,7 +219,7 @@ export const TrainConsistView = ({
Editing wagon #{selectedWagon.sequenceNo}
</Badge>
<Text size="xs" c="dimmed">
Update container numbers or remove the booking
Update container numbers, move containers to another wagon, or remove the booking
</Text>
</Group>
<WagonCard
@@ -192,6 +233,8 @@ export const TrainConsistView = ({
scheduleStatus={scheduleDetail.status}
onRemoveBooking={handleRemoveBooking}
onRemoveWagon={handleRemoveWagon}
wagons={wagons}
onMoveContainer={canRearrange ? (move) => void handleMoveContainer(move) : undefined}
/>
</Box>
) : wagons.length ? (
@@ -209,7 +252,8 @@ export const TrainConsistView = ({
<MousePointerClick size={13} />
</ThemeIcon>
<Text size="xs" c="dimmed">
Click a wagon in the train to edit container numbers or remove its booking.
Click a wagon to edit its containers or drag a container between wagons to
rearrange the load.
</Text>
</Group>
</Paper>

View File

@@ -1,5 +1,19 @@
import { Badge, Box, Button, Card, Group, Progress, Stack, Text, ThemeIcon } from "@mantine/core";
import {
ActionIcon,
Badge,
Box,
Button,
Card,
Group,
Menu,
Progress,
Stack,
Text,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import {
ArrowLeftRight,
Building2,
Container as ContainerIcon,
Fuel,
@@ -10,6 +24,7 @@ import {
} from "lucide-react";
import type { TrainScheduleDetail } from "@/types/trainScheduling";
import { ContainerNumberInput } from "./ContainerNumberInput";
import type { ContainerMove } from "./InteractiveTrainConsist";
import { freightBrand } from "@/theme/freight-brand";
type Wagon = NonNullable<TrainScheduleDetail["trainSet"]>["wagons"][number];
@@ -21,8 +36,17 @@ interface WagonCardProps {
scheduleStatus?: string;
onRemoveBooking: (wagon: Wagon) => void;
onRemoveWagon: (wagonId: string) => void;
/** All wagons of the consist — targets for the per-container move menu. */
wagons?: Wagon[];
onMoveContainer?: (move: ContainerMove) => void;
}
const itemCountOf = (w: Wagon) =>
(w.allocations ?? []).reduce((sum, a) => sum + (a.containerItems?.length ?? 0), 0);
const isBulkWagon = (w: Wagon) =>
(w.allocations ?? []).some((a) => (a.loadType ?? "").toUpperCase().includes("BULK"));
export const WagonCard = ({
wagon,
company,
@@ -30,6 +54,8 @@ export const WagonCard = ({
scheduleStatus,
onRemoveBooking,
onRemoveWagon,
wagons,
onMoveContainer,
}: WagonCardProps) => {
const isDispatched = scheduleStatus === "DISPATCHED";
const allocation = wagon.allocations?.[0];
@@ -108,20 +134,67 @@ export const WagonCard = ({
Containers
</Text>
<Stack gap={6}>
{allocation.containerItems.map((item, idx) => (
<Group key={item.id} gap={8} wrap="nowrap">
<ContainerIcon size={13} color="var(--mantine-color-cyan-7)" />
<Text size="xs" c="dimmed">
#{idx + 1}
</Text>
<ContainerNumberInput
value={item.containerNumber ?? null}
itemId={item.id}
scheduleId={scheduleId}
disabled={isDispatched}
/>
</Group>
))}
{allocation.containerItems.map((item, idx) => {
const targets = (wagons ?? []).filter(
(w) => w.id !== wagon.id && !isBulkWagon(w) && itemCountOf(w) < 2,
);
return (
<Group key={item.id} gap={8} wrap="nowrap">
<ContainerIcon size={13} color="var(--mantine-color-cyan-7)" />
<Text size="xs" c="dimmed">
#{idx + 1}
</Text>
<ContainerNumberInput
value={item.containerNumber ?? null}
itemId={item.id}
scheduleId={scheduleId}
disabled={isDispatched}
/>
{!isDispatched && onMoveContainer ? (
<Menu shadow="md" width={220} position="bottom-end" withinPortal>
<Menu.Target>
<Tooltip label="Move to another wagon" withArrow>
<ActionIcon variant="light" color="cyan" size="sm">
<ArrowLeftRight size={13} />
</ActionIcon>
</Tooltip>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>Move to wagon</Menu.Label>
{targets.length ? (
targets.map((w) => {
const count = itemCountOf(w);
return (
<Menu.Item
key={w.id}
onClick={() =>
onMoveContainer({
itemId: item.id,
targetWagonId: w.id,
})
}
>
<Group gap={6} wrap="nowrap" justify="space-between">
<Text size="xs" fw={600}>
#{w.sequenceNo} ·{" "}
{w.physicalWagonNumber ?? w.wagonType?.code ?? "Wagon"}
</Text>
<Badge size="xs" variant="light" color={count ? "cyan" : "gray"}>
{count ? `${count}/2` : "empty"}
</Badge>
</Group>
</Menu.Item>
);
})
) : (
<Menu.Item disabled>No wagon has free space</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
) : null}
</Group>
);
})}
</Stack>
</Box>
) : null}

View File

@@ -280,3 +280,135 @@ export function RouteCorridor({
</Group>
);
}
/** Minimal booking shape the occupancy strip needs from TrainScheduleDetail. */
export type SegmentStripBooking = {
originYardId?: string | null;
destinationYardId?: string | null;
tradeDirection?: string | null;
wagonsRequired?: number | null;
};
/**
* Per-segment wagon occupancy along the corridor: which legs are full and
* which still run empty. Through cargo (unknown/off-route yards) occupies the
* whole corridor; a ride-along counts only on its own leg — this is what makes
* "export full Adama→Doraleh, intercity riding Gelan→Adama" legible at a
* glance instead of two disconnected booking lists.
*/
export function SegmentOccupancyStrip({
stops,
bookings,
maxWagons,
}: {
stops: Array<{ yardId: string; label: string }>;
bookings: SegmentStripBooking[];
maxWagons?: number | null;
}) {
if (stops.length < 2) return null;
const lastIdx = stops.length - 1;
const indexOf = new Map(stops.map((s, i) => [s.yardId, i]));
const segments = stops.slice(0, -1).map((stop, edge) => {
let cargo = 0;
let intercity = 0;
for (const b of bookings) {
const from = (b.originYardId ? indexOf.get(b.originYardId) : undefined) ?? 0;
const to =
(b.destinationYardId ? indexOf.get(b.destinationYardId) : undefined) ??
lastIdx;
const rides = from <= edge && edge < (to > from ? to : lastIdx);
if (!rides) continue;
const wagons = Number(b.wagonsRequired) || 1;
if (b.tradeDirection === "DOMESTIC") intercity += wagons;
else cargo += wagons;
}
return { from: stop, to: stops[edge + 1], cargo, intercity };
});
const cap = Number(maxWagons) || null;
return (
<Group gap={0} wrap="nowrap" align="stretch" style={{ overflowX: "auto", paddingBottom: 4 }}>
{segments.map((seg, i) => {
const used = seg.cargo + seg.intercity;
const pct = cap ? Math.min(100, Math.round((used / cap) * 100)) : null;
const full = cap != null && used >= cap;
return (
<Group key={seg.from.yardId} gap={0} wrap="nowrap" align="stretch">
<Stack gap={2} align="center" justify="flex-end" style={{ minWidth: 0 }}>
<Box
w={9}
h={9}
style={{
borderRadius: 999,
border: `2px solid ${freightBrand.primary}`,
background: i === 0 ? "white" : freightBrand.primary,
}}
/>
<Text size="xs" fw={600} style={{ whiteSpace: "nowrap" }}>
{seg.from.label}
</Text>
</Stack>
<Stack gap={3} px={10} pb={16} justify="flex-end" style={{ minWidth: 130 }}>
<Text size="xs" ta="center" fw={600} c={full ? "orange.8" : "dimmed"}>
{used}
{cap ? `/${cap}` : ""} wagons
{full ? " · full" : ""}
</Text>
<Box
style={{
height: 6,
borderRadius: 999,
background: "var(--mantine-color-gray-2)",
overflow: "hidden",
display: "flex",
}}
>
{cap ? (
<>
<Box
style={{
width: `${Math.min(100, (seg.cargo / cap) * 100)}%`,
background: freightBrand.primary,
}}
/>
<Box
style={{
width: `${Math.min(100, (seg.intercity / cap) * 100)}%`,
background: "var(--mantine-color-indigo-6)",
}}
/>
</>
) : (
<Box style={{ width: pct ? `${pct}%` : 0 }} />
)}
</Box>
<Text size="xs" ta="center" c="dimmed" style={{ whiteSpace: "nowrap" }}>
{seg.cargo} cargo
{seg.intercity > 0 ? (
<Text span size="xs" fw={700} c="indigo.7">
{" "}
· {seg.intercity} intercity
</Text>
) : null}
</Text>
</Stack>
{i === segments.length - 1 ? (
<Stack gap={2} align="center" justify="flex-end" style={{ minWidth: 0 }}>
<Box
w={9}
h={9}
style={{ borderRadius: 999, background: freightBrand.primary }}
/>
<Text size="xs" fw={600} style={{ whiteSpace: "nowrap" }}>
{seg.to.label}
</Text>
</Stack>
) : null}
</Group>
);
})}
</Group>
);
}

View File

@@ -399,6 +399,8 @@ export const URL_CONSTANTS = {
`/train-scheduling/schedules/${scheduleId}/wagons/${wagonId}`,
UPDATE_CONTAINER_ITEM: (scheduleId: string, itemId: string) =>
`/train-scheduling/schedules/${scheduleId}/container-items/${itemId}`,
MOVE_CONTAINER_ITEM: (scheduleId: string, itemId: string) =>
`/train-scheduling/schedules/${scheduleId}/container-items/${itemId}/move`,
UNASSIGNED_BOOKINGS: (scheduleId: string) =>
`/train-scheduling/schedules/${scheduleId}/unassigned-bookings`,
COMPOSITION_REMOVALS: (scheduleId: string) =>

View File

@@ -4,3 +4,17 @@ import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/** Human label for a trade direction — DOMESTIC reads "Intercity" everywhere. */
export function directionLabel(direction?: string | null): string {
switch (direction) {
case "IMPORT":
return "Import";
case "EXPORT":
return "Export";
case "DOMESTIC":
return "Intercity";
default:
return direction || "—";
}
}

View File

@@ -1,3 +1,4 @@
import { directionLabel } from "@/lib/utils";
import {
ActionIcon,
Box,
@@ -199,7 +200,7 @@ export default function ClearanceDocumentsPage() {
variant="outline"
className="h-5 border-border/50 bg-background/50 px-1.5 text-[10px] font-medium uppercase backdrop-blur-sm"
>
{c.tradeDirection}
{directionLabel(c.tradeDirection)}
</Badge>
<Badge
variant="secondary"

View File

@@ -1,3 +1,4 @@
import { directionLabel } from "@/lib/utils";
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { useLocation, useParams } from "react-router-dom";
@@ -468,7 +469,7 @@ function ClearanceHero({
color={direction === "IMPORT" ? "edr-green" : "gray"}
radius="sm"
>
{direction}
{directionLabel(direction)}
</Badge>
{customs ? (
<Badge

View File

@@ -1,3 +1,4 @@
import { directionLabel } from "@/lib/utils";
import {
Fragment,
useCallback,
@@ -180,7 +181,7 @@ function CustomsBadge({ customs }: { customs: boolean }) {
function DirectionIcon({ direction }: { direction: string }) {
const isImport = direction === "IMPORT";
const Icon = isImport ? Truck : ShipWheel;
const label = isImport ? "Import" : direction === "EXPORT" ? "Export" : "—";
const label = directionLabel(direction);
return (
<Tooltip label={label} withArrow>
<ThemeIcon

View File

@@ -1,3 +1,4 @@
import { directionLabel } from "@/lib/utils";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
@@ -597,7 +598,7 @@ export default function ContractRequestDetailPage() {
<SectionCard icon={Package} title="Cargo scope">
<Group gap="sm" mb="md">
<Badge variant="light" color="gray" radius="sm" tt="uppercase">
{contract.tradeDirection}
{directionLabel(contract.tradeDirection)}
</Badge>
<Badge variant="light" color="gray" radius="sm" tt="uppercase">
{contract.freightType}

View File

@@ -1,3 +1,4 @@
import { directionLabel } from "@/lib/utils";
import {
ActionIcon,
Box,
@@ -299,7 +300,7 @@ export default function ContractRequestsPage() {
variant="outline"
className="h-5 border-border/50 bg-background/50 px-1.5 text-[10px] font-medium uppercase backdrop-blur-sm"
>
{c.tradeDirection}
{directionLabel(c.tradeDirection)}
</Badge>
<Badge
variant="secondary"

View File

@@ -1,3 +1,4 @@
import { directionLabel } from "@/lib/utils";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useParams } from "react-router-dom";
@@ -16,6 +17,7 @@ import {
} from "@mantine/core";
import {
AlertCircle,
AlertTriangle,
ClipboardList,
FileText,
Upload,
@@ -36,6 +38,8 @@ import {
type GlClearanceUploadKind,
} from "@/components/contracts/GlClearanceUploadModal";
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { IncidentReportCard } from "@/components/contracts/gl-actions/IncidentReportCard";
import { findWorkflowFile } from "@/components/contracts/PhasedUploadedFileRow";
import { useFileViewer } from "@/hooks/useFileViewer";
import { useBookingMilestones } from "@/hooks/contracts/useContracts";
@@ -146,6 +150,9 @@ export default function GlClearanceDetailPage() {
"vesselDepartureDate" in data.clearance
? (data.clearance.vesselDepartureDate ?? null)
: null;
// Incident reporting attaches to a booking; a contract-level clearance can
// only report against its linked booking once one exists.
const incidentBookingId = data.kind === "booking" ? id : linkedBookingId;
// The shipment booking instance backing this clearance (per-booking GENERAL
// customs). Bare until GL completes it: no cargo, no price.
@@ -173,7 +180,7 @@ export default function GlClearanceDetailPage() {
]}
meta={
<Badge variant="light" color={isImport ? "edr-green" : "gray"} radius="sm">
{data.tradeDirection}
{directionLabel(data.tradeDirection)}
</Badge>
}
action={
@@ -220,6 +227,11 @@ export default function GlClearanceDetailPage() {
>
Customs documents (all steps)
</Tabs.Tab>
{incidentBookingId ? (
<Tabs.Tab value="incidents" leftSection={<AlertTriangle size={14} />}>
Incidents
</Tabs.Tab>
) : null}
</Tabs.List>
<Tabs.Panel value="workflow">
@@ -309,6 +321,19 @@ export default function GlClearanceDetailPage() {
</Box>
)}
</Tabs.Panel>
{incidentBookingId ? (
<Tabs.Panel value="incidents">
<SectionCard icon={AlertTriangle} title="Incident reports" accent="edr-green">
<Stack gap="sm">
<Text size="sm" c="dimmed">
Log container or seal issues discovered during clearance handling.
</Text>
<IncidentReportCard bookingId={incidentBookingId} />
</Stack>
</SectionCard>
</Tabs.Panel>
) : null}
</Tabs>
</Stack>

View File

@@ -1,3 +1,4 @@
import { directionLabel } from "@/lib/utils";
import { useCallback, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
@@ -249,7 +250,7 @@ function toContractRow(c: Freight.IContract): ContractRow {
function DirectionIcon({ direction }: { direction: string }) {
const isImport = direction === "IMPORT";
const Icon = isImport ? Truck : ShipWheel;
const label = isImport ? "Import" : direction === "EXPORT" ? "Export" : "—";
const label = directionLabel(direction);
return (
<Tooltip label={label} withArrow>
<ThemeIcon

View File

@@ -866,7 +866,7 @@ export default function BatchScheduleDetailPage() {
>
Refresh
</Button>
{data.train && ["DRAFT", "SCHEDULED"].includes(data.status) ? (
{/* {data.train && ["DRAFT", "SCHEDULED"].includes(data.status) ? (
<Button
variant="light"
color="edr-green"
@@ -876,7 +876,7 @@ export default function BatchScheduleDetailPage() {
>
Adjust consist
</Button>
) : null}
) : null} */}
{data.windowPhase === "DOC_REVIEW" ? (
<Button
color="yellow"

View File

@@ -59,6 +59,7 @@ import { ScheduleWorkspacePanel } from "@/components/trainScheduling/ScheduleWor
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
import {
RouteCorridor,
SegmentOccupancyStrip,
StatusPill,
scheduleBrand,
} from "@/components/trainScheduling/scheduleVisuals";
@@ -388,12 +389,22 @@ export default function TrainScheduleV2DetailPage() {
const unloadedCount = dispatchBookings.filter(
(b) => b.wagonAssigned && (b.loadingStatus ?? "UNLOADED") !== "LOADED",
).length;
// Intercity ride-alongs load through the journey flow (Load at their origin
// yard), not the workspace toggle — dispatching before that leaves paid cargo
// stranded on the platform while its train departs.
const intercityNotLoadedCount = dispatchBookings.filter(
(b) =>
b.tradeDirection === "DOMESTIC" &&
!b.loadedAt &&
!["IN_TRANSIT", "COMPLETED"].includes(b.status ?? ""),
).length;
// Import-Djibouti trains are HARD-blocked from dispatch until loading is
// confirmed in the workspace — surface it as a blocker, not just a warning.
const loadingBlocksDispatch =
schedule.requiresLoadingConfirmation === true &&
schedule.loadingConfirmed !== true;
const hasDispatchWarnings = unassignedCount > 0 || unloadedCount > 0;
const hasDispatchWarnings =
unassignedCount > 0 || unloadedCount > 0 || intercityNotLoadedCount > 0;
const finalizeStep = hasContainerStep ? 3 : 2;
const canModifyBookings = canEditBookings && !["DISPATCHED", "ARRIVED"].includes(schedule.status);
@@ -916,17 +927,28 @@ export default function TrainScheduleV2DetailPage() {
</Text>
) : null}
</Group>
<Box maw={340}>
<RouteCorridor
origin={
schedule.originStation?.label ?? schedule.originStation?.code
}
destination={
schedule.destinationStation?.label ??
schedule.destinationStation?.code
}
{(schedule.stops?.length ?? 0) >= 3 ||
(schedule.bookings ?? []).some(
(b) => b.tradeDirection === "DOMESTIC",
) ? (
<SegmentOccupancyStrip
stops={schedule.stops ?? []}
bookings={schedule.bookings ?? []}
maxWagons={schedule.maxWagons}
/>
</Box>
) : (
<Box maw={340}>
<RouteCorridor
origin={
schedule.originStation?.label ?? schedule.originStation?.code
}
destination={
schedule.destinationStation?.label ??
schedule.destinationStation?.code
}
/>
</Box>
)}
<Group gap="sm" align="center">
<FreightTypeBadge freightType={schedule.freightType} />
<StatusPill status={schedule.status} />
@@ -1059,6 +1081,14 @@ export default function TrainScheduleV2DetailPage() {
{
label: "Bookings",
value: schedule.bookings?.length ?? 0,
hint: (() => {
const intercity = (schedule.bookings ?? []).filter(
(b) => b.tradeDirection === "DOMESTIC",
).length;
return intercity > 0
? `${intercity} intercity ride-along${intercity === 1 ? "" : "s"}`
: undefined;
})(),
icon: Package,
},
{
@@ -1282,6 +1312,16 @@ export default function TrainScheduleV2DetailPage() {
unloaded
</List.Item>
) : null}
{intercityNotLoadedCount > 0 ? (
<List.Item>
<Text span fw={700}>
{intercityNotLoadedCount}
</Text>{" "}
intercity ride-along{intercityNotLoadedCount === 1 ? "" : "s"} not
loaded yet load them from the Workspace tab (Yard work) before
the train leaves their origin yard
</List.Item>
) : null}
</List>
<Text size="xs" c="dimmed" mt={6}>
You can still dispatch confirm to proceed.

View File

@@ -814,6 +814,26 @@ export const api = {
undefined,
() => [QUERY_KEYS.TRAIN_SCHEDULING.ROOT],
),
moveContainerItem: endpoint<
{
scheduleId: string;
itemId: string;
targetTrainSetWagonId: string;
swapWithItemId?: string;
},
TrainScheduleDetail
>(
"train-scheduling",
"move-container-item",
({ scheduleId, itemId, targetTrainSetWagonId, swapWithItemId }) =>
trainSchedulingService.moveContainerItem(scheduleId, itemId, {
targetTrainSetWagonId,
swapWithItemId,
}),
undefined,
() => [QUERY_KEYS.TRAIN_SCHEDULING.ROOT],
),
},
warehouses: {

View File

@@ -757,6 +757,18 @@ export const trainSchedulingService = {
return unwrap(response.data);
},
moveContainerItem: async (
scheduleId: string,
itemId: string,
payload: { targetTrainSetWagonId: string; swapWithItemId?: string },
): Promise<TrainScheduleDetail> => {
const response = await client.post<TrainScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.MOVE_CONTAINER_ITEM(scheduleId, itemId),
payload,
);
return unwrap(response.data);
},
getUnassignedBookings: async (
scheduleId: string,
): Promise<UnassignedBookingsResponse> => {

View File

@@ -626,9 +626,20 @@ export interface TrainScheduleDetail {
status: string | null;
schedulingStatus?: SchedulingStatus | null;
freightType?: FreightType | string | null;
/** DOMESTIC = intercity ride-along; rides only its own leg below. */
tradeDirection?: string | null;
originYardId?: string | null;
destinationYardId?: string | null;
origin?: string | null;
destination?: string | null;
wagonsRequired?: number | null;
loadedAt?: string | null;
arrivedAt?: string | null;
loadingStatus?: "LOADED" | "UNLOADED";
wagonAssigned?: boolean;
}>;
/** Ordered corridor stops (route milestones) — for per-segment occupancy. */
stops?: Array<{ yardId: string; label: string }>;
warnings?: string[];
}