fix issue

This commit is contained in:
Marshal
2026-07-31 12:23:41 +00:00
parent 8b9dd76710
commit 7e85c16141
5 changed files with 318 additions and 465 deletions

View File

@@ -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."