mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 07:45:45 +00:00
fix issue
This commit is contained in:
@@ -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."
|
||||
|
||||
Reference in New Issue
Block a user