mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 19:58:11 +00:00
Merge pull request #1414 from Tria-plc/freight_feature/usermanagement
feat: enhance train scheduling and contract management features
This commit is contained in:
@@ -128,14 +128,27 @@ export function BookingRouteServiceCard({
|
||||
background: "#F8FAFC",
|
||||
}}
|
||||
>
|
||||
<Group gap={10} align="center">
|
||||
<FileText size={15} color="#64748B" />
|
||||
<Text fz={13} fw={500} c="#374151">
|
||||
Customs clearing agent:{" "}
|
||||
<Text component="span" fw={700} c="#10202F">
|
||||
{booking.customsClearingAgent}
|
||||
<Group gap={10} align="flex-start" wrap="nowrap">
|
||||
<FileText size={15} color="#64748B" style={{ marginTop: 2 }} />
|
||||
<Stack gap={2}>
|
||||
<Text fz={13} fw={500} c="#374151">
|
||||
Customs clearing agent:{" "}
|
||||
<Text component="span" fw={700} c="#10202F">
|
||||
{booking.customsClearingAgent}
|
||||
</Text>
|
||||
</Text>
|
||||
</Text>
|
||||
{(booking.customsClearingAgentEmail ||
|
||||
booking.customsClearingAgentPhone) && (
|
||||
<Text fz={12.5} c="#64748B">
|
||||
{[
|
||||
booking.customsClearingAgentEmail,
|
||||
booking.customsClearingAgentPhone,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
@@ -130,6 +130,7 @@ interface LineErrors {
|
||||
|
||||
interface BulkErrors {
|
||||
quantity?: string;
|
||||
wagons?: string;
|
||||
hazardous?: string;
|
||||
reefer?: string;
|
||||
}
|
||||
@@ -175,6 +176,8 @@ interface ContainerLineDraft {
|
||||
interface BulkDraft {
|
||||
cargoWeightTons: string;
|
||||
itemCount: string;
|
||||
/** NUMBER_OF_WAGONS cargo only: wagons this shipment needs. */
|
||||
requestedWagons: string;
|
||||
hazardousQuantity: string;
|
||||
reeferQuantity: string;
|
||||
}
|
||||
@@ -203,7 +206,19 @@ function emptyLine(size: string): ContainerLineDraft {
|
||||
|
||||
function bulkUnitOfMeasure(
|
||||
contract: Freight.IContract,
|
||||
): "PER_TON" | "PER_ITEM" {
|
||||
): "PER_TON" | "PER_ITEM" | "NUMBER_OF_WAGONS" {
|
||||
// The cargo type's own configured unit wins; the pricing-line sniff below is
|
||||
// the legacy fallback for contracts loaded without the cargoScope relation.
|
||||
const configured = contract.cargoScope?.find(
|
||||
(scope) => scope.cargoType?.unitOfMeasure,
|
||||
)?.cargoType?.unitOfMeasure;
|
||||
if (
|
||||
configured === "PER_TON" ||
|
||||
configured === "PER_ITEM" ||
|
||||
configured === "NUMBER_OF_WAGONS"
|
||||
) {
|
||||
return configured;
|
||||
}
|
||||
const hasPerItem = contract.pricingBreakdown?.lineItems?.some(
|
||||
(li) => li.unit === "per_item",
|
||||
);
|
||||
@@ -322,6 +337,7 @@ export default function GlCreateBookingForm() {
|
||||
const [bulk, setBulk] = useState<BulkDraft>({
|
||||
cargoWeightTons: "",
|
||||
itemCount: "",
|
||||
requestedWagons: "",
|
||||
hazardousQuantity: "0",
|
||||
reeferQuantity: "0",
|
||||
});
|
||||
@@ -521,16 +537,18 @@ export default function GlCreateBookingForm() {
|
||||
})),
|
||||
);
|
||||
} else if (lines.bulk) {
|
||||
setBulk({
|
||||
setBulk((b) => ({
|
||||
cargoWeightTons:
|
||||
lines.bulk.cargoWeightTons != null
|
||||
? String(lines.bulk.cargoWeightTons)
|
||||
lines.bulk!.cargoWeightTons != null
|
||||
? String(lines.bulk!.cargoWeightTons)
|
||||
: "",
|
||||
itemCount:
|
||||
lines.bulk.itemCount != null ? String(lines.bulk.itemCount) : "",
|
||||
hazardousQuantity: String(lines.bulk.hazardousQuantity ?? 0),
|
||||
lines.bulk!.itemCount != null ? String(lines.bulk!.itemCount) : "",
|
||||
// The request never carries a wagon count — GL enters it here.
|
||||
requestedWagons: b.requestedWagons,
|
||||
hazardousQuantity: String(lines.bulk!.hazardousQuantity ?? 0),
|
||||
reeferQuantity: "0",
|
||||
});
|
||||
}));
|
||||
}
|
||||
if (bookingRequest.contractRouteId)
|
||||
setContractRouteId(bookingRequest.contractRouteId);
|
||||
@@ -618,6 +636,7 @@ export default function GlCreateBookingForm() {
|
||||
returnQuantity: Number(l.returnQuantity || 0),
|
||||
})),
|
||||
bulkQuantity: Number(bulk.cargoWeightTons || bulk.itemCount || 0),
|
||||
bulkRequestedWagons: Number(bulk.requestedWagons || 0),
|
||||
bulkHazardousQuantity: Number(bulk.hazardousQuantity || 0),
|
||||
bulkReeferQuantity: Number(bulk.reeferQuantity || 0),
|
||||
}),
|
||||
@@ -979,6 +998,12 @@ export default function GlCreateBookingForm() {
|
||||
if (Number.isNaN(qty) || qty <= 0) {
|
||||
errs.quantity = "Enter a quantity greater than 0.";
|
||||
}
|
||||
if (bulkUom === "NUMBER_OF_WAGONS") {
|
||||
const wagons = Number(bulk.requestedWagons || 0);
|
||||
if (!Number.isInteger(wagons) || wagons < 1) {
|
||||
errs.wagons = "Enter the number of wagons needed (at least 1).";
|
||||
}
|
||||
}
|
||||
const h = Number(bulk.hazardousQuantity || 0);
|
||||
if (Number.isNaN(h) || h < 0) {
|
||||
errs.hazardous = "Enter a valid hazardous quantity.";
|
||||
@@ -1017,7 +1042,10 @@ export default function GlCreateBookingForm() {
|
||||
line.every((e) => !e.containerNumber && !e.vgmTons),
|
||||
) &&
|
||||
!cargoDescriptionError
|
||||
: !bulkErrors.quantity && !bulkErrors.hazardous && !bulkErrors.reefer;
|
||||
: !bulkErrors.quantity &&
|
||||
!bulkErrors.wagons &&
|
||||
!bulkErrors.hazardous &&
|
||||
!bulkErrors.reefer;
|
||||
|
||||
// COMPLETION never blocks on an odd 20ft total: a customs instance can share
|
||||
// the wagon via the manual pair (consolidationActive), and anything else is
|
||||
@@ -1152,6 +1180,9 @@ export default function GlCreateBookingForm() {
|
||||
reeferQuantity: Number(bulk.reeferQuantity || 0) || undefined,
|
||||
},
|
||||
];
|
||||
if (bulkUom === "NUMBER_OF_WAGONS" && bulk.requestedWagons !== "") {
|
||||
payload.requestedWagons = Number(bulk.requestedWagons);
|
||||
}
|
||||
}
|
||||
|
||||
return payload;
|
||||
@@ -2049,6 +2080,27 @@ export default function GlCreateBookingForm() {
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
{bulkUom === "NUMBER_OF_WAGONS" && (
|
||||
<TextInput
|
||||
type="number"
|
||||
onKeyDown={blockNegative}
|
||||
label="Number of wagons needed"
|
||||
placeholder="e.g. 40"
|
||||
description="The cargo weight is spread evenly across these wagons; a per-wagon rate bills this count."
|
||||
min={1}
|
||||
step={1}
|
||||
value={bulk.requestedWagons}
|
||||
error={showErrors ? bulkErrors.wagons : undefined}
|
||||
onChange={(e) =>
|
||||
setBulk((b) => ({
|
||||
...b,
|
||||
requestedWagons: e.currentTarget.value,
|
||||
}))
|
||||
}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
)}
|
||||
{contract.isHazardous && (
|
||||
<TextInput
|
||||
type="number"
|
||||
|
||||
@@ -28,6 +28,8 @@ export interface GlShipmentQuantities {
|
||||
}>;
|
||||
/** Bulk: tons (or item count) + hazardous/reefer qty. */
|
||||
bulkQuantity: number;
|
||||
/** NUMBER_OF_WAGONS cargo: the wagon count GL enters (0 otherwise). */
|
||||
bulkRequestedWagons: number;
|
||||
bulkHazardousQuantity: number;
|
||||
bulkReeferQuantity: number;
|
||||
}
|
||||
@@ -132,7 +134,6 @@ export function computeGlShipmentTotal(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const qty = q.bulkQuantity;
|
||||
const rate =
|
||||
rateFor(
|
||||
(i) =>
|
||||
@@ -140,6 +141,9 @@ export function computeGlShipmentTotal(
|
||||
!i.isClearance &&
|
||||
!i.conditionalOn,
|
||||
) ?? items[0];
|
||||
// NUMBER_OF_WAGONS cargo: a per-wagon base rate bills the requested count.
|
||||
const qty =
|
||||
rate?.unit === "per_wagon" ? q.bulkRequestedWagons : q.bulkQuantity;
|
||||
if (rate && qty > 0) {
|
||||
lines.push({
|
||||
label: rate.label,
|
||||
@@ -179,8 +183,14 @@ export function computeGlShipmentTotal(
|
||||
// it (the commodity needs lashing). Per-ton scales by tonnage; per-wagon
|
||||
// depends on the wagon capacity the train stocks — shown at real pricing.
|
||||
const lashing = items.find((i) => i.conditionalOn === "has_lashing");
|
||||
if (lashing && (lashing.unit === "per_ton" || lashing.unit === "per_item")) {
|
||||
const tons = q.bulkQuantity;
|
||||
if (
|
||||
lashing &&
|
||||
(lashing.unit === "per_ton" ||
|
||||
lashing.unit === "per_item" ||
|
||||
(lashing.unit === "per_wagon" && q.bulkRequestedWagons > 0))
|
||||
) {
|
||||
const tons =
|
||||
lashing.unit === "per_wagon" ? q.bulkRequestedWagons : q.bulkQuantity;
|
||||
if (tons > 0) {
|
||||
lines.push({
|
||||
label: lashing.label,
|
||||
@@ -208,6 +218,8 @@ export function computeGlShipmentTotal(
|
||||
: boxes;
|
||||
} else if (cl.unit === "per_ton" || cl.unit === "per_item") {
|
||||
qty = q.bulkQuantity;
|
||||
} else if (cl.unit === "per_wagon") {
|
||||
qty = q.bulkRequestedWagons;
|
||||
} else if (cl.unit === "flat") {
|
||||
qty = 1;
|
||||
}
|
||||
|
||||
@@ -5,18 +5,30 @@ import {
|
||||
Group,
|
||||
Pagination,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link2, MapPin, PackageOpen, User } from "lucide-react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { isAxiosError } from "axios";
|
||||
import { ArrowRightLeft, Link2, MapPin, PackageOpen, User } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { api } from "@/services/api";
|
||||
|
||||
const parseError = (error: unknown, fallback: string) => {
|
||||
if (isAxiosError(error)) {
|
||||
const message = error.response?.data?.message;
|
||||
if (Array.isArray(message)) return message.join(", ");
|
||||
if (typeof message === "string") return message;
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
trainId: string;
|
||||
/** Staff may attach and the train is editable (not out on a run). */
|
||||
@@ -50,6 +62,47 @@ export default function DetachedWagonsPanel({
|
||||
// Selection is page-scoped in the header checkbox but survives paging, so
|
||||
// staff can gather wagons across pages into one attach.
|
||||
const [selected, setSelected] = useState<ReadonlySet<string>>(new Set());
|
||||
|
||||
// Attach the selection to a DIFFERENT built train: pick a target, reuse the
|
||||
// same assign endpoint with that train's id. The builder attach is
|
||||
// yard-agnostic, so any loose AVAILABLE wagon qualifies; a train that is
|
||||
// out on a run rejects server-side and is disabled here too.
|
||||
const { toast } = useToast();
|
||||
const [targetTrainId, setTargetTrainId] = useState<string | null>(null);
|
||||
const trainsQuery = useQuery(
|
||||
api.trainBuilder.list.queryOptions({
|
||||
input: { filters: { pageSize: 200, sortBy: "code", sortOrder: "ASC" } },
|
||||
enabled: canAttach,
|
||||
staleTime: 60_000,
|
||||
}),
|
||||
);
|
||||
const trainOptions = (trainsQuery.data?.items ?? [])
|
||||
.filter((t) => t.id !== trainId)
|
||||
.map((t) => ({
|
||||
value: t.id,
|
||||
label: `${t.code}${t.trainName ? ` · ${t.trainName}` : ""} — ${t.wagonCount} wagon${t.wagonCount === 1 ? "" : "s"}${t.status === "IN_SERVICE" ? " (in service)" : ""}`,
|
||||
disabled: t.status === "IN_SERVICE",
|
||||
}));
|
||||
const attachOther = useMutation(api.trainBuilder.assignWagons.mutationOptions());
|
||||
const handleAttachOther = async () => {
|
||||
if (!targetTrainId || !selected.size) return;
|
||||
const target = trainsQuery.data?.items.find((t) => t.id === targetTrainId);
|
||||
try {
|
||||
await attachOther.mutateAsync({ id: targetTrainId, wagonIds: [...selected] });
|
||||
toast({
|
||||
title: `${selected.size} wagon(s) attached to ${target?.code ?? "the selected train"}`,
|
||||
});
|
||||
setSelected(new Set());
|
||||
setTargetTrainId(null);
|
||||
void query.refetch();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Could not attach to the other train",
|
||||
description: parseError(error, "The target train may be out on a run."),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
const allSelected = rows.length > 0 && rows.every((r) => selected.has(r.wagonId));
|
||||
|
||||
const toggle = (wagonId: string, checked: boolean) =>
|
||||
@@ -79,17 +132,40 @@ export default function DetachedWagonsPanel({
|
||||
</Stack>
|
||||
</Group>
|
||||
{canAttach ? (
|
||||
<Button
|
||||
leftSection={<Link2 size={16} />}
|
||||
disabled={selected.size === 0}
|
||||
loading={attachPending}
|
||||
onClick={() => {
|
||||
onAttach([...selected]);
|
||||
setSelected(new Set());
|
||||
}}
|
||||
>
|
||||
Attach {selected.size || ""} wagon{selected.size === 1 ? "" : "s"}
|
||||
</Button>
|
||||
<Group gap="sm" align="flex-end" wrap="wrap">
|
||||
<Button
|
||||
leftSection={<Link2 size={16} />}
|
||||
disabled={selected.size === 0}
|
||||
loading={attachPending}
|
||||
onClick={() => {
|
||||
onAttach([...selected]);
|
||||
setSelected(new Set());
|
||||
}}
|
||||
>
|
||||
Attach {selected.size || ""} wagon{selected.size === 1 ? "" : "s"}
|
||||
</Button>
|
||||
<Select
|
||||
size="sm"
|
||||
w={280}
|
||||
searchable
|
||||
clearable
|
||||
placeholder="Or pick another train…"
|
||||
maxDropdownHeight={350}
|
||||
data={trainOptions}
|
||||
value={targetTrainId}
|
||||
onChange={setTargetTrainId}
|
||||
nothingFoundMessage="No other built trains"
|
||||
/>
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<ArrowRightLeft size={16} />}
|
||||
disabled={selected.size === 0 || !targetTrainId}
|
||||
loading={attachOther.isPending}
|
||||
onClick={() => void handleAttachOther()}
|
||||
>
|
||||
Attach to that train
|
||||
</Button>
|
||||
</Group>
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user