mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Export bookings whose cargo goes straight from customer truck to wagon never get a warehouse GRN, so the load gate (assertExportReceivedWithGrn) rejects them forever unless staff first flip exportHandoverMode on the booking detail page, then come back and retry Load. Add a Truck to Train button next to Load in the schedule workspace's on-train list, shown for EXPORT bookings at the boarding yard. One click sets exportHandoverMode=DIRECT_TO_TRAIN then loads the booking.
1159 lines
41 KiB
TypeScript
1159 lines
41 KiB
TypeScript
import { useMemo, useState } from "react";
|
|
import { isAxiosError } from "axios";
|
|
import {
|
|
Badge,
|
|
Box,
|
|
Button,
|
|
Group,
|
|
Modal,
|
|
Paper,
|
|
Progress,
|
|
ScrollArea,
|
|
Select,
|
|
Stack,
|
|
Text,
|
|
ThemeIcon,
|
|
Tooltip,
|
|
} from "@mantine/core";
|
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
import {
|
|
AlertTriangle,
|
|
ArrowLeftRight,
|
|
ArrowRight,
|
|
CheckCircle2,
|
|
Inbox,
|
|
Landmark,
|
|
MapPin,
|
|
PackageCheck,
|
|
PackageOpen,
|
|
// Repeat, // used by the hidden Move (reassign) button
|
|
Train,
|
|
TrainFront,
|
|
Truck,
|
|
Weight,
|
|
X,
|
|
} from "lucide-react";
|
|
|
|
import { CountdownTimer } from "@edr/ui-common";
|
|
|
|
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
|
import { api } from "@/services/api";
|
|
import { bookingsService } from "@/services/bookings.service";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import type {
|
|
EligibleContainerBooking,
|
|
FreightType,
|
|
TrainScheduleDetail,
|
|
YardWorkBookingRow,
|
|
} from "@/types/trainScheduling";
|
|
|
|
interface ScheduleWorkspacePanelProps {
|
|
schedule: TrainScheduleDetail;
|
|
/** Refetch the schedule detail after a mutation so both panels refresh. */
|
|
onChanged: () => void;
|
|
}
|
|
|
|
const GREEN = "var(--mantine-color-edr-green-6)";
|
|
|
|
/** Pull the API's violation detail out of an error (e.g. "No CW3 wagon available…"). */
|
|
function apiErrorMessage(error: unknown, fallback: string): string {
|
|
if (isAxiosError(error)) {
|
|
const data = error.response?.data as Record<string, unknown> | undefined;
|
|
const violations = data?.violations;
|
|
if (Array.isArray(violations) && violations.length) return violations.join(", ");
|
|
if (typeof data?.message === "string") return data.message;
|
|
if (Array.isArray(data?.message)) return (data.message as string[]).join(", ");
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
/**
|
|
* Deadline + label for the window phase this schedule is currently in.
|
|
* Phases run: window open (windowClosesAt) → document review (docReviewEndsAt)
|
|
* → payment (paymentPhaseEndsAt). Display only. Returns null off-phase.
|
|
*/
|
|
function phaseCountdown(
|
|
schedule: TrainScheduleDetail,
|
|
): { label: string; deadline: string; expiredText: string } | null {
|
|
switch (schedule.windowPhase) {
|
|
case "PRE_WINDOW":
|
|
return schedule.windowOpensAt
|
|
? {
|
|
label: "Booking window opens in",
|
|
deadline: schedule.windowOpensAt,
|
|
expiredText: "Booking opening now…",
|
|
}
|
|
: null;
|
|
case "OPEN":
|
|
return schedule.windowClosesAt
|
|
? {
|
|
label: "Booking window closes in",
|
|
deadline: schedule.windowClosesAt,
|
|
expiredText: "Document review starting…",
|
|
}
|
|
: null;
|
|
case "DOC_REVIEW":
|
|
return schedule.docReviewEndsAt
|
|
? {
|
|
label: "Document review ends in",
|
|
deadline: schedule.docReviewEndsAt,
|
|
expiredText: "Payment starting…",
|
|
}
|
|
: null;
|
|
case "PAYMENT":
|
|
return schedule.paymentPhaseEndsAt
|
|
? {
|
|
label: "Payment window ends in",
|
|
deadline: schedule.paymentPhaseEndsAt,
|
|
expiredText: "Payment window closing…",
|
|
}
|
|
: null;
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* GROSS weight the locomotives actually haul: the HEAVIEST LEG, never the
|
|
* whole-route sum — disjoint legs (Mojo→Dire + Dire→Doraleh) are pulled one
|
|
* at a time, so summing every booking over-reports a multi-stop train.
|
|
* Prefers the API's consist-derived heaviestLeg; before allocation it falls
|
|
* back to a per-leg max over the bookings (same span math as the header strip).
|
|
*/
|
|
function usedWeight(schedule: TrainScheduleDetail): number {
|
|
const consist = schedule.trainSet?.heaviestLeg?.grossWeightTons;
|
|
if (consist != null) return Number(consist) || 0;
|
|
|
|
const bookings = schedule.bookings ?? [];
|
|
const stops = schedule.stops ?? [];
|
|
if (stops.length <= 2) {
|
|
return bookings.reduce((sum, b) => sum + (Number(b.weightTons) || 0), 0);
|
|
}
|
|
const indexOf = new Map(stops.map((s, i) => [s.yardId, i]));
|
|
const lastIdx = stops.length - 1;
|
|
let heaviest = 0;
|
|
for (let edge = 0; edge < lastIdx; edge += 1) {
|
|
let legTons = 0;
|
|
for (const b of bookings) {
|
|
const from = (b.originYardId ? indexOf.get(b.originYardId) : undefined) ?? 0;
|
|
const toRaw = b.destinationYardId ? indexOf.get(b.destinationYardId) : lastIdx;
|
|
const to = toRaw != null && toRaw > from ? toRaw : lastIdx;
|
|
if (from <= edge && edge < to) legTons += Number(b.weightTons) || 0;
|
|
}
|
|
heaviest = Math.max(heaviest, legTons);
|
|
}
|
|
return heaviest;
|
|
}
|
|
|
|
/**
|
|
* Pull capacity of the set. Locomotive pull weights ADD UP (they haul
|
|
* together), so prefer the API's maxGrossWeightTons — the combined set limit
|
|
* incl. overage tolerance, the same ceiling the validator holds each leg to —
|
|
* and fall back to summing the locos' own limits.
|
|
*/
|
|
function pullCapacity(schedule: TrainScheduleDetail): number {
|
|
if (schedule.maxGrossWeightTons != null) return Number(schedule.maxGrossWeightTons) || 0;
|
|
const set = schedule.trainSet;
|
|
if (!set) return 0;
|
|
const locos =
|
|
set.locomotives && set.locomotives.length > 0
|
|
? set.locomotives
|
|
: set.locomotive
|
|
? [set.locomotive]
|
|
: [];
|
|
return locos.reduce((sum, l) => sum + (Number(l.maxPullWeightTons) || 0), 0);
|
|
}
|
|
|
|
export function ScheduleWorkspacePanel({
|
|
schedule,
|
|
onChanged,
|
|
}: ScheduleWorkspacePanelProps) {
|
|
const { toast } = useToast();
|
|
|
|
const freightType: FreightType | undefined =
|
|
schedule.freightType === "CONTAINER" || schedule.freightType === "BULK"
|
|
? schedule.freightType
|
|
: undefined;
|
|
|
|
const locked = ["DISPATCHED", "ARRIVED"].includes(schedule.status);
|
|
const canManage = ["DRAFT", "SCHEDULED"].includes(schedule.status);
|
|
// Loading follows the train: it keeps working AFTER dispatch, per yard, as
|
|
// checkpoints are logged — only add/remove is closed once the train rolls.
|
|
const canWork = ["DRAFT", "SCHEDULED", "DISPATCHED"].includes(schedule.status);
|
|
|
|
// Pool = accepted, ready-to-pay bookings on THIS train's route+day that are not
|
|
// yet linked to any schedule (same filter the auto-batch uses).
|
|
const poolQuery = useQuery(
|
|
api.trainScheduling.eligibleBookings.queryOptions({
|
|
input: {
|
|
filters: {
|
|
originStationId: schedule.originStation?.id,
|
|
destinationStationId: schedule.destinationStation?.id,
|
|
trainScheduleId: schedule.id,
|
|
},
|
|
freightType,
|
|
},
|
|
enabled: Boolean(schedule.originStation?.id && schedule.destinationStation?.id),
|
|
}),
|
|
);
|
|
|
|
const onTrainIds = useMemo(
|
|
() => new Set((schedule.bookings ?? []).map((b) => b.id)),
|
|
[schedule.bookings],
|
|
);
|
|
|
|
const pool: EligibleContainerBooking[] = useMemo(
|
|
() => (poolQuery.data?.items ?? []).filter((b) => !onTrainIds.has(b.id)),
|
|
[poolQuery.data, onTrainIds],
|
|
);
|
|
|
|
const onTrain = schedule.bookings ?? [];
|
|
|
|
// ── Corridor position: which yard the train currently stands at ───────────
|
|
// The journey worklist knows the train's latest checkpoint AND per-booking
|
|
// load/unload eligibility — the same server rules that gate the mutations.
|
|
const yardWorkQuery = useQuery(
|
|
api.trainScheduling.yardWork.queryOptions({
|
|
input: { scheduleId: schedule.id },
|
|
refetchInterval: 60_000,
|
|
}),
|
|
);
|
|
const trainAtYardId = yardWorkQuery.data?.trainAtYardId ?? null;
|
|
const journeyById = useMemo(() => {
|
|
const map = new Map<string, YardWorkBookingRow>();
|
|
for (const yard of yardWorkQuery.data?.yards ?? []) {
|
|
for (const row of [...yard.toLoad, ...yard.toUnload]) map.set(row.id, row);
|
|
}
|
|
return map;
|
|
}, [yardWorkQuery.data]);
|
|
|
|
// Ordered corridor (origin → stops → destination). Falls back to the two
|
|
// endpoints when the schedule has no stops recorded.
|
|
const stations = useMemo(() => {
|
|
const stops = schedule.stops ?? [];
|
|
if (stops.length) return stops;
|
|
return [
|
|
{ yardId: schedule.originStation?.id ?? "origin", label: schedule.originStation?.label ?? "Origin" },
|
|
{
|
|
yardId: schedule.destinationStation?.id ?? "destination",
|
|
label: schedule.destinationStation?.label ?? "Destination",
|
|
},
|
|
];
|
|
}, [schedule.stops, schedule.originStation, schedule.destinationStation]);
|
|
const stationIdx = useMemo(
|
|
() => new Map(stations.map((s, i) => [s.yardId, i])),
|
|
[stations],
|
|
);
|
|
const trainIdx = trainAtYardId != null ? (stationIdx.get(trainAtYardId) ?? null) : null;
|
|
const trainAtLabel =
|
|
trainIdx != null ? stations[trainIdx]?.label : null;
|
|
|
|
// On-train bookings grouped by BOARDING yard, in corridor order. A booking
|
|
// whose origin is off this corridor (through cargo on legacy data) groups
|
|
// under the train's own origin.
|
|
const corridorGroups = useMemo(() => {
|
|
const groups = new Map<string, { yardId: string; label: string; rows: typeof onTrain }>();
|
|
for (const b of onTrain) {
|
|
const yardId =
|
|
b.originYardId && stationIdx.has(b.originYardId)
|
|
? b.originYardId
|
|
: (stations[0]?.yardId ?? "origin");
|
|
let group = groups.get(yardId);
|
|
if (!group) {
|
|
group = {
|
|
yardId,
|
|
label:
|
|
stations[stationIdx.get(yardId) ?? 0]?.label ?? b.origin ?? "Origin",
|
|
rows: [],
|
|
};
|
|
groups.set(yardId, group);
|
|
}
|
|
group.rows.push(b);
|
|
}
|
|
return [...groups.values()].sort(
|
|
(a, b) => (stationIdx.get(a.yardId) ?? 0) - (stationIdx.get(b.yardId) ?? 0),
|
|
);
|
|
}, [onTrain, stationIdx, stations]);
|
|
|
|
// ── Mutations (reuse the existing endpoints) ───────────────────────────────
|
|
const assign = useMutation(api.trainScheduling.assignBookings.mutationOptions());
|
|
const assignUnassigned = useMutation(
|
|
api.trainScheduling.assignUnassignedBooking.mutationOptions(),
|
|
);
|
|
const unassign = useMutation(api.trainScheduling.unassignBooking.mutationOptions());
|
|
const loadJourney = useMutation(
|
|
api.trainScheduling.loadScheduleBooking.mutationOptions(),
|
|
);
|
|
const unloadJourney = useMutation(
|
|
api.trainScheduling.unloadScheduleBooking.mutationOptions(),
|
|
);
|
|
const moveSchedule = useMutation(
|
|
api.trainScheduling.moveBookingSchedule.mutationOptions(),
|
|
);
|
|
|
|
const [moveBookingId, setMoveBookingId] = useState<string | null>(null);
|
|
const [moveTarget, setMoveTarget] = useState<string | null>(null);
|
|
|
|
// Pool → pick a same-day schedule with free wagons and place the booking there.
|
|
const [poolAssign, setPoolAssign] = useState<{ id: string; reference: string } | null>(
|
|
null,
|
|
);
|
|
const [poolTarget, setPoolTarget] = useState<string | null>(null);
|
|
|
|
const { data: targets } = useQuery(
|
|
api.trainScheduling.bookableSchedules.queryOptions({
|
|
input: {
|
|
originYardId: schedule.originStation?.id,
|
|
destinationYardId: schedule.destinationStation?.id,
|
|
},
|
|
enabled: Boolean(
|
|
schedule.originStation?.id && schedule.destinationStation?.id,
|
|
),
|
|
}),
|
|
);
|
|
const moveOptions = useMemo(
|
|
() =>
|
|
(targets ?? [])
|
|
.filter((s) => s.id !== schedule.id)
|
|
.map((s) => ({
|
|
value: s.id,
|
|
label: `${s.routeName ?? `${s.origin} → ${s.destination}`} · ${new Date(
|
|
s.scheduleDate,
|
|
).toLocaleString()} · ${s.remainingWagons}/${s.maxWagons} free`,
|
|
})),
|
|
[targets, schedule.id],
|
|
);
|
|
|
|
// Every schedule departing on THIS train's day (EAT) — a paid booking waiting
|
|
// for a wagon may board any of them, so staff pick whichever has wagons free.
|
|
const eatDayOf = (iso: string) =>
|
|
new Date(iso).toLocaleDateString("en-CA", { timeZone: "Africa/Addis_Ababa" });
|
|
const sameDayOptions = useMemo(() => {
|
|
const day = eatDayOf(schedule.scheduledDepartureDate);
|
|
return (targets ?? [])
|
|
.filter((s) => eatDayOf(s.scheduleDate) === day)
|
|
.map((s) => ({
|
|
value: s.id,
|
|
label: `${s.id === schedule.id ? "This train · " : ""}${
|
|
s.routeName ?? `${s.origin} → ${s.destination}`
|
|
} · ${s.remainingWagons}/${s.maxWagons} wagons free`,
|
|
}));
|
|
}, [targets, schedule.id, schedule.scheduledDepartureDate]);
|
|
|
|
// ── Capacity meter (by cargo weight vs locomotive pull) ────────────────────
|
|
const used = usedWeight(schedule);
|
|
const capacity = pullCapacity(schedule);
|
|
const pct = capacity > 0 ? Math.min(100, Math.round((used / capacity) * 100)) : 0;
|
|
const over = capacity > 0 && used > capacity;
|
|
|
|
const forceAdd = (bookingId: string, ref: string, weightTons: number) => {
|
|
const wouldOverfill = capacity > 0 && used + (weightTons || 0) > capacity;
|
|
assign
|
|
.mutateAsync({
|
|
id: schedule.id,
|
|
freightType,
|
|
payload: {
|
|
bookingIds: [...onTrainIds, bookingId],
|
|
forceAssign: true,
|
|
},
|
|
})
|
|
.then(() => {
|
|
toast({
|
|
title: `${ref} added to train`,
|
|
description: wouldOverfill
|
|
? "Force-added past the pull-weight limit — review capacity."
|
|
: "Wagons auto-pinned.",
|
|
variant: wouldOverfill ? "destructive" : undefined,
|
|
});
|
|
onChanged();
|
|
void poolQuery.refetch();
|
|
})
|
|
.catch((error) =>
|
|
toast({
|
|
title: "Could not add booking",
|
|
description: apiErrorMessage(error, "Validation failed — check capacity and status."),
|
|
variant: "destructive",
|
|
}),
|
|
);
|
|
};
|
|
|
|
const removeFromTrain = (bookingId: string, ref: string) => {
|
|
unassign
|
|
.mutateAsync({ id: schedule.id, bookingId })
|
|
.then(() => {
|
|
toast({ title: `${ref} removed from train` });
|
|
onChanged();
|
|
void poolQuery.refetch();
|
|
})
|
|
.catch((error) =>
|
|
toast({
|
|
title: "Could not remove booking",
|
|
description: apiErrorMessage(error, "Please try again."),
|
|
variant: "destructive",
|
|
}),
|
|
);
|
|
};
|
|
|
|
// Journey load/unload — the server checks the train's recorded position, so
|
|
// a stale UI can never load cargo at the wrong yard.
|
|
const doLoad = (bookingId: string, ref: string) => {
|
|
loadJourney
|
|
.mutateAsync({ scheduleId: schedule.id, bookingId })
|
|
.then(() => {
|
|
toast({ title: `${ref} loaded onto the train` });
|
|
onChanged();
|
|
void yardWorkQuery.refetch();
|
|
})
|
|
.catch((error) =>
|
|
toast({
|
|
title: "Could not load cargo",
|
|
description: apiErrorMessage(error, "Train may not be at the boarding yard."),
|
|
variant: "destructive",
|
|
}),
|
|
);
|
|
};
|
|
|
|
// Export cargo that skipped the warehouse (customer truck straight onto the
|
|
// wagon) has no GRN and never will — loadBooking's GRN gate would keep
|
|
// rejecting it forever. Setting DIRECT_TO_TRAIN tells that gate the carriage
|
|
// acceptance sheet is the handover document instead, then loads in one click.
|
|
const [truckToTrainPending, setTruckToTrainPending] = useState<string | null>(null);
|
|
const doTruckToTrain = (bookingId: string, ref: string) => {
|
|
setTruckToTrainPending(bookingId);
|
|
bookingsService
|
|
.setExportHandoverMode(bookingId, "DIRECT_TO_TRAIN")
|
|
.then(() => loadJourney.mutateAsync({ scheduleId: schedule.id, bookingId }))
|
|
.then(() => {
|
|
toast({ title: `${ref} loaded — direct truck-to-train handover` });
|
|
onChanged();
|
|
void yardWorkQuery.refetch();
|
|
})
|
|
.catch((error) =>
|
|
toast({
|
|
title: "Could not load as direct truck-to-train",
|
|
description: apiErrorMessage(error, "Please try again."),
|
|
variant: "destructive",
|
|
}),
|
|
)
|
|
.finally(() => setTruckToTrainPending(null));
|
|
};
|
|
|
|
const doUnload = (bookingId: string, ref: string) => {
|
|
unloadJourney
|
|
.mutateAsync({ scheduleId: schedule.id, bookingId })
|
|
.then((result) => {
|
|
toast({
|
|
title:
|
|
result.status === "COMPLETED"
|
|
? `${ref} unloaded — booking completed`
|
|
: `${ref} unloaded — booking arrived`,
|
|
});
|
|
onChanged();
|
|
void yardWorkQuery.refetch();
|
|
})
|
|
.catch((error) =>
|
|
toast({
|
|
title: "Could not unload cargo",
|
|
description: apiErrorMessage(error, "Train may not be at the destination yard."),
|
|
variant: "destructive",
|
|
}),
|
|
);
|
|
};
|
|
|
|
// Point the pool booking at the chosen same-day train, then put it on wagons.
|
|
// If the wagon step fails (that train is short too) the booking stays paid &
|
|
// unassigned in the pool — nothing is lost, staff just pick another train.
|
|
const doPoolAssign = () => {
|
|
if (!poolAssign || !poolTarget) return;
|
|
const { id: bookingId, reference } = poolAssign;
|
|
moveSchedule
|
|
.mutateAsync({ bookingId, trainScheduleId: poolTarget })
|
|
.then(() => assignUnassigned.mutateAsync({ id: poolTarget, bookingId }))
|
|
.then(() => {
|
|
toast({
|
|
title: `${reference} assigned`,
|
|
description: "Booking placed on the selected train with wagons pinned.",
|
|
});
|
|
setPoolAssign(null);
|
|
onChanged();
|
|
void poolQuery.refetch();
|
|
})
|
|
.catch((error) =>
|
|
toast({
|
|
title: `Could not assign ${reference}`,
|
|
description: apiErrorMessage(
|
|
error,
|
|
"The selected train has no free wagon of the required type.",
|
|
),
|
|
variant: "destructive",
|
|
}),
|
|
);
|
|
};
|
|
|
|
const doMove = () => {
|
|
if (!moveBookingId || !moveTarget) return;
|
|
moveSchedule
|
|
.mutateAsync({ bookingId: moveBookingId, trainScheduleId: moveTarget })
|
|
.then(() => {
|
|
toast({ title: "Booking reassigned to another train" });
|
|
setMoveBookingId(null);
|
|
onChanged();
|
|
void poolQuery.refetch();
|
|
})
|
|
.catch((error) =>
|
|
toast({
|
|
title: "Could not reassign booking",
|
|
description: apiErrorMessage(error, "Target train may be closed or full."),
|
|
variant: "destructive",
|
|
}),
|
|
);
|
|
};
|
|
|
|
return (
|
|
<Paper radius="xl" p="lg" withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
|
|
<Stack gap="lg">
|
|
{/* Header + capacity meter */}
|
|
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
|
<Group gap="sm" align="center" wrap="nowrap">
|
|
<ThemeIcon size={40} radius="md" variant="light" color="edr-green">
|
|
<PackageCheck size={20} />
|
|
</ThemeIcon>
|
|
<div>
|
|
<Text fw={700}>Allocation workspace</Text>
|
|
<Text size="xs" c="dimmed">
|
|
Add or remove bookings, then load each one when the train is at
|
|
its boarding yard
|
|
</Text>
|
|
</div>
|
|
</Group>
|
|
|
|
<Box miw={240} style={{ flex: "0 1 320px" }}>
|
|
<Group justify="space-between" mb={4} gap={4}>
|
|
<Group gap={6} align="center">
|
|
<Weight size={14} color={over ? "#B42318" : undefined} />
|
|
<Text size="xs" fw={600} c={over ? "red" : "dimmed"}>
|
|
Load {used.toFixed(1)}T
|
|
{capacity > 0 ? ` / ${capacity.toFixed(0)}T pull` : ""}
|
|
{(schedule.stops?.length ?? 0) > 2 ? " · heaviest leg" : ""}
|
|
</Text>
|
|
</Group>
|
|
{over ? (
|
|
<Badge color="red" variant="light" size="sm" radius="sm">
|
|
Over capacity
|
|
</Badge>
|
|
) : (
|
|
<Text size="xs" c="dimmed">
|
|
{capacity > 0 ? `${pct}%` : "—"}
|
|
</Text>
|
|
)}
|
|
</Group>
|
|
<Progress
|
|
value={capacity > 0 ? pct : 0}
|
|
color={over ? "red" : pct > 85 ? "orange" : "edr-green"}
|
|
radius="xl"
|
|
size="md"
|
|
/>
|
|
</Box>
|
|
</Group>
|
|
|
|
{(() => {
|
|
const cd = phaseCountdown(schedule);
|
|
return cd ? (
|
|
<Group
|
|
gap={8}
|
|
p="xs"
|
|
wrap="nowrap"
|
|
align="center"
|
|
style={{
|
|
borderRadius: 10,
|
|
background: "var(--mantine-color-blue-0)",
|
|
border: "1px solid var(--mantine-color-blue-2)",
|
|
}}
|
|
>
|
|
<CountdownTimer
|
|
deadline={cd.deadline}
|
|
label={cd.label}
|
|
expiredText={cd.expiredText}
|
|
size="sm"
|
|
/>
|
|
</Group>
|
|
) : null;
|
|
})()}
|
|
|
|
{over ? (
|
|
<Group
|
|
gap={8}
|
|
p="xs"
|
|
wrap="nowrap"
|
|
align="center"
|
|
style={{
|
|
borderRadius: 10,
|
|
background: "var(--mantine-color-red-0)",
|
|
border: "1px solid var(--mantine-color-red-2)",
|
|
}}
|
|
>
|
|
<AlertTriangle size={16} color="#B42318" />
|
|
<Text size="xs" c="red.8" fw={500}>
|
|
This train is loaded beyond its locomotive pull weight. Force-adds are
|
|
allowed, but review before dispatch.
|
|
</Text>
|
|
</Group>
|
|
) : null}
|
|
|
|
{locked ? (
|
|
<Text size="sm" c="dimmed">
|
|
This train is {schedule.status.toLowerCase()} — bookings can no longer be
|
|
added or removed.
|
|
{schedule.status === "DISPATCHED"
|
|
? " Loading continues per yard as checkpoints are logged on the track page."
|
|
: ""}
|
|
</Text>
|
|
) : null}
|
|
|
|
{/* Loading confirmation gate removed: bookings can board mid-corridor,
|
|
so per-yard loading happens from the track page's log-pass flow. */}
|
|
|
|
{/* Two-panel board */}
|
|
<Group align="stretch" gap="lg" grow wrap="wrap">
|
|
{/* Pool */}
|
|
<PanelColumn
|
|
title="Paid · unassigned"
|
|
hint="Paid · this route & day · not on a train"
|
|
count={pool.length}
|
|
accent="#F2A516"
|
|
loading={poolQuery.isLoading}
|
|
emptyIcon={Inbox}
|
|
emptyText="No paid, unassigned bookings waiting for this train."
|
|
>
|
|
{pool.map((b) => (
|
|
<BookingCard
|
|
key={b.id}
|
|
reference={b.reference}
|
|
customer={b.customer}
|
|
weightTons={b.weightTons}
|
|
status={b.status}
|
|
waitingForWagon={b.schedulingStatus === "WAITING_FOR_WAGON"}
|
|
government={b.isGovernment}
|
|
leg={
|
|
b.origin && b.destination ? `${b.origin} → ${b.destination}` : null
|
|
}
|
|
right={
|
|
canManage ? (
|
|
<Group gap={6} wrap="nowrap" justify="flex-end">
|
|
<Tooltip label="Force-add to this train" withArrow>
|
|
<Button
|
|
size="compact-sm"
|
|
color="edr-green"
|
|
radius="md"
|
|
rightSection={<ArrowRight size={14} />}
|
|
loading={assign.isPending}
|
|
onClick={() => forceAdd(b.id, b.reference, b.weightTons)}
|
|
>
|
|
Add
|
|
</Button>
|
|
</Tooltip>
|
|
<Tooltip
|
|
label="Pick any train departing this day that has wagons free"
|
|
withArrow
|
|
>
|
|
<Button
|
|
size="compact-sm"
|
|
variant="light"
|
|
color="edr-green"
|
|
radius="md"
|
|
leftSection={<ArrowLeftRight size={13} />}
|
|
onClick={() => {
|
|
setPoolAssign({ id: b.id, reference: b.reference });
|
|
setPoolTarget(null);
|
|
}}
|
|
>
|
|
Add to…
|
|
</Button>
|
|
</Tooltip>
|
|
</Group>
|
|
) : null
|
|
}
|
|
/>
|
|
))}
|
|
</PanelColumn>
|
|
|
|
{/* On train — grouped by boarding yard, walked in corridor order.
|
|
Load is only offered where the train actually stands; the journey
|
|
endpoints re-validate the position server-side. */}
|
|
<PanelColumn
|
|
title="On this train"
|
|
hint={
|
|
trainAtLabel ? `Train at ${trainAtLabel}` : "Grouped by boarding yard"
|
|
}
|
|
count={onTrain.length}
|
|
accent="#0EA371"
|
|
emptyIcon={Train}
|
|
emptyText="No bookings allocated yet. Add one from the pool."
|
|
>
|
|
{corridorGroups.map((group) => {
|
|
const groupIdx = stationIdx.get(group.yardId) ?? 0;
|
|
const trainHere = trainAtYardId === group.yardId;
|
|
const passed = trainIdx != null && groupIdx < trainIdx;
|
|
return (
|
|
<Stack key={group.yardId} gap={6}>
|
|
<Group gap={8} align="center" mt={4}>
|
|
<MapPin size={13} color="var(--mantine-color-gray-6)" />
|
|
<Text size="xs" fw={700}>
|
|
{group.label}
|
|
</Text>
|
|
{trainHere ? (
|
|
<Badge
|
|
size="sm"
|
|
radius="sm"
|
|
variant="filled"
|
|
color="edr-green"
|
|
leftSection={<TrainFront size={11} />}
|
|
>
|
|
Train here
|
|
</Badge>
|
|
) : passed ? (
|
|
<Badge size="sm" radius="sm" variant="light" color="gray">
|
|
Passed
|
|
</Badge>
|
|
) : (
|
|
<Badge size="sm" radius="sm" variant="outline" color="gray">
|
|
Ahead
|
|
</Badge>
|
|
)}
|
|
<Badge size="sm" radius="sm" variant="light" color="gray">
|
|
{group.rows.length}
|
|
</Badge>
|
|
</Group>
|
|
{group.rows.map((b) => {
|
|
const ref = b.reference ?? b.id.slice(0, 8);
|
|
const journey = journeyById.get(b.id);
|
|
const riding = b.status === "IN_TRANSIT";
|
|
const done = ["ARRIVED", "COMPLETED", "DELIVERED"].includes(
|
|
b.status ?? "",
|
|
);
|
|
const boardHere = trainHere;
|
|
const alightHere = trainAtYardId != null && b.destinationYardId === trainAtYardId;
|
|
const showLoad = canWork && !riding && !done && (journey?.canLoad ?? false);
|
|
const showUnload = canWork && riding && (journey?.canUnload ?? false);
|
|
const showTruckToTrain =
|
|
canWork &&
|
|
!riding &&
|
|
!done &&
|
|
boardHere &&
|
|
b.tradeDirection === "EXPORT";
|
|
return (
|
|
<BookingCard
|
|
key={b.id}
|
|
reference={ref}
|
|
customer={b.customer}
|
|
weightTons={b.weightTons}
|
|
status={b.status}
|
|
government={journey?.isGovernment ?? false}
|
|
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={
|
|
riding || Boolean(b.loadedAt)
|
|
? "LOADED"
|
|
: b.wagonAssigned
|
|
? (b.loadingStatus ?? "UNLOADED")
|
|
: undefined
|
|
}
|
|
right={
|
|
<Group gap={6} wrap="nowrap" justify="flex-end">
|
|
{showLoad ? (
|
|
<Tooltip
|
|
label={
|
|
boardHere
|
|
? `Load cargo onto the train at ${group.label}`
|
|
: passed
|
|
? `Train already passed ${group.label} — this cargo missed its stop`
|
|
: `Loads at ${group.label} — train is ${
|
|
trainAtLabel ? `at ${trainAtLabel}` : "not there yet"
|
|
}`
|
|
}
|
|
withArrow
|
|
>
|
|
<Button
|
|
size="compact-sm"
|
|
variant="filled"
|
|
color="edr-green"
|
|
radius="md"
|
|
disabled={!boardHere}
|
|
leftSection={<PackageCheck size={13} />}
|
|
loading={
|
|
loadJourney.isPending &&
|
|
loadJourney.variables?.bookingId === b.id
|
|
}
|
|
onClick={() => doLoad(b.id, ref)}
|
|
>
|
|
Load
|
|
</Button>
|
|
</Tooltip>
|
|
) : null}
|
|
{showTruckToTrain ? (
|
|
<Tooltip
|
|
label="Customer truck loaded straight onto the wagon — no warehouse receipt, no GRN. Sets direct truck-to-train handover and loads."
|
|
withArrow
|
|
>
|
|
<Button
|
|
size="compact-sm"
|
|
variant="light"
|
|
color="blue"
|
|
radius="md"
|
|
leftSection={<Truck size={13} />}
|
|
loading={truckToTrainPending === b.id}
|
|
onClick={() => doTruckToTrain(b.id, ref)}
|
|
>
|
|
Truck to Train
|
|
</Button>
|
|
</Tooltip>
|
|
) : null}
|
|
{showUnload ? (
|
|
<Tooltip
|
|
label={
|
|
alightHere
|
|
? "Unload at this yard — stamps the booking's arrival"
|
|
: "Unloads when the train reaches its destination yard"
|
|
}
|
|
withArrow
|
|
>
|
|
<Button
|
|
size="compact-sm"
|
|
variant="light"
|
|
color="orange"
|
|
radius="md"
|
|
disabled={!alightHere}
|
|
leftSection={<PackageOpen size={13} />}
|
|
loading={
|
|
unloadJourney.isPending &&
|
|
unloadJourney.variables?.bookingId === b.id
|
|
}
|
|
onClick={() => doUnload(b.id, ref)}
|
|
>
|
|
Unload
|
|
</Button>
|
|
</Tooltip>
|
|
) : null}
|
|
{canManage && !riding && !done ? (
|
|
journey?.isGovernment ? null : (
|
|
<Tooltip label="Remove from this train" withArrow>
|
|
<Button
|
|
size="compact-sm"
|
|
variant="light"
|
|
color="red"
|
|
radius="md"
|
|
leftSection={<X size={13} />}
|
|
loading={
|
|
unassign.isPending &&
|
|
unassign.variables?.bookingId === b.id
|
|
}
|
|
onClick={() => removeFromTrain(b.id, ref)}
|
|
>
|
|
Remove
|
|
</Button>
|
|
</Tooltip>
|
|
)
|
|
) : null}
|
|
</Group>
|
|
}
|
|
/>
|
|
);
|
|
})}
|
|
</Stack>
|
|
);
|
|
})}
|
|
</PanelColumn>
|
|
</Group>
|
|
</Stack>
|
|
|
|
{/* Pool → same-day train assignment modal */}
|
|
<Modal
|
|
opened={Boolean(poolAssign)}
|
|
onClose={() => setPoolAssign(null)}
|
|
title={
|
|
<Group gap={8}>
|
|
<Train size={18} />
|
|
<Text fw={700}>
|
|
Assign {poolAssign?.reference ?? "booking"} to a train on this day
|
|
</Text>
|
|
</Group>
|
|
}
|
|
centered
|
|
radius="lg"
|
|
>
|
|
<Stack gap="md">
|
|
<Text size="xs" c="dimmed">
|
|
All open trains departing on this schedule's day. Pick one with
|
|
free wagons — the booking is placed and its wagons pinned in one step.
|
|
</Text>
|
|
<Select
|
|
label="Target train (same day)"
|
|
placeholder="Select a departure"
|
|
data={sameDayOptions}
|
|
value={poolTarget}
|
|
onChange={setPoolTarget}
|
|
searchable
|
|
nothingFoundMessage="No open schedules depart on this day"
|
|
/>
|
|
<Group justify="flex-end">
|
|
<Button variant="default" onClick={() => setPoolAssign(null)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
color="edr-green"
|
|
disabled={!poolTarget}
|
|
loading={moveSchedule.isPending || assignUnassigned.isPending}
|
|
leftSection={<CheckCircle2 size={16} />}
|
|
onClick={doPoolAssign}
|
|
>
|
|
Assign to train
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Modal>
|
|
|
|
{/* Reassign modal */}
|
|
<Modal
|
|
opened={Boolean(moveBookingId)}
|
|
onClose={() => setMoveBookingId(null)}
|
|
title={
|
|
<Group gap={8}>
|
|
<ArrowLeftRight size={18} />
|
|
<Text fw={700}>Reassign booking to another train</Text>
|
|
</Group>
|
|
}
|
|
centered
|
|
radius="lg"
|
|
>
|
|
<Stack gap="md">
|
|
<Select
|
|
label="Target train (same route, open window)"
|
|
placeholder="Select an open schedule"
|
|
data={moveOptions}
|
|
value={moveTarget}
|
|
onChange={setMoveTarget}
|
|
searchable
|
|
nothingFoundMessage="No other open schedules on this route"
|
|
/>
|
|
<Group justify="flex-end">
|
|
<Button variant="default" onClick={() => setMoveBookingId(null)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
color="edr-green"
|
|
disabled={!moveTarget}
|
|
loading={moveSchedule.isPending}
|
|
leftSection={<CheckCircle2 size={16} />}
|
|
onClick={doMove}
|
|
>
|
|
Reassign
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Modal>
|
|
</Paper>
|
|
);
|
|
}
|
|
|
|
// ── Sub-components ───────────────────────────────────────────────────────────
|
|
|
|
function PanelColumn({
|
|
title,
|
|
hint,
|
|
count,
|
|
accent,
|
|
loading,
|
|
emptyIcon: EmptyIcon,
|
|
emptyText,
|
|
children,
|
|
}: {
|
|
title: string;
|
|
hint: string;
|
|
count: number;
|
|
accent: string;
|
|
loading?: boolean;
|
|
emptyIcon: typeof Inbox;
|
|
emptyText: string;
|
|
children: React.ReactNode;
|
|
}) {
|
|
const isEmpty = !loading && count === 0;
|
|
return (
|
|
<Paper
|
|
radius="lg"
|
|
withBorder
|
|
p="md"
|
|
miw={280}
|
|
style={{
|
|
flex: 1,
|
|
borderColor: "var(--mantine-color-gray-2)",
|
|
background: `linear-gradient(180deg, ${accent}0A 0%, transparent 90px)`,
|
|
}}
|
|
>
|
|
<Group justify="space-between" align="center" mb="sm">
|
|
<Group gap={8} align="center">
|
|
<Box w={8} h={8} style={{ borderRadius: 999, background: accent }} />
|
|
<Text fw={700} size="sm">
|
|
{title}
|
|
</Text>
|
|
<Badge variant="light" color="gray" radius="sm" size="sm">
|
|
{count}
|
|
</Badge>
|
|
</Group>
|
|
<Text size="xs" c="dimmed">
|
|
{hint}
|
|
</Text>
|
|
</Group>
|
|
|
|
{isEmpty ? (
|
|
<Stack align="center" gap={6} py={32}>
|
|
<EmptyIcon size={24} color="var(--mantine-color-gray-4)" />
|
|
<Text size="xs" c="dimmed" ta="center" maw={220}>
|
|
{emptyText}
|
|
</Text>
|
|
</Stack>
|
|
) : (
|
|
<ScrollArea.Autosize mah={420} type="hover">
|
|
<Stack gap={8} pr={4}>
|
|
{loading ? (
|
|
<Text size="xs" c="dimmed" py="md" ta="center">
|
|
Loading…
|
|
</Text>
|
|
) : (
|
|
children
|
|
)}
|
|
</Stack>
|
|
</ScrollArea.Autosize>
|
|
)}
|
|
</Paper>
|
|
);
|
|
}
|
|
|
|
function BookingCard({
|
|
reference,
|
|
customer,
|
|
weightTons,
|
|
status,
|
|
loadingStatus,
|
|
waitingForWagon,
|
|
intercity,
|
|
government,
|
|
leg,
|
|
right,
|
|
}: {
|
|
reference: string;
|
|
customer?: string | null;
|
|
weightTons?: number | null;
|
|
status?: string | null;
|
|
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;
|
|
/** Government booking — remove is blocked, only switch. */
|
|
government?: boolean;
|
|
/** "Origin → Destination" when the booking rides a sub-corridor leg. */
|
|
leg?: string | null;
|
|
right?: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<Paper
|
|
radius="md"
|
|
withBorder
|
|
p="sm"
|
|
style={{
|
|
borderColor: "var(--mantine-color-gray-2)",
|
|
transition: "border-color 120ms ease, box-shadow 120ms ease",
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
e.currentTarget.style.borderColor = GREEN;
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.borderColor = "var(--mantine-color-gray-2)";
|
|
}}
|
|
>
|
|
<Group justify="space-between" align="center" wrap="nowrap" gap="sm">
|
|
<Stack gap={3} style={{ minWidth: 0 }}>
|
|
<Group gap={8} align="center" wrap="nowrap">
|
|
<Text size="sm" fw={700} truncate>
|
|
{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}
|
|
{government ? (
|
|
<Tooltip
|
|
label="Government booking — cannot be removed, only switched"
|
|
withArrow
|
|
>
|
|
<Badge
|
|
size="sm"
|
|
radius="sm"
|
|
variant="light"
|
|
color="yellow"
|
|
leftSection={<Landmark size={10} />}
|
|
>
|
|
Government
|
|
</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."
|
|
withArrow
|
|
>
|
|
<Badge size="sm" radius="sm" variant="light" color="orange">
|
|
Waiting for wagon
|
|
</Badge>
|
|
</Tooltip>
|
|
) : null}
|
|
{loadingStatus ? (
|
|
<Badge
|
|
size="sm"
|
|
radius="sm"
|
|
variant={loadingStatus === "LOADED" ? "filled" : "light"}
|
|
color={loadingStatus === "LOADED" ? "edr-green" : "gray"}
|
|
>
|
|
{loadingStatus === "LOADED" ? "Loaded" : "Unloaded"}
|
|
</Badge>
|
|
) : null}
|
|
</Group>
|
|
<Group gap={10} align="center" wrap="nowrap">
|
|
<Text size="xs" c="dimmed" truncate>
|
|
{customer ?? "—"}
|
|
</Text>
|
|
{weightTons != null ? (
|
|
<Group gap={3} align="center" wrap="nowrap">
|
|
<Weight size={11} color="var(--mantine-color-gray-5)" />
|
|
<Text size="xs" c="dimmed">
|
|
{Number(weightTons).toFixed(1)}T
|
|
</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}
|
|
</Group>
|
|
</Paper>
|
|
);
|
|
}
|