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

feat: enhance train scheduling and contract management features
This commit is contained in:
marshal
2026-08-26 00:46:17 +03:00
committed by GitHub
67 changed files with 2998 additions and 255 deletions

View File

@@ -25,6 +25,7 @@ import { useEffect, useState } from "react";
import { Freight } from "@edr/types";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { StationWorkControls } from "@/components/trainScheduling/StationWorkControls";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "@/lib/permissions";
import { useToast } from "@/hooks/use-toast";
@@ -115,6 +116,7 @@ export function LogPassYardWorkModal({
const { toast } = useToast();
const { user } = useAuth();
const canLoad = hasFreightPermission(user, FREIGHT_PERMS.trainScheduling.load);
const canUnload = hasFreightPermission(user, FREIGHT_PERMS.trainScheduling.unload);
const canLeave = hasFreightPermission(user, FREIGHT_PERMS.trainScheduling.update);
const [justLogged, setJustLogged] = useState(false);
// When the train was here — defaults to now, past allowed (recorded after the fact).
@@ -135,6 +137,7 @@ export function LogPassYardWorkModal({
api.trainScheduling.recordCheckpoint.mutationOptions(),
);
const load = useMutation(api.trainScheduling.loadScheduleBooking.mutationOptions());
const unload = useMutation(api.trainScheduling.unloadScheduleBooking.mutationOptions());
// "Leave behind": the cargo is not on the train — unassign frees its wagons
// and returns the booking to the pool for a later schedule. Reversible (the
// booking can be re-assigned), so no extra confirm step.
@@ -144,6 +147,13 @@ export function LogPassYardWorkModal({
const boarders: YardWorkBookingRow[] = yard?.toLoad ?? [];
const arrivals: YardWorkBookingRow[] = yard?.toUnload ?? [];
const pendingBoarders = boarders.filter((r) => !r.loadedAt);
// Loading/unloading time windows at this station: the server rejects booking
// load/unload until the matching window is started, so the buttons mirror it.
const workLog = station
? yardWorkQuery.data?.stationWorkLogs?.[station.yardId]
: undefined;
const loadingStarted = Boolean(workLog?.loading?.startedAt);
const unloadingStarted = Boolean(workLog?.unloading?.startedAt);
const doLogPass = () => {
if (!station) return;
@@ -165,7 +175,9 @@ export function LogPassYardWorkModal({
description: isFinal
? undefined
: arrivals.some((r) => r.canUnload)
? "Bookings arriving here have been marked arrived."
? unloadingStarted
? "Bookings arriving here have been marked arrived."
: "Start unloading, then unload each arriving booking."
: undefined,
});
void yardWorkQuery.refetch();
@@ -201,6 +213,27 @@ export function LogPassYardWorkModal({
);
};
const doUnload = (row: YardWorkBookingRow) => {
unload.mutate(
{ scheduleId, bookingId: row.id },
{
onSuccess: () => {
toast({
title: `${row.reference ?? "Booking"} unloaded`,
description: `Cargo left the train at ${station?.label ?? "this yard"}.`,
});
void yardWorkQuery.refetch();
},
onError: (err) =>
toast({
title: "Could not unload booking",
description: parseError(err, "Please try again"),
variant: "destructive",
}),
},
);
};
const doLeave = (row: YardWorkBookingRow) => {
leave.mutate(
{ id: scheduleId, bookingId: row.id },
@@ -264,10 +297,22 @@ export function LogPassYardWorkModal({
title="Arriving at this yard"
count={arrivals.length}
/>
{station ? (
<StationWorkControls
scheduleId={scheduleId}
yardId={station.yardId}
phase="unloading"
log={workLog?.unloading}
/>
) : null}
{!logged ? (
<Text size="xs" c="dimmed">
Logging the pass marks the loaded bookings below as Arrived
(import/export) or Completed (intercity) automatically.
Log the pass, start unloading, then unload each booking below.
</Text>
) : !unloadingStarted && arrivals.some((r) => r.canUnload) ? (
<Text size="xs" c="dimmed">
Start unloading first bookings can only be unloaded inside a
started unloading window.
</Text>
) : null}
<Table.ScrollContainer minWidth={620}>
@@ -279,6 +324,7 @@ export function LogPassYardWorkModal({
<Table.Th>Direction</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Arrived</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
@@ -303,6 +349,36 @@ export function LogPassYardWorkModal({
{row.arrivedAt ? fmtDate(row.arrivedAt) : "—"}
</Text>
</Table.Td>
<Table.Td>
{row.canUnload ? (
<Tooltip
label={
!canUnload
? "You don't have permission to unload cargo"
: !logged
? "Log the pass first — the train must be at this yard"
: !unloadingStarted
? "Start unloading first"
: "Confirm cargo unloaded off the train"
}
>
<Button
size="compact-xs"
variant="light"
color="teal"
leftSection={<PackageCheck size={13} />}
disabled={!canUnload || !logged || !unloadingStarted}
loading={
unload.isPending &&
unload.variables?.bookingId === row.id
}
onClick={() => doUnload(row)}
>
Unload
</Button>
</Tooltip>
) : null}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
@@ -321,11 +397,24 @@ export function LogPassYardWorkModal({
title="Boarding at this yard"
count={boarders.length}
/>
{station ? (
<StationWorkControls
scheduleId={scheduleId}
yardId={station.yardId}
phase="loading"
log={workLog?.loading}
/>
) : null}
{!logged && pendingBoarders.length > 0 ? (
<Text size="xs" c="dimmed">
Log the pass first the train must be at {station?.label} before
cargo can be loaded.
</Text>
) : !loadingStarted && pendingBoarders.length > 0 ? (
<Text size="xs" c="dimmed">
Start loading first bookings can only be loaded inside a started
loading window.
</Text>
) : null}
<Table.ScrollContainer minWidth={620}>
<Table verticalSpacing="xs" highlightOnHover>
@@ -389,16 +478,18 @@ export function LogPassYardWorkModal({
? "You don't have permission to load cargo"
: !logged
? "Log the pass first — the train must be at this yard"
: !row.canLoad
? "Booking is not ready to load (payment pending)"
: "Confirm cargo loaded onto the train"
: !loadingStarted
? "Start loading first"
: !row.canLoad
? "Booking is not ready to load (payment pending)"
: "Confirm cargo loaded onto the train"
}
>
<Button
size="compact-xs"
variant="light"
leftSection={<PackageCheck size={13} />}
disabled={!canLoad || !logged || !row.canLoad}
disabled={!canLoad || !logged || !loadingStarted || !row.canLoad}
loading={
load.isPending && load.variables?.bookingId === row.id
}
@@ -467,16 +558,22 @@ export function LogPassYardWorkModal({
Close
</Button>
{!logged ? (
<Button
color={isFinal ? "teal" : "edr-green"}
leftSection={isFinal ? <Flag size={15} /> : <MapPin size={15} />}
loading={recordCheckpoint.isPending}
onClick={doLogPass}
<Tooltip
label="Start unloading first — arrival marks the remaining bookings arrived, so the unloading window must be open"
disabled={!(isFinal && !unloadingStarted && arrivals.some((r) => r.canUnload))}
>
{isFinal
? `Mark arrived at ${station?.label ?? "destination"}`
: `Log pass at ${station?.label ?? "station"}`}
</Button>
<Button
color={isFinal ? "teal" : "edr-green"}
leftSection={isFinal ? <Flag size={15} /> : <MapPin size={15} />}
loading={recordCheckpoint.isPending}
disabled={isFinal && !unloadingStarted && arrivals.some((r) => r.canUnload)}
onClick={doLogPass}
>
{isFinal
? `Mark arrived at ${station?.label ?? "destination"}`
: `Log pass at ${station?.label ?? "station"}`}
</Button>
</Tooltip>
) : null}
</Group>
</Group>

View File

@@ -799,6 +799,17 @@ export function ScheduleWorkspacePanel({
{group.rows.map((b) => {
const ref = b.reference ?? b.id.slice(0, 8);
const journey = journeyById.get(b.id);
// Server gates load/unload on the yard's started work
// window (Start loading/unloading buttons) — mirror it.
const loadWindowStarted = Boolean(
b.originYardId &&
schedule.stationWorkLogs?.[b.originYardId]?.loading?.startedAt,
);
const unloadWindowStarted = Boolean(
b.destinationYardId &&
schedule.stationWorkLogs?.[b.destinationYardId]?.unloading
?.startedAt,
);
const riding = b.status === "IN_TRANSIT";
const done = ["ARRIVED", "COMPLETED", "DELIVERED"].includes(
b.status ?? "",
@@ -845,7 +856,9 @@ export function ScheduleWorkspacePanel({
label={
!canLoad
? "You don't have permission to load cargo"
: boardHere
: boardHere && !loadWindowStarted
? `Start loading at ${group.label} first`
: boardHere
? `Load cargo onto the train at ${group.label}`
: passed
? `Train already passed ${group.label} — this cargo missed its stop`
@@ -860,7 +873,7 @@ export function ScheduleWorkspacePanel({
variant="filled"
color="edr-green"
radius="md"
disabled={!boardHere || !canLoad}
disabled={!boardHere || !canLoad || !loadWindowStarted}
leftSection={<PackageCheck size={13} />}
loading={
loadJourney.isPending &&
@@ -877,9 +890,11 @@ export function ScheduleWorkspacePanel({
{showTruckToTrain ? (
<Tooltip
label={
canLoad
? "Customer truck loaded straight onto the wagon — no warehouse receipt, no GRN. Sets direct truck-to-train handover and loads."
: "You don't have permission to load cargo"
!canLoad
? "You don't have permission to load cargo"
: !loadWindowStarted
? `Start loading at ${group.label} first`
: "Customer truck loaded straight onto the wagon — no warehouse receipt, no GRN. Sets direct truck-to-train handover and loads."
}
withArrow
>
@@ -888,7 +903,7 @@ export function ScheduleWorkspacePanel({
variant="light"
color="blue"
radius="md"
disabled={!canLoad}
disabled={!canLoad || !loadWindowStarted}
leftSection={<Truck size={13} />}
loading={truckToTrainPending === b.id}
onClick={() =>
@@ -908,9 +923,11 @@ export function ScheduleWorkspacePanel({
label={
!canUnload
? "You don't have permission to unload cargo"
: alightHere
? "Unload at this yard — stamps the booking's arrival"
: "Unloads when the train reaches its destination yard"
: alightHere && !unloadWindowStarted
? "Start unloading at this yard first"
: alightHere
? "Unload at this yard — stamps the booking's arrival"
: "Unloads when the train reaches its destination yard"
}
withArrow
>
@@ -919,7 +936,7 @@ export function ScheduleWorkspacePanel({
variant="light"
color="orange"
radius="md"
disabled={!alightHere || !canUnload}
disabled={!alightHere || !canUnload || !unloadWindowStarted}
leftSection={<PackageOpen size={13} />}
loading={
unloadJourney.isPending &&

View File

@@ -0,0 +1,266 @@
import {
ActionIcon,
Badge,
Button,
Group,
Popover,
Stack,
Text,
Tooltip,
} from "@mantine/core";
import { DateTimePicker } from "@mantine/dates";
import { useMutation } from "@tanstack/react-query";
import { Pencil, PlayCircle, StopCircle } from "lucide-react";
import { useEffect, useState } from "react";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { useToast } from "@/hooks/use-toast";
import { api } from "@/services/api";
import type { StationWorkPhaseLog } 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 fmtTime = (iso: string) => {
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
};
const fmtElapsed = (fromIso: string, toIso?: string | null) => {
const from = new Date(fromIso).getTime();
const to = toIso ? new Date(toIso).getTime() : Date.now();
const mins = Math.max(0, Math.round((to - from) / 60_000));
const h = Math.floor(mins / 60);
const m = mins % 60;
return h > 0 ? `${h}h ${m}m` : `${m}m`;
};
/** Pencil-popover to correct an already-recorded start/end timestamp. */
function EditTimeButton({
label,
value,
disabled,
disabledReason,
minDate,
maxDate,
onSave,
saving,
}: {
label: string;
value: string;
disabled: boolean;
disabledReason: string;
minDate?: Date;
maxDate?: Date;
onSave: (at: Date) => void;
saving: boolean;
}) {
const [opened, setOpened] = useState(false);
const [draft, setDraft] = useState<Date | null>(null);
useEffect(() => {
if (opened) setDraft(new Date(value));
}, [opened, value]);
return (
<Popover opened={opened} onChange={setOpened} withArrow shadow="md" position="bottom">
<Popover.Target>
<Tooltip label={disabled ? disabledReason : `Correct the ${label} time`}>
<ActionIcon
size="xs"
variant="subtle"
color="gray"
disabled={disabled}
onClick={() => setOpened((o) => !o)}
>
<Pencil size={12} />
</ActionIcon>
</Tooltip>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="xs">
<DateTimePicker
label={`Correct ${label} time`}
value={draft}
onChange={(v) => setDraft(v ? new Date(v) : null)}
minDate={minDate}
maxDate={maxDate ?? new Date()}
valueFormat="DD MMM YYYY HH:mm"
clearable={false}
radius="md"
maw={280}
/>
<Group justify="flex-end" gap="xs">
<Button size="compact-xs" variant="default" onClick={() => setOpened(false)}>
Cancel
</Button>
<Button
size="compact-xs"
loading={saving}
disabled={!draft}
onClick={() => {
if (draft) {
onSave(draft);
setOpened(false);
}
}}
>
Save
</Button>
</Group>
</Stack>
</Popover.Dropdown>
</Popover>
);
}
/**
* Start/End buttons + elapsed time for one station's loading OR unloading
* window. Booking load/unload at the yard is server-gated on the window having
* been started, so these buttons come first in the operator's flow. Each of
* the four buttons (start/end × loading/unloading) is its own permission, and
* the pencil edits a recorded time under the same permission that set it.
*/
export function StationWorkControls({
scheduleId,
yardId,
phase,
log,
}: {
scheduleId: string;
yardId: string;
phase: "loading" | "unloading";
log?: StationWorkPhaseLog | null;
}) {
const { user } = useAuth();
const { toast } = useToast();
const canStart = hasPermission(
user,
phase === "loading"
? FREIGHT_PERMS.trainScheduling.loadingStart
: FREIGHT_PERMS.trainScheduling.unloadingStart,
);
const canEnd = hasPermission(
user,
phase === "loading"
? FREIGHT_PERMS.trainScheduling.loadingEnd
: FREIGHT_PERMS.trainScheduling.unloadingEnd,
);
const record = useMutation(api.trainScheduling.recordStationWork.mutationOptions());
// Re-render each minute so the running elapsed time ticks while unended.
const [, setTick] = useState(0);
useEffect(() => {
if (!log?.startedAt || log?.endedAt) return;
const t = setInterval(() => setTick((n) => n + 1), 60_000);
return () => clearInterval(t);
}, [log?.startedAt, log?.endedAt]);
const doRecord = (edge: "start" | "end", at?: Date) => {
record.mutate(
{ scheduleId, yardId, phase, edge, ...(at ? { at: at.toISOString() } : {}) },
{
onSuccess: () =>
toast({
title: `${phase === "loading" ? "Loading" : "Unloading"} ${edge} recorded`,
}),
onError: (err) =>
toast({
title: `Could not record ${phase} ${edge}`,
description: parseError(err, "Please try again"),
variant: "destructive",
}),
},
);
};
const title = phase === "loading" ? "Loading" : "Unloading";
const started = Boolean(log?.startedAt);
const ended = Boolean(log?.endedAt);
return (
<Group gap="sm" wrap="wrap" align="center">
<Badge variant="light" color={ended ? "gray" : started ? "edr-green" : "yellow"} radius="sm">
{title}
{ended ? " done" : started ? " in progress" : " not started"}
</Badge>
{!started ? (
<Tooltip
label={
canStart
? `Record the moment ${phase} work begins at this station`
: `You don't have permission to start ${phase}`
}
>
<Button
size="compact-sm"
variant="light"
color="edr-green"
leftSection={<PlayCircle size={14} />}
disabled={!canStart}
loading={record.isPending}
onClick={() => doRecord("start")}
>
Start {phase}
</Button>
</Tooltip>
) : (
<>
<Group gap={4} wrap="nowrap">
<Text size="xs" c="dimmed">
{fmtTime(log!.startedAt!)} {ended ? fmtTime(log!.endedAt!) : "…"} (
{fmtElapsed(log!.startedAt!, log?.endedAt)})
</Text>
<EditTimeButton
label={`${phase} start`}
value={log!.startedAt!}
disabled={!canStart}
disabledReason={`You don't have permission to edit the ${phase} start`}
maxDate={log?.endedAt ? new Date(log.endedAt) : new Date()}
onSave={(at) => doRecord("start", at)}
saving={record.isPending}
/>
{ended ? (
<EditTimeButton
label={`${phase} end`}
value={log!.endedAt!}
disabled={!canEnd}
disabledReason={`You don't have permission to edit the ${phase} end`}
minDate={new Date(log!.startedAt!)}
onSave={(at) => doRecord("end", at)}
saving={record.isPending}
/>
) : null}
</Group>
{!ended ? (
<Tooltip
label={
canEnd
? `Record the moment ${phase} work is finished at this station`
: `You don't have permission to end ${phase}`
}
>
<Button
size="compact-sm"
variant="light"
color="orange"
leftSection={<StopCircle size={14} />}
disabled={!canEnd}
loading={record.isPending}
onClick={() => doRecord("end")}
>
End {phase}
</Button>
</Tooltip>
) : null}
</>
)}
</Group>
);
}