feat: enhance train scheduling and contract management features

- Added StationWorkControls to manage loading/unloading phases in TrainScheduleV2DetailPage.
- Implemented API endpoints for recording station work and managing wagon detach requests.
- Updated contract templates to include Ethiopian customs handling options.
- Enhanced shipment forms to collect customs clearing agent details for without-customs bookings.
- Introduced NUMBER_OF_WAGONS as a unit of measure for bulk cargo, allowing customers to specify wagon counts.
- Improved validation for customs clearing agent information in shipment forms.
- Updated various components and services to accommodate new features and ensure data integrity.
This commit is contained in:
Marshal
2026-08-25 21:44:21 +00:00
parent d5a5085d6d
commit b926a3116e
67 changed files with 2998 additions and 255 deletions

View File

@@ -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}

View File

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

View File

@@ -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;
}

View File

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

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>
);
}

View File

@@ -481,6 +481,12 @@ export const URL_CONSTANTS = {
FINALIZE: (id: string) => `/train-scheduling/schedules/${id}/finalize`,
DISPATCH: (id: string) => `/train-scheduling/schedules/${id}/dispatch`,
YARD_WORK: (id: string) => `/train-scheduling/schedules/${id}/yard-work`,
STATION_WORK: (
id: string,
yardId: string,
phase: "loading" | "unloading",
edge: "start" | "end",
) => `/train-scheduling/schedules/${id}/stations/${yardId}/${phase}/${edge}`,
BOOKING_LOAD: (id: string, bookingId: string) =>
`/train-scheduling/schedules/${id}/bookings/${bookingId}/load`,
BOOKING_UNLOAD: (id: string, bookingId: string) =>

View File

@@ -107,6 +107,11 @@ export const FREIGHT_PERMS = {
/** Confirm cargo loaded/unloaded at a yard — import, export, and intercity alike. */
load: "edr_freight_app:train_scheduling:load",
unload: "edr_freight_app:train_scheduling:unload",
/** Per-station loading/unloading time-window buttons (start/end pairs). */
loadingStart: "edr_freight_app:train_scheduling:loading_start",
loadingEnd: "edr_freight_app:train_scheduling:loading_end",
unloadingStart: "edr_freight_app:train_scheduling:unloading_start",
unloadingEnd: "edr_freight_app:train_scheduling:unloading_end",
cancel: "edr_freight_app:train_scheduling:cancel",
reschedule: "edr_freight_app:train_scheduling:reschedule",
rulesManage: "edr_freight_app:train_scheduling:rules_manage",
@@ -225,6 +230,8 @@ export const FREIGHT_PERMS = {
changeWagonYard: "edr_freight_app:trains:change_wagon_yard",
toggleActive: "edr_freight_app:trains:toggle_active",
disband: "edr_freight_app:trains:disband",
/** Decide detach/maintenance requests on a SCHEDULED train (4-eyes gate). */
approveWagonDetach: "edr_freight_app:trains:approve_wagon_detach",
},
routes: {
view: "edr_freight_app:routes:view",

View File

@@ -62,15 +62,45 @@ function isBulk(template: ContractTemplate): boolean {
// System container codes are DIRECTION_CONTAINER(_CUSTOMS); intercity is
// domestic and crosses no border, so it has no customs variant at all — hence
// null rather than false, which would wrongly read as a deliberate "client
// clears its own customs" choice.
function customsVariant(template: ContractTemplate): boolean | null {
if (isBulk(template)) return template.withCustoms ?? null;
if (template.code.endsWith("_NO_CUSTOMS")) return false;
if (template.code.endsWith("_CUSTOMS")) return true;
// null rather than "WITHOUT", which would wrongly read as a deliberate "client
// clears its own customs" choice. "ETHIOPIAN" is the with-customs variant
// restricted to Ethiopian-side clearing (Djibouti stays with the client).
type CustomsVariant = "WITH" | "WITHOUT" | "ETHIOPIAN" | null;
function customsVariant(template: ContractTemplate): CustomsVariant {
if (isBulk(template)) {
if (template.withCustoms == null) return null;
if (!template.withCustoms) return "WITHOUT";
return template.ethiopianCustomsOnly ? "ETHIOPIAN" : "WITH";
}
if (template.code.endsWith("_NO_CUSTOMS")) return "WITHOUT";
if (template.code.endsWith("_ETHIOPIAN_CUSTOMS")) return "ETHIOPIAN";
if (template.code.endsWith("_CUSTOMS")) return "WITH";
return null;
}
const CUSTOMS_BADGE: Record<
Exclude<CustomsVariant, null>,
{ label: string; color: string; tooltip: string }
> = {
WITH: {
label: "With customs",
color: "teal",
tooltip: "Used when the contract has customs clearing enabled",
},
ETHIOPIAN: {
label: "Ethiopian customs",
color: "indigo",
tooltip:
"Used when the service type includes Ethiopian customs clearing only — Djibouti clearing stays with the client",
},
WITHOUT: {
label: "No customs",
color: "gray",
tooltip: "Used when the client handles its own customs clearing",
},
};
// Bulk templates carry the direction on the row; the fixed container codes
// carry it as the code prefix.
function directionOf(template: ContractTemplate): string {
@@ -116,7 +146,7 @@ export default function ContractTemplatesPage() {
<PageContainer>
<PageHeader
title="Contract templates"
subtitle="The five container contract documents are built in — one per trade direction and customs-clearing option. Bulk contracts are written per cargo type: create one template per trade direction, customs option and commodity. Articles are fully editable."
subtitle="The container contract documents are built in — one per trade direction and customs-clearing option (with customs, Ethiopian customs only, without). Bulk contracts are written per cargo type: create one template per trade direction, customs option and commodity. Articles are fully editable."
action={
canCreate ? (
<Button
@@ -285,9 +315,17 @@ function CreateTemplateModal({
onChange={setWithCustoms}
data={[
{ value: "true", label: "With customs clearing" },
{ value: "ethiopian", label: "Ethiopian customs only" },
{ value: "false", label: "Without customs clearing" },
]}
/>
{withCustoms === "ethiopian" && (
<Text size="xs" c="dimmed" mt={6}>
Used for service types marked Ethiopian customs only: the
Service Provider clears the Ethiopian side, Djibouti clearing
stays with the client.
</Text>
)}
</div>
)}
@@ -316,8 +354,15 @@ function CreateTemplateModal({
{
cargoTypeId,
tradeDirection: direction,
// Omitted for intercity — the API rejects the flag there.
...(intercity ? {} : { withCustoms: withCustoms === "true" }),
// Omitted for intercity — the API rejects the flags there.
...(intercity
? {}
: {
withCustoms: withCustoms !== "false",
...(withCustoms === "ethiopian"
? { ethiopianCustomsOnly: true }
: {}),
}),
},
{
onSuccess: (template) =>
@@ -394,16 +439,9 @@ function TemplateCard({
</Group>
<Group gap={6} wrap="nowrap">
{customs !== null && (
<Tooltip
label={
customs
? "Used when the contract has customs clearing enabled"
: "Used when the client handles its own customs clearing"
}
withArrow
>
<Badge size="sm" variant="light" color={customs ? "teal" : "gray"}>
{customs ? "With customs" : "No customs"}
<Tooltip label={CUSTOMS_BADGE[customs].tooltip} withArrow>
<Badge size="sm" variant="light" color={CUSTOMS_BADGE[customs].color}>
{CUSTOMS_BADGE[customs].label}
</Badge>
</Tooltip>
)}

View File

@@ -95,6 +95,7 @@ const FORM_FIELDS: FormFieldDef[] = [
{ label: "None", value: RULE_ENGINE_SELECT_NONE },
{ label: "Per ton (bulk)", value: "PER_TON" },
{ label: "Per item (break-bulk)", value: "PER_ITEM" },
{ label: "Based on number of wagons", value: "NUMBER_OF_WAGONS" },
],
},
{
@@ -597,7 +598,11 @@ function CargoRow({
{node.unitOfMeasure ? (
<Tooltip label="How bookings measure this cargo" withArrow>
<Badge size="xs" variant="light" color="teal" radius="sm">
{node.unitOfMeasure === "PER_ITEM" ? "Per item" : "Per ton"}
{node.unitOfMeasure === "PER_ITEM"
? "Per item"
: node.unitOfMeasure === "NUMBER_OF_WAGONS"
? "By wagons"
: "Per ton"}
</Badge>
</Tooltip>
) : null}

View File

@@ -12,6 +12,7 @@ import {
Tabs,
Text,
Textarea,
Tooltip,
} from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { isAxiosError } from "axios";
@@ -55,7 +56,10 @@ import { api } from "@/services/api";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { useToast } from "@/hooks/use-toast";
import type { TrainCompositionWagon } from "@/services/trainBuilder.service";
import type {
TrainCompositionWagon,
WagonDetachRequestRow,
} from "@/services/trainBuilder.service";
const parseError = (error: unknown, fallback: string) => {
if (isAxiosError(error)) {
@@ -95,8 +99,26 @@ export default function TrainBuilderDetailPage() {
setMaintenanceTarget(null);
setMaintenanceNote("");
};
// Detach-approval flow: on a SCHEDULED run, detach/maintenance is filed as a
// request (with reason) and executed by a second staffer's approval.
const [requestTarget, setRequestTarget] = useState<{
wagon: TrainCompositionWagon;
action: "DETACH" | "MAINTENANCE";
} | null>(null);
const [requestReason, setRequestReason] = useState("");
const closeRequest = () => {
setRequestTarget(null);
setRequestReason("");
};
const [rejectTarget, setRejectTarget] = useState<WagonDetachRequestRow | null>(null);
const [rejectNote, setRejectNote] = useState("");
const closeReject = () => {
setRejectTarget(null);
setRejectNote("");
};
const { user } = useAuth();
const canAssign = hasPermission(user, FREIGHT_PERMS.trains.assignWagons);
const canApproveDetach = hasPermission(user, FREIGHT_PERMS.trains.approveWagonDetach);
const canChangeLocomotives = hasPermission(user, FREIGHT_PERMS.trains.changeLocomotives);
const canChangeYard = hasPermission(user, FREIGHT_PERMS.trains.changeYard);
const canChangeWagonYard = hasPermission(user, FREIGHT_PERMS.trains.changeWagonYard);
@@ -121,12 +143,42 @@ export default function TrainBuilderDetailPage() {
api.trainBuilder.sendWagonToMaintenance.mutationOptions(),
);
const reorderWagons = useMutation(api.trainBuilder.reorderWagons.mutationOptions());
const detachRequestsQuery = useQuery(
api.trainBuilder.detachRequests.queryOptions({
input: { id },
enabled: Boolean(id),
}),
);
const createDetachRequest = useMutation(
api.trainBuilder.createDetachRequest.mutationOptions(),
);
const approveDetachRequest = useMutation(
api.trainBuilder.approveDetachRequest.mutationOptions(),
);
const rejectDetachRequest = useMutation(
api.trainBuilder.rejectDetachRequest.mutationOptions(),
);
const disband = useMutation(api.trainBuilder.disband.mutationOptions());
const deactivate = useMutation(api.trainBuilder.deactivate.mutationOptions());
const activate = useMutation(api.trainBuilder.activate.mutationOptions());
const composition = compositionQuery.data;
// Approval kicks in once a run is SCHEDULED. DRAFT stays direct-edit; a
// dispatched train is frozen outright (composition.editable is false).
const requiresDetachApproval = (composition?.activeSchedules ?? []).some(
(s) => s.status === "SCHEDULED",
);
const detachRequests = useMemo(
() => detachRequestsQuery.data ?? [],
[detachRequestsQuery.data],
);
const pendingDetachRequests = detachRequests.filter((r) => r.status === "PENDING");
const pendingWagonIds = useMemo(
() => new Set(detachRequests.filter((r) => r.status === "PENDING").map((r) => r.wagonId)),
[detachRequests],
);
// The diagram memoizes off its `locomotives`/`wagons` props; building those
// arrays inline in JSX would hand it a new identity on every render and
// re-normalize + repaint every car for each keystroke or pending mutation.
@@ -217,15 +269,33 @@ export default function TrainBuilderDetailPage() {
},
[withToast, reorderWagons.mutateAsync, trainId],
);
const wagons = composition?.wagons;
const openDetachRequest = useCallback(
(wagonId: string, action: "DETACH" | "MAINTENANCE") => {
if (pendingWagonIds.has(wagonId)) {
toast({
title: "A detach request for this wagon is already pending approval",
});
return;
}
const wagon = wagons?.find((w) => w.id === wagonId);
if (wagon) setRequestTarget({ wagon, action });
},
[pendingWagonIds, wagons, toast],
);
const handleRemove = useCallback(
(wagonId: string) => {
if (!trainId) return;
if (requiresDetachApproval) {
openDetachRequest(wagonId, "DETACH");
return;
}
void withToast(
() => removeWagon.mutateAsync({ id: trainId, wagonId }),
"Could not detach wagon",
);
},
[withToast, removeWagon.mutateAsync, trainId],
[withToast, removeWagon.mutateAsync, trainId, requiresDetachApproval, openDetachRequest],
);
const handleChangeWagonYard = useCallback(
(wagonId: string, currentYardId: string) => {
@@ -248,8 +318,14 @@ export default function TrainBuilderDetailPage() {
[withToast, setWagonsYard.mutateAsync, trainId],
);
const handleMaintenance = useCallback(
(wagon: TrainCompositionWagon) => setMaintenanceTarget(wagon),
[],
(wagon: TrainCompositionWagon) => {
if (requiresDetachApproval) {
openDetachRequest(wagon.id, "MAINTENANCE");
return;
}
setMaintenanceTarget(wagon);
},
[requiresDetachApproval, openDetachRequest],
);
if (compositionQuery.isLoading) {
@@ -487,6 +563,117 @@ export default function TrainBuilderDetailPage() {
))}
</Group>
{detachRequests.length ? (
<Card>
<Stack gap="sm">
<Group justify="space-between">
<Text fw={600}>Detach approvals</Text>
{pendingDetachRequests.length ? (
<Badge color="yellow" variant="light">
{pendingDetachRequests.length} pending
</Badge>
) : null}
</Group>
<Text size="xs" c="dimmed">
While this train is on a scheduled run, detaching a wagon (or sending it to
maintenance) needs a second staff member's approval. Decided requests stay
here as the audit trail.
</Text>
{detachRequests.map((req) => {
const isOwn = Boolean(req.requestedById && user?.id === req.requestedById);
return (
<Group key={req.id} justify="space-between" align="flex-start" wrap="nowrap">
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Group gap="xs">
<Text size="sm" fw={600} ff="monospace">
{req.wagonNumber}
</Text>
<Badge
size="sm"
variant="light"
color={req.action === "MAINTENANCE" ? "orange" : "red"}
>
{req.action === "MAINTENANCE" ? "To maintenance" : "Detach"}
</Badge>
<Badge
size="sm"
variant="light"
color={
req.status === "PENDING"
? "yellow"
: req.status === "APPROVED"
? "green"
: "gray"
}
>
{req.status}
</Badge>
</Group>
<Text size="xs" c="dimmed">
Requested by {req.requestedBy ?? "unknown"} ·{" "}
{new Date(req.requestedAt).toLocaleString()} — {req.reason}
</Text>
{req.status !== "PENDING" ? (
<Text size="xs" c="dimmed">
{req.status === "APPROVED" ? "Approved" : "Rejected"} by{" "}
{req.decidedBy ?? "unknown"}
{req.decidedAt ? ` · ${new Date(req.decidedAt).toLocaleString()}` : ""}
{req.decisionNote ? ` — ${req.decisionNote}` : ""}
</Text>
) : null}
</Stack>
{req.status === "PENDING" && canApproveDetach ? (
<Group gap="xs" wrap="nowrap">
<Tooltip
label="You filed this request — a different staff member must approve it"
disabled={!isOwn}
withArrow
>
<Button
size="compact-sm"
color="green"
disabled={isOwn}
loading={approveDetachRequest.isPending}
onClick={() =>
void withToast(async () => {
await approveDetachRequest.mutateAsync({
id: composition.id,
requestId: req.id,
});
toast({
title: `Wagon ${req.wagonNumber} ${
req.action === "MAINTENANCE"
? "sent to maintenance"
: "detached"
}`,
});
}, "Could not approve request")
}
>
Approve
</Button>
</Tooltip>
<Button
size="compact-sm"
variant="light"
color="red"
onClick={() => setRejectTarget(req)}
>
Reject
</Button>
</Group>
) : req.status === "PENDING" ? (
<Text size="xs" c="dimmed">
Awaiting approval
</Text>
) : null}
</Group>
);
})}
</Stack>
</Card>
) : null}
<Stack gap="sm">
<TrainCompositionDiagram
locomotives={diagramLocomotives}
@@ -681,6 +868,129 @@ export default function TrainBuilderDetailPage() {
</Stack>
</Modal>
<Modal
opened={Boolean(requestTarget)}
onClose={closeRequest}
title={
<Text fw={600}>
{requestTarget?.action === "MAINTENANCE"
? "Request maintenance approval?"
: "Request detach approval?"}
</Text>
}
radius="lg"
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Train{" "}
<Text span fw={700} c="dark">
{trainRunLabel}
</Text>{" "}
is on a scheduled run, so wagon{" "}
<Text span fw={700} ff="monospace" c="dark">
{requestTarget?.wagon.wagonNumber}
</Text>{" "}
is not detached now your request goes to a staff member with approval
rights, and the{" "}
{requestTarget?.action === "MAINTENANCE" ? "maintenance move" : "detach"}{" "}
happens the moment they approve it.
</Text>
<Textarea
label="Reason"
placeholder="Why must this wagon leave the scheduled consist? (required)"
value={requestReason}
onChange={(e) => setRequestReason(e.currentTarget.value)}
autosize
minRows={2}
required
/>
<Group justify="flex-end">
<Button variant="default" onClick={closeRequest}>
Keep in consist
</Button>
<Button
color={requestTarget?.action === "MAINTENANCE" ? "orange" : "red"}
leftSection={
requestTarget?.action === "MAINTENANCE" ? (
<Wrench size={16} />
) : (
<Trash2 size={16} />
)
}
disabled={!requestReason.trim()}
loading={createDetachRequest.isPending}
onClick={() =>
void withToast(async () => {
await createDetachRequest.mutateAsync({
id: composition.id,
wagonId: requestTarget!.wagon.id,
action: requestTarget!.action,
reason: requestReason.trim(),
});
toast({
title: `Request for wagon ${requestTarget!.wagon.wagonNumber} filed — awaiting approval`,
});
closeRequest();
}, "Could not file the request")
}
>
Request approval
</Button>
</Group>
</Stack>
</Modal>
<Modal
opened={Boolean(rejectTarget)}
onClose={closeReject}
title={<Text fw={600}>Reject this request?</Text>}
radius="lg"
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Wagon{" "}
<Text span fw={700} ff="monospace" c="dark">
{rejectTarget?.wagonNumber}
</Text>{" "}
stays in the consist. The requester sees your note in the request history.
</Text>
<Textarea
label="Why is it rejected?"
placeholder="Required"
value={rejectNote}
onChange={(e) => setRejectNote(e.currentTarget.value)}
autosize
minRows={2}
required
/>
<Group justify="flex-end">
<Button variant="default" onClick={closeReject}>
Cancel
</Button>
<Button
color="red"
disabled={!rejectNote.trim()}
loading={rejectDetachRequest.isPending}
onClick={() =>
void withToast(async () => {
await rejectDetachRequest.mutateAsync({
id: composition.id,
requestId: rejectTarget!.id,
note: rejectNote.trim(),
});
toast({ title: `Request for wagon ${rejectTarget!.wagonNumber} rejected` });
closeReject();
}, "Could not reject the request")
}
>
Reject request
</Button>
</Group>
</Stack>
</Modal>
<Modal
opened={deactivateOpen}
onClose={() => setDeactivateOpen(false)}

View File

@@ -17,6 +17,7 @@ import {
Text,
ThemeIcon,
Title,
Tooltip,
} from "@mantine/core";
import { isAxiosError } from "axios";
import {
@@ -65,6 +66,7 @@ import { LegLoadBoardPanel } from "@/components/trainScheduling/LegLoadBoardPane
import { LoadEmptyContainersModal } from "@/components/trainScheduling/LoadEmptyContainersModal";
import MergeScheduleTrainModal from "@/components/trainScheduling/MergeScheduleTrainModal";
import ScheduleHistoryPanel from "@/components/trainScheduling/ScheduleHistoryPanel";
import { StationWorkControls } from "@/components/trainScheduling/StationWorkControls";
// import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
@@ -490,6 +492,17 @@ export default function TrainScheduleV2DetailPage() {
const dispatchLeftCount = pendingOriginBoarders.filter(
(b) => !b.isGovernment && !dispatchLoadedIds.has(b.id),
).length;
// Origin loading time window: dispatch (which marks the ticked boarders
// loaded) is server-rejected until "Start loading" was clicked for the
// origin yard, so the button mirrors that gate.
const originLoadingLog = originYardId
? schedule.stationWorkLogs?.[originYardId]?.loading
: undefined;
const originLoadingStarted = Boolean(originLoadingLog?.startedAt);
const dispatchBoardersKept = pendingOriginBoarders.some(
(b) => b.isGovernment || dispatchLoadedIds.has(b.id),
);
const dispatchNeedsLoadingStart = dispatchBoardersKept && !originLoadingStarted;
const finalizeStep = hasContainerStep ? 3 : 2;
const canModifyBookings = canEditBookings && !["DISPATCHED", "ARRIVED"].includes(schedule.status);
@@ -936,6 +949,27 @@ export default function TrainScheduleV2DetailPage() {
</Stack>
</Group>
</Paper>
{originYardId ? (
<Paper p="md" radius="lg" withBorder>
<Stack gap={6}>
<Text fw={600} size="sm">
Loading at {schedule.originStation?.label ?? "the origin yard"}
</Text>
<StationWorkControls
scheduleId={scheduleId}
yardId={originYardId}
phase="loading"
log={originLoadingLog}
/>
{dispatchNeedsLoadingStart ? (
<Text size="xs" c="dimmed">
Start loading before dispatching the ticked bookings are marked
loaded at dispatch, which needs an open loading window.
</Text>
) : null}
</Stack>
</Paper>
) : null}
<Group>
{canDispatch ? (
<Button
@@ -1560,6 +1594,14 @@ export default function TrainScheduleV2DetailPage() {
Cargo boarding at {schedule.originStation?.label ?? "the origin yard"}
tick what was loaded
</Text>
{originYardId ? (
<StationWorkControls
scheduleId={scheduleId}
yardId={originYardId}
phase="loading"
log={originLoadingLog}
/>
) : null}
<Text size="xs" c="dimmed">
Unticked bookings are left behind: removed from this train, their
wagons freed, and the booking returned to the pool for a later
@@ -1656,14 +1698,20 @@ export default function TrainScheduleV2DetailPage() {
>
Cancel
</Button>
<Button
color="edr-green"
leftSection={<Send size={16} />}
loading={dispatch.isPending}
onClick={() => void runDispatch()}
<Tooltip
label="Start loading at the origin station first — dispatch marks the ticked bookings loaded"
disabled={!dispatchNeedsLoadingStart}
>
{hasDispatchWarnings ? "Dispatch anyway" : "Dispatch train"}
</Button>
<Button
color="edr-green"
leftSection={<Send size={16} />}
loading={dispatch.isPending}
disabled={dispatchNeedsLoadingStart}
onClick={() => void runDispatch()}
>
{hasDispatchWarnings ? "Dispatch anyway" : "Dispatch train"}
</Button>
</Tooltip>
</Group>
</Stack>
</Modal>

View File

@@ -241,6 +241,7 @@ import {
type TrainComposition,
type UpdateTrainDetailsPayload,
type UsedTrainNumbers,
type WagonDetachRequestRow,
} from "./trainBuilder.service";
import {
trainSchedulingService,
@@ -897,6 +898,24 @@ export const api = {
({ scheduleId }) => ["train-scheduling", "yard-work", scheduleId],
),
recordStationWork: endpoint<
{
scheduleId: string;
yardId: string;
phase: "loading" | "unloading";
edge: "start" | "end";
at?: string;
},
import("@/types/trainScheduling").StationWorkPhaseLog
>(
"train-scheduling",
"station-work",
({ scheduleId, yardId, phase, edge, at }) =>
trainSchedulingService.recordStationWork(scheduleId, yardId, phase, edge, at),
undefined,
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
loadScheduleBooking: endpoint<
{ scheduleId: string; bookingId: string },
import("@/types/trainScheduling").BookingLoadResult
@@ -2248,6 +2267,51 @@ export const api = {
seedComposition,
),
// Key derives to ["train-builder", "detachRequests", input] — the shared
// TRAIN_BUILDER.ROOT invalidation refreshes it after every consist edit.
detachRequests: endpoint<{ id: string }, WagonDetachRequestRow[]>(
"train-builder",
"detachRequests",
({ id }) => trainBuilderService.detachRequests(id).then((r) => r.data),
),
createDetachRequest: endpoint<
{ id: string; wagonId: string; action: "DETACH" | "MAINTENANCE"; reason: string },
WagonDetachRequestRow
>(
"train-builder",
"createDetachRequest",
({ id, wagonId, action, reason }) =>
trainBuilderService.createDetachRequest(id, wagonId, { action, reason }).then((r) => r.data),
undefined,
() => TRAIN_BUILDER_INVALIDATIONS,
),
approveDetachRequest: endpoint<
{ id: string; requestId: string; note?: string },
TrainComposition
>(
"train-builder",
"approveDetachRequest",
({ id, requestId, note }) =>
trainBuilderService.approveDetachRequest(id, requestId, note).then((r) => r.data),
undefined,
() => TRAIN_BUILDER_WAGON_INVALIDATIONS,
seedComposition,
),
rejectDetachRequest: endpoint<
{ id: string; requestId: string; note: string },
TrainComposition
>(
"train-builder",
"rejectDetachRequest",
({ id, requestId, note }) =>
trainBuilderService.rejectDetachRequest(id, requestId, note).then((r) => r.data),
undefined,
() => TRAIN_BUILDER_INVALIDATIONS,
),
reorderWagons: endpoint<{ id: string; wagonIds: string[] }, TrainComposition>(
"train-builder",
"reorderWagons",

View File

@@ -34,7 +34,12 @@ export interface ContractTemplate {
* Null for intercity — domestic movements have no customs leg.
*/
withCustoms?: boolean | null;
/** The five seeded container templates — cannot be deleted. */
/**
* Bulk templates only: the with-customs variant restricted to Ethiopian-side
* clearing (Djibouti stays with the client).
*/
ethiopianCustomsOnly?: boolean | null;
/** The seeded container templates — cannot be deleted. */
isSystem: boolean;
createdAt: string;
updatedAt: string;
@@ -45,6 +50,8 @@ export interface CreateContractTemplatePayload {
tradeDirection: BulkTemplateDirection;
/** Omitted for INTERCITY — the API rejects the flag there. */
withCustoms?: boolean;
/** Ethiopian-side clearing only; requires withCustoms: true. */
ethiopianCustomsOnly?: boolean;
name?: string;
description?: string;
}

View File

@@ -386,6 +386,22 @@ export interface UpdateScheduleWagonYardsPayload {
export type UpdateScheduleWagonYardsResult = ScheduleWagonYards & { warnings: string[] };
/** One detach/maintenance approval request — pending or decided (audit trail). */
export interface WagonDetachRequestRow {
id: string;
wagonId: string;
wagonNumber: string;
action: "DETACH" | "MAINTENANCE";
reason: string;
status: "PENDING" | "APPROVED" | "REJECTED";
requestedById: string | null;
requestedBy: string | null;
requestedAt: string;
decidedBy: string | null;
decidedAt: string | null;
decisionNote: string | null;
}
export const trainBuilderService = {
list: (filters: BuiltTrainListFilters = {}) =>
apiClient.get<BuiltTrainListResponse>(`${BASE}${toQuery(filters)}`),
@@ -428,6 +444,29 @@ export const trainBuilderService = {
apiClient.post<TrainComposition>(`${BASE}/${id}/wagons/${wagonId}/maintenance`, {
note,
}),
/** Requests to detach a wagon from a SCHEDULED train, newest first. */
detachRequests: (id: string) =>
apiClient.get<WagonDetachRequestRow[]>(`${BASE}/${id}/detach-requests`),
/** File a detach/maintenance approval request (reason required). */
createDetachRequest: (
id: string,
wagonId: string,
payload: { action: "DETACH" | "MAINTENANCE"; reason: string },
) =>
apiClient.post<WagonDetachRequestRow>(
`${BASE}/${id}/wagons/${wagonId}/detach-requests`,
payload,
),
/** Approve a pending request — executes the detach immediately. */
approveDetachRequest: (id: string, requestId: string, note?: string) =>
apiClient.post<TrainComposition>(`${BASE}/${id}/detach-requests/${requestId}/approve`, {
note,
}),
/** Reject a pending request — a note explaining why is required. */
rejectDetachRequest: (id: string, requestId: string, note: string) =>
apiClient.post<TrainComposition>(`${BASE}/${id}/detach-requests/${requestId}/reject`, {
note,
}),
reorderWagons: (id: string, wagonIds: string[]) =>
apiClient.post<TrainComposition>(`${BASE}/${id}/reorder-wagons`, { wagonIds }),
/** Park the train indefinitely — only allowed with no active schedule. */

View File

@@ -471,6 +471,23 @@ export const trainSchedulingService = {
return unwrap(response.data);
},
/** Start/end (or correct, via `at`) a station's loading/unloading time window. */
recordStationWork: async (
scheduleId: string,
yardId: string,
phase: "loading" | "unloading",
edge: "start" | "end",
at?: string,
): Promise<import("@/types/trainScheduling").StationWorkPhaseLog> => {
const response = await client.post<
import("@/types/trainScheduling").StationWorkPhaseLog
>(
URL_CONSTANTS.TRAIN_SCHEDULING.STATION_WORK(scheduleId, yardId, phase, edge),
at ? { at } : {},
);
return unwrap(response.data);
},
loadScheduleBooking: async (
scheduleId: string,
bookingId: string,

View File

@@ -234,6 +234,8 @@ export interface BookingDetail {
equipmentReturn?: string;
customsClearingEnabled?: boolean;
customsClearingAgent?: string | null;
customsClearingAgentEmail?: string | null;
customsClearingAgentPhone?: string | null;
/** ET clearance queue: every required document approved (pre-finalize). */
allDocsApproved?: boolean;
/** ET clearance queue: a customer document is PENDING or QUERIED. */

View File

@@ -778,6 +778,8 @@ export interface TrainScheduleDetail {
}>;
/** Ordered corridor stops (route milestones) — for per-segment occupancy. */
stops?: Array<{ yardId: string; label: string }>;
/** Per-yard loading/unloading time windows (start/end operator clicks). */
stationWorkLogs?: Record<string, StationWorkLog>;
/** Loco pull ceiling incl. overage tolerance — per-leg gross is held to it. */
maxGrossWeightTons?: number | null;
/** Train length ceiling incl. overage tolerance — per-leg length is held to it. */
@@ -892,6 +894,19 @@ export interface TrackStation {
code: string;
}
/** One clicked loading or unloading window at a yard (ISO timestamps). */
export interface StationWorkPhaseLog {
startedAt?: string | null;
endedAt?: string | null;
startedByUserId?: string | null;
endedByUserId?: string | null;
}
export interface StationWorkLog {
loading?: StationWorkPhaseLog;
unloading?: StationWorkPhaseLog;
}
export interface TrainCheckpoint {
id: string;
sequenceNo: number;
@@ -912,6 +927,8 @@ export interface TrainTrackResponse {
origin: string | null;
destination: string | null;
stations: TrackStation[];
/** Per-yard loading/unloading time windows (start/end operator clicks). */
stationWorkLogs?: Record<string, StationWorkLog>;
currentSequenceNo: number;
checkpoints: TrainCheckpoint[];
}
@@ -1160,6 +1177,8 @@ export interface YardWorkResult {
scheduleId: string;
scheduleStatus: string;
trainAtYardId: string | null;
/** Per-yard loading/unloading time windows (start/end operator clicks). */
stationWorkLogs?: Record<string, StationWorkLog>;
yards: YardWorkYard[];
}

View File

@@ -648,14 +648,9 @@ export default function NewContractPage({
: {}),
// Customs bundling is a property of the chosen service, not of a stored
// form flag — derive it here so stale drafts can't misreport it. A
// non-bundled contract still records the customer's own clearing agent.
...(serviceType?.includesCustoms
? { customsClearingEnabled: true }
: {
customsClearingEnabled: false,
customsClearingAgent:
data.customsClearingAgent?.trim() || undefined,
}),
// non-bundled contract collects the clearing agent per booking, at
// booking completion — nothing on the contract.
customsClearingEnabled: Boolean(serviceType?.includesCustoms),
cargoScope,
routes,
};

View File

@@ -239,7 +239,19 @@ export default function NewShipmentPage() {
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",
);
@@ -288,6 +300,10 @@ function mapBookingToShipmentValues(
: "",
withReturn: booking.equipmentReturn === "WITH_RETURN",
cargoDescription: b.cargoFreeText ?? "",
// The agent entered at the first completion stays on a resubmit.
customsClearingAgent: booking.customsClearingAgent ?? "",
customsClearingAgentEmail: booking.customsClearingAgentEmail ?? "",
customsClearingAgentPhone: booking.customsClearingAgentPhone ?? "",
...(b.contractRouteId ? { contractRouteId: b.contractRouteId } : {}),
};
if (contract.freightType === "CONTAINER") {
@@ -317,9 +333,9 @@ function mapBookingToShipmentValues(
.filter((s): s is "20ft" | "40ft" => s === "20ft" || s === "40ft");
values.containers = (sizes.length ? sizes : (["20ft", "40ft"] as const)).map(lineFor);
} else {
const perItem = bulkUnitOfMeasure(contract) === "PER_ITEM";
const uom = bulkUnitOfMeasure(contract);
const amount = Number(b.cargoTotalWeightVgm ?? 0);
if (perItem) {
if (uom === "PER_ITEM") {
values.itemCount = amount ? String(amount) : "";
values.cargoWeightTons =
b.bulkTotalWeightTons != null
@@ -327,6 +343,17 @@ function mapBookingToShipmentValues(
: "";
} else {
values.cargoWeightTons = amount ? String(amount) : "";
if (uom === "NUMBER_OF_WAGONS") {
const wagons = Number(
(b as { bulkRequestedWagons?: number | string | null })
.bulkRequestedWagons ?? 0,
);
const items = Number(
(b as { bulkItemCount?: number | string | null }).bulkItemCount ?? 0,
);
values.requestedWagons = wagons ? String(wagons) : "";
values.itemCount = items ? String(items) : "";
}
}
values.bulkHazardousQuantity = String(Number(b.bulkHazardousQuantity ?? 0));
values.bulkReeferQuantity = String(Number(b.bulkReeferQuantity ?? 0));
@@ -413,6 +440,11 @@ function NewShipmentBookingForm({
// (mirrors the ScheduleStep picker's visibility).
requiresTrain:
contract.tradeDirection === "EXPORT" && Boolean(completeBookingId),
// Without-customs import/export completion collects the customer's own
// clearing agent per booking (this page never renders for a customs
// contract — see the gate above). Intercity has no border to clear.
requiresClearingAgent:
Boolean(completeBookingId) && contract.tradeDirection !== "DOMESTIC",
}),
),
mode: "onChange",
@@ -575,7 +607,20 @@ function NewShipmentBookingForm({
Number(values.bulkReeferQuantity || 0) || undefined,
},
],
...(bulkUnitOfMeasure(contract) === "NUMBER_OF_WAGONS" &&
values.requestedWagons
? { requestedWagons: Number(values.requestedWagons) }
: {}),
}),
// Customer's own clearing agent — collected at completion; the server
// requires all three for a without-customs import/export booking.
...(values.customsClearingAgent?.trim()
? {
customsClearingAgent: values.customsClearingAgent.trim(),
customsClearingAgentEmail: values.customsClearingAgentEmail.trim(),
customsClearingAgentPhone: values.customsClearingAgentPhone.trim(),
}
: {}),
...(values.notes ? { notes: values.notes } : {}),
};
}
@@ -710,6 +755,10 @@ function NewShipmentBookingForm({
)}
<RouteStep form={form} contract={contract} routes={routes} />
<CargoStep form={form} contract={contract} />
{Boolean(completeBookingId) &&
contract.tradeDirection !== "DOMESTIC" && (
<ClearingAgentStep form={form} />
)}
{/* Legacy contracts only — WITH_RETURN contracts capture per-line
return quantities in the cargo step; WITHOUT_RETURN locked it off. */}
{contract.freightType === "CONTAINER" &&
@@ -1410,7 +1459,11 @@ function CargoStep({
const isContainer = contract.freightType === "CONTAINER";
// Break-bulk (PER_ITEM) cargo needs BOTH the item count (which prices it) and
// the total tonnage (which sizes the wagons); PER_TON needs tonnage only.
const isPerItem = bulkUnitOfMeasure(contract) === "PER_ITEM";
// NUMBER_OF_WAGONS needs the tonnage PLUS the wagon count (an optional item
// count may ride along as information).
const bulkUom = bulkUnitOfMeasure(contract);
const isPerItem = bulkUom === "PER_ITEM";
const isByWagons = bulkUom === "NUMBER_OF_WAGONS";
// Sizes enabled by the contract scope.
const sizes = useMemo(
() =>
@@ -1766,7 +1819,9 @@ function CargoStep({
description={
isPerItem
? "Combined weight of all the items — used to work out how many wagons the shipment needs."
: undefined
: isByWagons
? "Spread evenly across the wagons you request below."
: undefined
}
placeholder="e.g. 1200"
min={0}
@@ -1777,6 +1832,47 @@ function CargoStep({
/>
)}
/>
{isByWagons && (
<>
<Controller
name="itemCount"
control={form.control}
render={({ field, fieldState }) => (
<TextInput
{...field}
type="number"
onKeyDown={blockNegative}
label="Number of items (optional)"
placeholder="e.g. 500"
min={0}
step={1}
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
/>
)}
/>
<Controller
name="requestedWagons"
control={form.control}
render={({ field, fieldState }) => (
<TextInput
{...field}
type="number"
onKeyDown={blockNegative}
label="Number of wagons needed *"
description="Your cargo is allocated exactly this many wagons; a per-wagon rate bills this count."
placeholder="e.g. 40"
min={1}
step={1}
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
/>
)}
/>
</>
)}
{contract.isHazardous && (
<Controller
name="bulkHazardousQuantity"
@@ -1892,6 +1988,71 @@ function EquipmentReturnStep({ form }: { form: ShipmentForm }) {
);
}
/**
* Completion of a without-customs import/export booking: the customer names
* their own customs clearing agent per booking — name, email and phone are
* all required (the schema and the server both enforce it).
*/
function ClearingAgentStep({ form }: { form: ShipmentForm }) {
return (
<StepCard>
<StepHeader
icon={<FileText size={22} />}
title="Customs Clearing Agent"
description="Your service does not include customs clearance — enter the agent handling customs for this booking."
/>
<Stack gap="sm">
<Controller
name="customsClearingAgent"
control={form.control}
render={({ field, fieldState }) => (
<TextInput
{...field}
label="Agent name *"
placeholder="Customs clearing agent name"
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
/>
)}
/>
<Group grow align="flex-start">
<Controller
name="customsClearingAgentEmail"
control={form.control}
render={({ field, fieldState }) => (
<TextInput
{...field}
type="email"
label="Agent email *"
placeholder="agent@example.com"
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
/>
)}
/>
<Controller
name="customsClearingAgentPhone"
control={form.control}
render={({ field, fieldState }) => (
<TextInput
{...field}
type="tel"
label="Agent phone *"
placeholder="+251 9…"
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
/>
)}
/>
</Group>
</Stack>
</StepCard>
);
}
function NotesSection({ form }: { form: ShipmentForm }) {
return (
<StepCard>

View File

@@ -107,7 +107,6 @@ export function contractToFormValues(
lng: contract.lastMileDeliveryLng ?? null,
},
customsClearingEnabled: contract.customsClearingEnabled,
customsClearingAgent: contract.customsClearingAgent ?? "",
cargoType: isContainer ? "container" : "bulk",
enabledContainerSizes:

View File

@@ -156,7 +156,6 @@ export const contractFormSchema = z
.enum(["with_return", "without_return"])
.default("without_return"),
customsClearingEnabled: z.boolean().default(false),
customsClearingAgent: z.string().default(""),
// ── Cargo SCOPE (no quantities) ──
cargoType: z.enum(["container", "bulk"], "Select a cargo type."),
@@ -274,7 +273,6 @@ export const initialContractFormValues: DeepPartial<ContractFormValues> = {
lastMile: { enabled: false, deliveryAddress: "", exactLocation: "", lat: null, lng: null },
equipmentReturn: "without_return",
customsClearingEnabled: false,
customsClearingAgent: "",
cargoType: "container",
enabledContainerSizes: [...CONTAINER_SIZES],
@@ -307,7 +305,6 @@ export const contractStepFields: Record<
"serviceTypeId",
"equipmentReturn",
"customsClearingEnabled",
"customsClearingAgent",
"firstMile",
"lastMile",
],

View File

@@ -131,7 +131,6 @@ export function Step1ContractType({
"customsClearingEnabled",
contract.customsClearingEnabled ?? false,
);
form.setValue("customsClearingAgent", contract.customsClearingAgent ?? "");
// ── Route (single route per contract) ──
const routes = contract.routes ?? [];

View File

@@ -4,7 +4,6 @@ import {
Check,
Container,
FileCheck2,
FileText,
Info,
// PackageCheck,
ShieldCheck,
@@ -306,10 +305,6 @@ export function Step2ServiceType({
if (form.getValues("customsClearingEnabled") !== desired) {
form.setValue("customsClearingEnabled", desired, { shouldDirty: true });
}
// A bundled-customs service never carries a customer-named agent.
if (desired && form.getValues("customsClearingAgent")) {
form.setValue("customsClearingAgent", "", { shouldDirty: true });
}
}, [includesCustoms, form]);
// A hidden mile must not leak a stale enabled=true into the payload. The
@@ -357,13 +352,6 @@ export function Step2ServiceType({
// shipment (at booking, or on the shipment request when GL books). Intercity
// still bills in ETB, but that is applied at booking time, not here.
const isIntercity = operationType === "intercity";
useEffect(() => {
// The customs clearing agent field is hidden for intercity — drop any value
// carried over from a draft or an operation-type switch.
if (isIntercity && form.getValues("customsClearingAgent")) {
form.setValue("customsClearingAgent", "", { shouldDirty: true });
}
}, [isIntercity, form]);
return (
<Stack gap={18}>
@@ -566,8 +554,9 @@ export function Step2ServiceType({
)}
{/* Intercity (domestic) moves never cross a border, so no customs
clearing agent is collected. */}
{/* Without bundled customs the customer names their own clearing
agent per booking, at booking completion — nothing to collect
on the contract. Intercity never crosses a border. */}
{isIntercity ? null : includesCustoms ? (
<Box
px={16}
@@ -614,57 +603,7 @@ export function Step2ServiceType({
</Box>
</Group>
</Box>
) : (
<Controller
name="customsClearingAgent"
control={form.control}
render={({ field, fieldState }) => (
<Box
px={16}
py={14}
style={{
borderRadius: 14,
border: "1.5px solid #E6ECF2",
background: "#fff",
}}
>
<Group gap={13} align="flex-start" wrap="nowrap" mb="sm">
<Box
style={{
width: 38,
height: 38,
flexShrink: 0,
borderRadius: 11,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#F1F4F7",
color: "#64748B",
}}
>
<FileText size={18} />
</Box>
<Box>
<Text fz={14} fw={700} c="#10202F">
Customs Clearing Agent
</Text>
<Text fz={12} c="#6B7C8E" style={{ lineHeight: 1.4 }}>
Enter the name of your customs clearing agent for this
contract.
</Text>
</Box>
</Group>
<TextInput
{...field}
placeholder="Customs clearing agent name"
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
/>
</Box>
)}
/>
)}
) : null}
</div>
</Stack>
)}

View File

@@ -216,15 +216,13 @@ export function Step8Review({
// Customs is a property of the chosen service (bundled → Global Logistics),
// not of the stored form flag — a stale draft flag must not misreport it.
// Without bundling, the customer may still name their own clearing agent.
const ownAgent = values.customsClearingAgent?.trim();
// Without bundling, the customer names their own agent per booking, at
// booking completion — nothing is recorded on the contract.
const customsTag: { label: string; color: string } = isIntercity
? { label: "Not applicable · domestic", color: "gray" }
: serviceType?.includesCustoms || values.customsClearingEnabled
? { label: "EDR handles it · Global Logistics", color: "edr-green" }
: ownAgent
? { label: `Own agent · ${ownAgent}`, color: "blue" }
: { label: "Not requested", color: "gray" };
: { label: "Own agent · named per booking", color: "blue" };
// Mirror the step-2 gating: imports never truck the first mile, exports never
// truck the last mile, and a service that doesn't bundle a mile can't have it.

View File

@@ -24,7 +24,7 @@ export interface ShipmentValidationContext {
* per-line "with return" quantity, validated like hazardous/reefer.
*/
withReturnService?: boolean;
unitOfMeasure?: "PER_TON" | "PER_ITEM";
unitOfMeasure?: "PER_TON" | "PER_ITEM" | "NUMBER_OF_WAGONS";
/**
* Intercity (DOMESTIC) shipments ride a passing import/export train that
* staff pick later, so no shipment day is chosen. Defaults to true.
@@ -35,6 +35,12 @@ export interface ShipmentValidationContext {
* customer picks for the chosen day. Defaults to false.
*/
requiresTrain?: boolean;
/**
* Completion of a without-customs import/export booking: the customer's own
* clearing agent (name, email, phone) is required per booking. Defaults to
* false — direct drawdown creates and intercity never collect it.
*/
requiresClearingAgent?: boolean;
}
// ISO 6346: 3-letter owner code + category id (U/J/Z) + 6-digit serial + check digit.
@@ -92,8 +98,15 @@ const shipmentFormBase = z.object({
cargoDescription: z.string().default(""),
cargoWeightTons: z.string().default(""),
itemCount: z.string().default(""),
// NUMBER_OF_WAGONS cargo only: wagons this shipment needs (required then).
requestedWagons: z.string().default(""),
bulkHazardousQuantity: z.string().default("0"),
bulkReeferQuantity: z.string().default("0"),
// Customer's own customs clearing agent — collected per booking when the
// service does not bundle customs (required at completion, see superRefine).
customsClearingAgent: z.string().default(""),
customsClearingAgentEmail: z.string().default(""),
customsClearingAgentPhone: z.string().default(""),
notes: z.string().default(""),
});
@@ -121,6 +134,30 @@ export function createShipmentFormSchema(ctx: ShipmentValidationContext) {
});
}
if (ctx.requiresClearingAgent) {
if (!data.customsClearingAgent.trim()) {
refineCtx.addIssue({
code: "custom",
path: ["customsClearingAgent"],
message: "Enter your customs clearing agent's name.",
});
}
if (!z.email().safeParse(data.customsClearingAgentEmail.trim()).success) {
refineCtx.addIssue({
code: "custom",
path: ["customsClearingAgentEmail"],
message: "Enter a valid email for your clearing agent.",
});
}
if (!data.customsClearingAgentPhone.trim()) {
refineCtx.addIssue({
code: "custom",
path: ["customsClearingAgentPhone"],
message: "Enter your clearing agent's phone number.",
});
}
}
// No default currency — the customer must pick one before submitting.
if (!data.paymentCurrency) {
refineCtx.addIssue({
@@ -264,6 +301,20 @@ export function createShipmentFormSchema(ctx: ShipmentValidationContext) {
}
}
// NUMBER_OF_WAGONS cargo: the wagon count is the customer's order — the
// weight spreads evenly across it (the server also checks each wagon's
// share against wagon capacity).
if (ctx.unitOfMeasure === "NUMBER_OF_WAGONS") {
const wagons = Number(data.requestedWagons || 0);
if (!Number.isInteger(wagons) || wagons < 1) {
refineCtx.addIssue({
code: "custom",
path: ["requestedWagons"],
message: "Enter the number of wagons needed (at least 1).",
});
}
}
const boundBulkPortion = (
on: boolean,
raw: string,
@@ -321,8 +372,12 @@ export const initialShipmentFormValues: DeepPartial<ShipmentFormValues> = {
cargoDescription: "",
cargoWeightTons: "",
itemCount: "",
requestedWagons: "",
bulkHazardousQuantity: "0",
bulkReeferQuantity: "0",
customsClearingAgent: "",
customsClearingAgentEmail: "",
customsClearingAgentPhone: "",
notes: "",
};
@@ -336,6 +391,7 @@ export const shipmentStepFields: Record<
"cargoDescription",
"cargoWeightTons",
"itemCount",
"requestedWagons",
"bulkHazardousQuantity",
"bulkReeferQuantity",
"withReturn",

View File

@@ -35,6 +35,10 @@ export function formatAmount(amount: number | string | null | undefined) {
* PER_TON cargo has no item count and falls back the other way for legacy rows.
*/
function bulkQtyForUnit(values: ShipmentFormValues, unit: string): number {
// NUMBER_OF_WAGONS cargo: a per_wagon rate bills the wagon count the
// customer requested (0 when the cargo is not wagon-requested — the caller
// then skips the line, matching the "shown at real pricing" fallback).
if (unit === "per_wagon") return Number(values.requestedWagons || 0);
return unit === "per_item"
? Number(values.itemCount || 0)
: Number(values.cargoWeightTons || values.itemCount || 0);
@@ -190,7 +194,12 @@ export function computeShipmentTotal(
// amount; 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")) {
if (
lashing &&
(lashing.unit === "per_ton" ||
lashing.unit === "per_item" ||
lashing.unit === "per_wagon")
) {
const qty = bulkQtyForUnit(values, lashing.unit);
if (qty > 0) {
lines.push({
@@ -217,7 +226,11 @@ export function computeShipmentTotal(
cl.unit === "per_wagon"
? Math.ceil(boxes * (cl.containerSize === "40ft" ? 1 : 0.5))
: boxes;
} else if (cl.unit === "per_ton" || cl.unit === "per_item") {
} else if (
cl.unit === "per_ton" ||
cl.unit === "per_item" ||
cl.unit === "per_wagon"
) {
qty = bulkQtyForUnit(values, cl.unit);
} else if (cl.unit === "flat") {
qty = 1;