mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
fix issue
This commit is contained in:
@@ -83,6 +83,11 @@ export class BookingJourneyService {
|
||||
loadedByUserId: userId ?? null,
|
||||
} as never);
|
||||
await this.setAllocationStatuses(manager, scheduleId, bookingId, 'LOADED');
|
||||
// Keep the schedule↔booking link's tracking flag in sync — the dispatch
|
||||
// readiness warnings and workspace badges read loading_status, not loadedAt.
|
||||
await manager
|
||||
.getRepository(TrainScheduleBooking)
|
||||
.update({ trainScheduleId: scheduleId, bookingId }, { loadingStatus: 'LOADED' });
|
||||
// The facility handed the cargo over — raise its GRN. No-ops for yards
|
||||
// without a facility (import/export terminals), which keep their own flow.
|
||||
await this.facilityHandling.recordHandling(manager, {
|
||||
@@ -236,7 +241,10 @@ export class BookingJourneyService {
|
||||
return {
|
||||
scheduleId,
|
||||
scheduleStatus: schedule.status,
|
||||
trainAtYardId: latest?.yardId ?? (schedule.status === 'DISPATCHED' ? null : schedule.originStationId),
|
||||
// No checkpoint yet ⇒ the train is still at its origin, even just after
|
||||
// dispatch — assertTrainAtYard allows origin loading in that state, so
|
||||
// the UI position must agree or origin Load buttons grey out wrongly.
|
||||
trainAtYardId: latest?.yardId ?? schedule.originStationId,
|
||||
yards: [...byYard.values()],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1977,6 +1977,16 @@ export class TrainSchedulingService {
|
||||
manager,
|
||||
);
|
||||
|
||||
// Unassign only runs pre-dispatch, so an IN_TRANSIT status here is stale
|
||||
// (e.g. auto-loaded by an earlier dispatch that was rolled back). Left as
|
||||
// is, the booking becomes invisible: the eligible pool only admits PAID,
|
||||
// so it can never be re-added to any train. Revert it to PAID.
|
||||
if (booking?.status === 'IN_TRANSIT' && !booking.arrivedAt) {
|
||||
await manager
|
||||
.getRepository(Booking)
|
||||
.update(bookingId, { status: 'PAID', loadedAt: null } as never);
|
||||
}
|
||||
|
||||
// Recompute the train-set composition from whatever survives this removal.
|
||||
// The removed booking's allocations were already deleted above, so any slot
|
||||
// left with zero allocations was ridden only by this booking — release it
|
||||
|
||||
@@ -22,10 +22,13 @@ import {
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
Inbox,
|
||||
Landmark,
|
||||
MapPin,
|
||||
PackageCheck,
|
||||
PackageX,
|
||||
PackageOpen,
|
||||
// Repeat, // used by the hidden Move (reassign) button
|
||||
Train,
|
||||
TrainFront,
|
||||
Weight,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
@@ -39,6 +42,7 @@ import type {
|
||||
EligibleContainerBooking,
|
||||
FreightType,
|
||||
TrainScheduleDetail,
|
||||
YardWorkBookingRow,
|
||||
} from "@/types/trainScheduling";
|
||||
|
||||
interface ScheduleWorkspacePanelProps {
|
||||
@@ -151,6 +155,9 @@ export function ScheduleWorkspacePanel({
|
||||
|
||||
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).
|
||||
@@ -180,14 +187,83 @@ export function ScheduleWorkspacePanel({
|
||||
|
||||
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 setLoading = useMutation(
|
||||
api.trainScheduling.setLoadingStatus.mutationOptions(),
|
||||
const loadJourney = useMutation(
|
||||
api.trainScheduling.loadScheduleBooking.mutationOptions(),
|
||||
);
|
||||
const unloadJourney = useMutation(
|
||||
api.trainScheduling.unloadScheduleBooking.mutationOptions(),
|
||||
);
|
||||
const moveSchedule = useMutation(
|
||||
api.trainScheduling.moveBookingSchedule.mutationOptions(),
|
||||
@@ -296,26 +372,42 @@ export function ScheduleWorkspacePanel({
|
||||
);
|
||||
};
|
||||
|
||||
const toggleLoaded = (
|
||||
bookingId: string,
|
||||
ref: string,
|
||||
next: "LOADED" | "UNLOADED",
|
||||
) => {
|
||||
setLoading
|
||||
.mutateAsync({ id: schedule.id, bookingIds: [bookingId], loadingStatus: next })
|
||||
// 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:
|
||||
next === "LOADED"
|
||||
? `${ref} marked loaded`
|
||||
: `${ref} marked unloaded`,
|
||||
});
|
||||
toast({ title: `${ref} loaded onto the train` });
|
||||
onChanged();
|
||||
void yardWorkQuery.refetch();
|
||||
})
|
||||
.catch((error) =>
|
||||
toast({
|
||||
title: "Could not update loading status",
|
||||
description: apiErrorMessage(error, "Please try again."),
|
||||
title: "Could not load cargo",
|
||||
description: apiErrorMessage(error, "Train may not be at the boarding yard."),
|
||||
variant: "destructive",
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
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",
|
||||
}),
|
||||
);
|
||||
@@ -382,7 +474,8 @@ export function ScheduleWorkspacePanel({
|
||||
<div>
|
||||
<Text fw={700}>Allocation workspace</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Manually add paid, unassigned bookings, remove, or reassign them
|
||||
Add or remove bookings, then load each one when the train is at
|
||||
its boarding yard
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
@@ -458,7 +551,10 @@ export function ScheduleWorkspacePanel({
|
||||
{locked ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
This train is {schedule.status.toLowerCase()} — bookings can no longer be
|
||||
changed.
|
||||
added or removed.
|
||||
{schedule.status === "DISPATCHED"
|
||||
? " Loading continues per yard as checkpoints are logged on the track page."
|
||||
: ""}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
@@ -485,6 +581,10 @@ export function ScheduleWorkspacePanel({
|
||||
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">
|
||||
@@ -525,114 +625,174 @@ export function ScheduleWorkspacePanel({
|
||||
))}
|
||||
</PanelColumn>
|
||||
|
||||
{/* On train */}
|
||||
{/* 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="Allocated bookings"
|
||||
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."
|
||||
>
|
||||
{onTrain.map((b) => (
|
||||
<BookingCard
|
||||
key={b.id}
|
||||
reference={b.reference ?? b.id.slice(0, 8)}
|
||||
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 ? (
|
||||
<Group gap={6} wrap="nowrap" justify="flex-end">
|
||||
{b.wagonAssigned ? (
|
||||
<Tooltip
|
||||
label={
|
||||
(b.loadingStatus ?? "UNLOADED") === "LOADED"
|
||||
? "Mark cargo unloaded from wagon"
|
||||
: "Mark cargo loaded onto wagon"
|
||||
}
|
||||
withArrow
|
||||
>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant={
|
||||
(b.loadingStatus ?? "UNLOADED") === "LOADED"
|
||||
? "light"
|
||||
: "filled"
|
||||
}
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={
|
||||
(b.loadingStatus ?? "UNLOADED") === "LOADED" ? (
|
||||
<PackageX size={13} />
|
||||
) : (
|
||||
<PackageCheck size={13} />
|
||||
{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);
|
||||
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}
|
||||
{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>
|
||||
)
|
||||
}
|
||||
loading={setLoading.isPending}
|
||||
onClick={() =>
|
||||
toggleLoaded(
|
||||
b.id,
|
||||
b.reference ?? b.id.slice(0, 8),
|
||||
(b.loadingStatus ?? "UNLOADED") === "LOADED"
|
||||
? "UNLOADED"
|
||||
: "LOADED",
|
||||
)
|
||||
}
|
||||
>
|
||||
{(b.loadingStatus ?? "UNLOADED") === "LOADED"
|
||||
? "Unload"
|
||||
: "Load"}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{/* Reassign-to-another-train — hidden for now.
|
||||
<Tooltip label="Reassign to another train" withArrow>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="subtle"
|
||||
color="orange"
|
||||
radius="md"
|
||||
leftSection={<Repeat size={13} />}
|
||||
onClick={() => {
|
||||
setMoveBookingId(b.id);
|
||||
setMoveTarget(null);
|
||||
}}
|
||||
>
|
||||
Move
|
||||
</Button>
|
||||
</Tooltip>
|
||||
*/}
|
||||
<Tooltip label="Remove from this train" withArrow>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color="red"
|
||||
radius="md"
|
||||
leftSection={<X size={13} />}
|
||||
loading={unassign.isPending}
|
||||
onClick={() =>
|
||||
removeFromTrain(b.id, b.reference ?? b.id.slice(0, 8))
|
||||
}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
))}
|
||||
) : null}
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</PanelColumn>
|
||||
</Group>
|
||||
</Stack>
|
||||
@@ -807,6 +967,7 @@ function BookingCard({
|
||||
loadingStatus,
|
||||
waitingForWagon,
|
||||
intercity,
|
||||
government,
|
||||
leg,
|
||||
right,
|
||||
}: {
|
||||
@@ -819,6 +980,8 @@ function BookingCard({
|
||||
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;
|
||||
@@ -856,6 +1019,22 @@ function BookingCard({
|
||||
</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."
|
||||
|
||||
@@ -1,342 +0,0 @@
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertCircle, MapPin, PackageCheck, PackageOpen, TrainFront } from "lucide-react";
|
||||
import { Freight } from "@edr/types";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||
import type { YardWorkBookingRow, YardWorkYard } 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;
|
||||
};
|
||||
|
||||
const fmtDate = (iso: string) => {
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
|
||||
};
|
||||
|
||||
const DIRECTION_COLORS: Record<string, string> = {
|
||||
IMPORT: "blue",
|
||||
EXPORT: "teal",
|
||||
DOMESTIC: "violet",
|
||||
};
|
||||
|
||||
/** DOMESTIC displays as "Intercity" — shared with the rest of the platform. */
|
||||
const DIRECTION_LABELS: Record<string, string> = Freight.TRADE_DIRECTION_LABELS;
|
||||
|
||||
function DirectionChip({ direction }: { direction: string }) {
|
||||
return (
|
||||
<Badge size="sm" variant="light" color={DIRECTION_COLORS[direction] ?? "gray"}>
|
||||
{DIRECTION_LABELS[direction] ?? direction}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function BookingCell({ row }: { row: YardWorkBookingRow }) {
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkTable({
|
||||
rows,
|
||||
side,
|
||||
trainHere,
|
||||
onLoad,
|
||||
onUnload,
|
||||
pendingBookingId,
|
||||
}: {
|
||||
rows: YardWorkBookingRow[];
|
||||
side: "load" | "unload";
|
||||
trainHere: boolean;
|
||||
onLoad: (bookingId: string) => void;
|
||||
onUnload: (bookingId: string) => void;
|
||||
pendingBookingId: string | null;
|
||||
}) {
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
{side === "load" ? "No bookings board here." : "No bookings alight here."}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Table.ScrollContainer minWidth={720}>
|
||||
<Table verticalSpacing="xs" highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Booking</Table.Th>
|
||||
<Table.Th>Customer</Table.Th>
|
||||
<Table.Th>Direction</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>{side === "load" ? "Loaded" : "Arrived"}</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows.map((row) => {
|
||||
const timestamp = side === "load" ? row.loadedAt : row.arrivedAt;
|
||||
const canAct = side === "load" ? row.canLoad : row.canUnload;
|
||||
return (
|
||||
<Table.Tr key={row.id}>
|
||||
<Table.Td>
|
||||
<BookingCell row={row} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{row.customer}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<DirectionChip direction={row.tradeDirection} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<BookingStatusBadge status={row.status} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{timestamp ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{fmtDate(timestamp)}
|
||||
</Text>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs" justify="flex-end">
|
||||
{side === "load" ? (
|
||||
<Tooltip
|
||||
label={
|
||||
trainHere
|
||||
? "Confirm cargo loaded at this yard"
|
||||
: "Train must be at this yard"
|
||||
}
|
||||
disabled={!canAct && Boolean(row.loadedAt)}
|
||||
>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={<PackageCheck size={13} />}
|
||||
disabled={!canAct || !trainHere}
|
||||
loading={pendingBookingId === row.id}
|
||||
onClick={() => onLoad(row.id)}
|
||||
>
|
||||
Load
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip
|
||||
label={
|
||||
trainHere
|
||||
? "Confirm cargo unloaded at this yard"
|
||||
: "Train must be at this yard"
|
||||
}
|
||||
disabled={!canAct && Boolean(row.arrivedAt)}
|
||||
>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<PackageOpen size={13} />}
|
||||
disabled={!canAct || !trainHere}
|
||||
loading={pendingBookingId === row.id}
|
||||
onClick={() => onUnload(row.id)}
|
||||
>
|
||||
Unload
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-yard load/unload worklist for one schedule — every trade direction. Each
|
||||
* booking boards at its origin yard and alights at its destination yard; the
|
||||
* operator confirms both while the train's last recorded checkpoint is at that
|
||||
* yard (the server validates the position). Unloading stamps the booking's own
|
||||
* arrival — ARRIVED for import/export, COMPLETED for intercity.
|
||||
*/
|
||||
export function YardWorkPanel({ scheduleId }: { scheduleId: string }) {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const yardWorkQuery = useQuery(
|
||||
api.trainScheduling.yardWork.queryOptions({
|
||||
input: { scheduleId },
|
||||
refetchInterval: 60_000,
|
||||
}),
|
||||
);
|
||||
|
||||
// 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({
|
||||
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.unloadScheduleBooking.mutationOptions({
|
||||
onSuccess: (result) => {
|
||||
void invalidate();
|
||||
toast({
|
||||
title:
|
||||
result.status === "COMPLETED"
|
||||
? "Cargo unloaded — booking completed"
|
||||
: "Cargo unloaded — booking arrived",
|
||||
});
|
||||
},
|
||||
onError: (err) =>
|
||||
toast({
|
||||
title: "Unload failed",
|
||||
description: parseError(err, "Could not confirm unloading"),
|
||||
variant: "destructive",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const data = yardWorkQuery.data;
|
||||
const yards: YardWorkYard[] = data?.yards ?? [];
|
||||
const trainAtYardId = data?.trainAtYardId ?? null;
|
||||
const pendingLoadId = load.isPending ? (load.variables?.bookingId ?? null) : null;
|
||||
const pendingUnloadId = unload.isPending ? (unload.variables?.bookingId ?? null) : null;
|
||||
|
||||
return (
|
||||
<Paper withBorder radius="lg" p="lg" mt="md">
|
||||
<Stack gap="md">
|
||||
<Group gap="xs">
|
||||
<MapPin size={18} />
|
||||
<Text fw={700}>Yard load / unload</Text>
|
||||
</Group>
|
||||
|
||||
{yardWorkQuery.isLoading ? (
|
||||
<Group gap="xs">
|
||||
<Loader size="xs" />
|
||||
<Text size="sm" c="dimmed">
|
||||
Loading yard worklists…
|
||||
</Text>
|
||||
</Group>
|
||||
) : yardWorkQuery.isError ? (
|
||||
<Alert color="red" variant="light" icon={<AlertCircle size={16} />}>
|
||||
{parseError(yardWorkQuery.error, "Could not load the yard worklist")}
|
||||
</Alert>
|
||||
) : yards.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No bookings are assigned to this schedule yet.
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<Text size="sm" c="dimmed">
|
||||
What boards and alights at each stop. Confirm loading at a booking's
|
||||
origin and unloading at its destination while the train is at that
|
||||
yard — unloading stamps the booking's own arrival, even before the
|
||||
train's final stop.
|
||||
</Text>
|
||||
{yards.map((yard, index) => {
|
||||
const trainHere = trainAtYardId === yard.yardId;
|
||||
return (
|
||||
<Stack key={yard.yardId} gap="sm">
|
||||
{index > 0 && <Divider />}
|
||||
<Group gap="xs">
|
||||
<Text fw={600}>{yard.yard}</Text>
|
||||
{trainHere && (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
leftSection={<TrainFront size={12} />}
|
||||
>
|
||||
Train here
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Stack gap={6}>
|
||||
<Text size="sm" fw={600} c="dimmed">
|
||||
Board here
|
||||
</Text>
|
||||
<WorkTable
|
||||
rows={yard.toLoad}
|
||||
side="load"
|
||||
trainHere={trainHere}
|
||||
onLoad={(bookingId) => load.mutate({ scheduleId, bookingId })}
|
||||
onUnload={(bookingId) => unload.mutate({ scheduleId, bookingId })}
|
||||
pendingBookingId={pendingLoadId}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack gap={6}>
|
||||
<Text size="sm" fw={600} c="dimmed">
|
||||
Alight here
|
||||
</Text>
|
||||
<WorkTable
|
||||
rows={yard.toUnload}
|
||||
side="unload"
|
||||
trainHere={trainHere}
|
||||
onLoad={(bookingId) => load.mutate({ scheduleId, bookingId })}
|
||||
onUnload={(bookingId) => unload.mutate({ scheduleId, bookingId })}
|
||||
pendingBookingId={pendingUnloadId}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -53,7 +53,6 @@ import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvai
|
||||
import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityRideAlongPanel";
|
||||
import { LegCapacityPanel } from "@/components/trainScheduling/LegCapacityPanel";
|
||||
import ScheduleHistoryPanel from "@/components/trainScheduling/ScheduleHistoryPanel";
|
||||
import { YardWorkPanel } from "@/components/trainScheduling/YardWorkPanel";
|
||||
// import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
|
||||
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
|
||||
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
|
||||
@@ -1248,7 +1247,6 @@ export default function TrainScheduleV2DetailPage() {
|
||||
void detailQuery.refetch();
|
||||
}}
|
||||
/>
|
||||
{scheduleId ? <YardWorkPanel scheduleId={scheduleId} /> : null}
|
||||
{scheduleId ? (
|
||||
<IntercityRideAlongPanel
|
||||
scheduleId={scheduleId}
|
||||
|
||||
Reference in New Issue
Block a user