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

add per-container handling options for hazardous, reefer, and return…
This commit is contained in:
marshal
2026-07-18 22:22:46 +03:00
committed by GitHub
28 changed files with 585 additions and 250 deletions

View File

@@ -128,6 +128,10 @@ interface UnitDraft {
containerNumber: string;
sealNumber: string;
vgmTons: string;
/** Handling is per physical container; the line counts roll these up. */
isHazardous: boolean;
isReefer: boolean;
isReturn: boolean;
}
/** Mirrors the portal shipment form's container line: line-level quantity +
@@ -150,7 +154,14 @@ interface BulkDraft {
}
function emptyUnit(): UnitDraft {
return { containerNumber: "", sealNumber: "", vgmTons: "" };
return {
containerNumber: "",
sealNumber: "",
vgmTons: "",
isHazardous: false,
isReefer: false,
isReturn: false,
};
}
function emptyLine(size: string): ContainerLineDraft {
@@ -285,6 +296,20 @@ export default function GlCreateBookingForm() {
// Legacy contracts (no equipment return chosen at creation) keep the old
// booking-level toggle.
const legacyReturnToggle = isContainer && !contract?.equipmentReturn;
/**
* Handling switches offered on each container row — only the services this
* contract was created with, since the server rejects the others.
*/
const handlingColumns = (
[
contract?.isHazardous && { key: "isHazardous", label: "Hazardous" },
contract?.isReefer && { key: "isReefer", label: "Refrigerated" },
contractWithReturn && { key: "isReturn", label: "With return" },
] as Array<false | undefined | { key: keyof UnitDraft; label: string }>
).filter(Boolean) as Array<{
key: "isHazardous" | "isReefer" | "isReturn";
label: string;
}>;
// Intercity shipments ride a passing import/export train staff pick at
// finalize time — no shipment day is chosen and no window gate applies.
const isIntercity = contract?.tradeDirection === "DOMESTIC";
@@ -488,6 +513,18 @@ export default function GlCreateBookingForm() {
enabled: cargoQuery !== null && !isIntercity,
});
/**
* Line handling totals are a roll-up of the per-container switches — the
* count is however many containers ticked each service. Recomputed on every
* unit change so the price estimate and payload follow the switches.
*/
const withDerivedCounts = (line: ContainerLineDraft): ContainerLineDraft => ({
...line,
hazardousQuantity: String(line.units.filter((u) => u.isHazardous).length),
reeferQuantity: String(line.units.filter((u) => u.isReefer).length),
returnQuantity: String(line.units.filter((u) => u.isReturn).length),
});
// Keep the units array length in sync with the entered quantity.
const syncUnits = (lineIdx: number, qty: number) => {
setContainerLines((prev) =>
@@ -496,7 +533,7 @@ export default function GlCreateBookingForm() {
const next = [...line.units];
while (next.length < qty) next.push(emptyUnit());
next.length = Math.max(0, qty);
return { ...line, units: next };
return withDerivedCounts({ ...line, units: next });
}),
);
};
@@ -511,11 +548,16 @@ export default function GlCreateBookingForm() {
unitIdx: number,
patch: Partial<UnitDraft>,
) =>
patchLine(lineIdx, {
units: containerLines[lineIdx].units.map((u, i) =>
i === unitIdx ? { ...u, ...patch } : u,
setContainerLines((prev) =>
prev.map((l, i) =>
i === lineIdx
? withDerivedCounts({
...l,
units: l.units.map((u, j) => (j === unitIdx ? { ...u, ...patch } : u)),
})
: l,
),
});
);
// Same client-side validation as the customer portal shipment form
// (new-shipment-form/schema.ts): ISO container numbers unique within the
@@ -560,10 +602,15 @@ export default function GlCreateBookingForm() {
hazardousQuantity: String(imported.filter((r) => r.hazardous).length),
reeferQuantity: String(imported.filter((r) => r.reefer).length),
returnQuantity: String(imported.filter((r) => r.withReturn).length),
// The spreadsheet marks handling per row — carry it onto the
// container it belongs to rather than collapsing it to a line count.
units: imported.map((r) => ({
containerNumber: r.containerNumber,
sealNumber: r.sealNumber,
vgmTons: String(r.vgmTons),
isHazardous: Boolean(r.hazardous),
isReefer: Boolean(r.reefer),
isReturn: Boolean(r.withReturn),
})),
};
}),
@@ -742,6 +789,11 @@ export default function GlCreateBookingForm() {
containerNumber: u.containerNumber.trim().toUpperCase(),
...(u.sealNumber ? { sealNumber: u.sealNumber } : {}),
vgmTons: Number(u.vgmTons) || 0,
// Per-container handling — the server rolls these into the line
// counts and bills each surcharge on the ticked containers only.
isHazardous: Boolean(u.isHazardous),
isReefer: Boolean(u.isReefer),
...(contractWithReturn ? { isReturn: Boolean(u.isReturn) } : {}),
})),
}));
} else {
@@ -1175,73 +1227,24 @@ export default function GlCreateBookingForm() {
radius={10}
styles={fieldStyles}
/>
{contract.isHazardous && (
<TextInput
type="number"
onKeyDown={blockNegative}
label="Hazardous qty"
min={0}
value={line.hazardousQuantity}
error={
showErrors
? lineErrors[lineIdx]?.hazardousQuantity
: undefined
}
onChange={(e) =>
patchLine(lineIdx, {
hazardousQuantity: e.currentTarget.value,
})
}
radius={10}
styles={fieldStyles}
/>
)}
{contract.isReefer && (
<TextInput
type="number"
onKeyDown={blockNegative}
label="Reefer qty"
min={0}
value={line.reeferQuantity}
error={
showErrors
? lineErrors[lineIdx]?.reeferQuantity
: undefined
}
onChange={(e) =>
patchLine(lineIdx, {
reeferQuantity: e.currentTarget.value,
})
}
radius={10}
styles={fieldStyles}
/>
)}
{contractWithReturn && (
<TextInput
type="number"
onKeyDown={blockNegative}
label="With return qty"
description="Containers EDR returns empty"
min={0}
value={line.returnQuantity}
error={
showErrors
? lineErrors[lineIdx]?.returnQuantity
: undefined
}
onChange={(e) =>
patchLine(lineIdx, {
returnQuantity: e.currentTarget.value,
})
}
radius={10}
styles={fieldStyles}
/>
)}
</Group>
<StepLabel>Per-container details</StepLabel>
{handlingColumns.length > 0 ? (
<Text fz={11} c="dimmed" mt={4}>
Tick the services each individual container needs
charges apply only to the containers ticked
{handlingColumns
.map((col) => {
const count = line.units.filter(
(u) => u[col.key],
).length;
return count > 0 ? ` · ${count} ${col.label.toLowerCase()}` : "";
})
.join("")}
.
</Text>
) : null}
<Stack gap={10} mt={8}>
{line.units.map((unit, unitIdx) => (
<Group key={unitIdx} gap={10} grow align="flex-start">
@@ -1296,6 +1299,22 @@ export default function GlCreateBookingForm() {
radius={10}
styles={fieldStyles}
/>
{handlingColumns.map((col) => (
<Switch
key={col.key}
checked={Boolean(unit[col.key])}
aria-label={`${col.label} — container ${unitIdx + 1}`}
onChange={(e) =>
patchUnit(lineIdx, unitIdx, {
[col.key]: e.currentTarget.checked,
})
}
label={unitIdx === 0 ? col.label : undefined}
labelPosition="right"
size="sm"
mt={unitIdx === 0 ? 26 : 6}
/>
))}
</Group>
))}
</Stack>

View File

@@ -40,17 +40,21 @@ export function PreviewSummary({
summary?: {
totalBookings: number;
totalWeightTons: number;
grossWeightTons?: number;
totalTareTons?: number;
wagonType: string;
wagonsNeeded: number;
totalLengthMeters: number;
};
}) {
if (!summary) return null;
// GROSS — the axis every train limit is spent against.
const gross = summary.grossWeightTons ?? summary.totalWeightTons;
const stats = [
{ label: "Bookings", value: String(summary.totalBookings) },
{ label: "Wagons", value: String(summary.wagonsNeeded) },
{ label: "Wagon type", value: summary.wagonType },
{ label: "Total weight", value: `${summary.totalWeightTons}T` },
{ label: "Gross weight", value: `${gross}T` },
{ label: "Train length", value: `${summary.totalLengthMeters}m` },
];
return (

View File

@@ -99,9 +99,8 @@ function usedWeight(schedule: TrainScheduleDetail): number {
/**
* Pull capacity of the set = the WEAKEST locomotive's max pull weight (0 when
* unknown). The API caps at the weakest loco, not the sum of all locos — a
* consist can only pull as hard as its weakest engine. Note: the API also adds
* the consist tare to the used weight when it checks this cap; tare isn't
* available client-side, so this meter compares cargo-only load against pull.
* consist can only pull as hard as its weakest engine. Both sides of this meter
* are gross: `usedWeight` sums per-booking gross (cargo + wagon tare).
*/
function pullCapacity(schedule: TrainScheduleDetail): number {
const set = schedule.trainSet;

View File

@@ -46,6 +46,8 @@ type NormalizedWagon = {
const CAR_WIDTH = 150; // car body + coupler footprint
const round1 = (n: number) => Math.round(n * 10) / 10;
function normalizeWagon(w: DiagramWagonInput, freightType?: string | null): NormalizedWagon {
const allocations = w.allocations ?? [];
const firstLoad = (
@@ -268,10 +270,11 @@ const CONTAINER_BORDERS = [
];
function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
// GROSS on both sides: cargo + tare vs rated payload + tare.
const grossTons = round1(wagon.assignedWeightTons + wagon.tareWeightTons);
const maxGrossTons = round1(wagon.capacityTons + wagon.tareWeightTons);
const utilization =
wagon.capacityTons > 0
? Math.min(100, Math.round((wagon.assignedWeightTons / wagon.capacityTons) * 100))
: 0;
maxGrossTons > 0 ? Math.min(100, Math.round((grossTons / maxGrossTons) * 100)) : 0;
const accent = wagon.isEmpty ? "gray" : wagon.isBulk ? "orange" : "cyan";
const accentVar = `var(--mantine-color-${accent}-6)`;
@@ -281,8 +284,8 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
wagon.bookingRefs.length ? wagon.bookingRefs.join(", ") : ""
}${
wagon.containerNumbers.length ? `\nContainers: ${wagon.containerNumbers.join(", ")}` : ""
}${wagon.cargoDescription ? `\n${wagon.cargoDescription}` : ""}\nLoad: ${wagon.assignedWeightTons}/${wagon.capacityTons}T (${utilization}%)${
wagon.tareWeightTons ? `\nTare: ${wagon.tareWeightTons}T` : ""
}${wagon.cargoDescription ? `\n${wagon.cargoDescription}` : ""}\nGross: ${grossTons}/${maxGrossTons}T (${utilization}%)\nCargo: ${wagon.assignedWeightTons}T${
wagon.tareWeightTons ? ` · Tare: ${wagon.tareWeightTons}T` : ""
}`;
// container blocks: one per container number (cap visual at 2 = TEU per wagon)
@@ -369,7 +372,7 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
/>
</Box>
<Text size="9px" c="dimmed" ta="center" fw={600}>
{wagon.assignedWeightTons}/{wagon.capacityTons}T
{grossTons}/{maxGrossTons}T
</Text>
</Stack>
) : (
@@ -442,7 +445,7 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
</Text>
{!wagon.isEmpty ? (
<Text size="8px" c="gray.6" fw={700} style={{ whiteSpace: "nowrap" }}>
{wagon.assignedWeightTons}T
{grossTons}T
</Text>
) : null}
</Group>

View File

@@ -6,6 +6,7 @@ type WagonSlot = (WagonPlanRow & { physicalWagonNumber?: string | null }) | {
sequenceNo: number;
capacityTons: number;
assignedWeightTons: number;
tareWeightTons?: number | null;
slotLoadType?: string;
wagonType?: { code: string } | null;
wagonTypeCode?: string;
@@ -21,6 +22,8 @@ type WagonSlot = (WagonPlanRow & { physicalWagonNumber?: string | null }) | {
}>;
};
const round1 = (n: number) => Math.round(n * 10) / 10;
function loadTypeColor(loadType: string | undefined, freightType?: string | null) {
const normalized = loadType?.toUpperCase() ?? "";
if (normalized.includes("BULK")) return "orange";
@@ -68,8 +71,16 @@ export function WagonPlanGrid({
);
}
const totalCapacity = wagonPlan.reduce((sum, w) => sum + w.capacityTons, 0);
const totalAssigned = wagonPlan.reduce((sum, w) => sum + w.assignedWeightTons, 0);
// GROSS on both sides: cargo + tare vs rated payload + tare.
const totalTare = round1(
wagonPlan.reduce((sum, w) => sum + (Number(w.tareWeightTons) || 0), 0),
);
const totalCapacity = round1(
wagonPlan.reduce((sum, w) => sum + w.capacityTons, 0) + totalTare,
);
const totalAssigned = round1(
wagonPlan.reduce((sum, w) => sum + w.assignedWeightTons, 0) + totalTare,
);
const usedSlots = wagonPlan.filter((w) => (w.allocations?.length ?? 0) > 0).length;
const isBulk = freightType === "BULK" || wagonPlan.every((w) => w.slotLoadType === "BULK" || (!w.slotLoadType && w.allocations?.[0]?.loadType === "Bulk"));
@@ -82,7 +93,7 @@ export function WagonPlanGrid({
</Text>
{isBulk ? (
<Text size="sm" c="dimmed">
Load: <strong>{totalAssigned}</strong> / {totalCapacity}T
Gross: <strong>{totalAssigned}</strong> / {totalCapacity}T
</Text>
) : null}
</Group>
@@ -90,8 +101,9 @@ export function WagonPlanGrid({
<SimpleGrid cols={{ base: 1, sm: 2, xl: 3 }} spacing="md">
{wagonPlan.map((wagon) => {
const seq = wagon.sequenceNo;
const capacity = wagon.capacityTons;
const assigned = wagon.assignedWeightTons;
const tare = Number(wagon.tareWeightTons) || 0;
const capacity = round1(wagon.capacityTons + tare);
const assigned = round1(wagon.assignedWeightTons + tare);
const allocations = wagon.allocations ?? [];
const utilization = capacity > 0 ? Math.min(100, Math.round((assigned / capacity) * 100)) : 0;
const label = slotLabel(wagon, freightType);
@@ -149,7 +161,7 @@ export function WagonPlanGrid({
</Text>
{label === "BULK" ? (
<Text size="xs" c="dimmed">
{alloc.allocatedWeightTons}T
{alloc.allocatedWeightTons}T cargo
</Text>
) : null}
</Group>

View File

@@ -132,7 +132,7 @@ export const BookingDetailModal = ({
/>
<InfoRow
icon={<Weight size={15} />}
label="Weight"
label="Gross weight"
value={
<Text size="sm" fw={700}>
{booking.weightTons != null ? `${booking.weightTons.toFixed(1)} T` : "—"}

View File

@@ -161,8 +161,11 @@ function WagonCar({
const allocation = wagon.allocations?.[0];
const isEmpty = !allocation;
const isBulk = (allocation?.loadType ?? "").toUpperCase().includes("BULK");
const assigned = allocation?.allocatedWeightTons ?? wagon.assignedWeightTons ?? 0;
const capacity = wagon.capacityTons ?? 0;
// GROSS on both sides: cargo + tare vs rated payload + tare.
const tare = wagon.tareWeightTons ?? 0;
const assigned =
(allocation?.allocatedWeightTons ?? wagon.assignedWeightTons ?? 0) + tare;
const capacity = (wagon.capacityTons ?? 0) + tare;
const utilization = capacity > 0 ? Math.min(100, Math.round((assigned / capacity) * 100)) : 0;
const accent = isEmpty ? "gray" : isBulk ? "orange" : "cyan";
const accentVar = `var(--mantine-color-${accent}-6)`;

View File

@@ -21,6 +21,8 @@ export const RemoveBookingModal = ({
if (!wagon || !wagon.allocations?.[0]) return null;
const allocation = wagon.allocations[0];
// GROSS: allocated cargo + the tare of the wagon it sits on.
const grossTons = (allocation.allocatedWeightTons ?? 0) + (wagon.tareWeightTons ?? 0);
return (
<Modal opened={opened} onClose={onClose} title="Confirm Booking Removal" centered>
@@ -40,7 +42,7 @@ export const RemoveBookingModal = ({
</Badge>
</Text>
<Text size="sm">
<strong>Weight:</strong> {allocation.allocatedWeightTons?.toFixed(2) || 0} T
<strong>Gross weight:</strong> {grossTons.toFixed(2)} T
</Text>
<Text size="sm">
<strong>Wagon Slot:</strong> #{wagon.sequenceNo}

View File

@@ -93,10 +93,14 @@ export const TrainConsistView = ({
}
};
const weightUsed = wagons.reduce(
(sum, w) => sum + (w.allocations?.[0]?.allocatedWeightTons ?? 0),
// GROSS: cargo on every allocation + the tare of every wagon in the consist.
// maxPullWeightTons is a gross limit, so the numerator must be gross too.
const cargoUsed = wagons.reduce(
(sum, w) => sum + (w.allocations ?? []).reduce((a, al) => a + (al.allocatedWeightTons ?? 0), 0),
0,
);
const tareUsed = wagons.reduce((sum, w) => sum + (w.tareWeightTons ?? 0), 0);
const weightUsed = cargoUsed + tareUsed;
const lengthUsed = wagons.reduce((sum, w) => sum + (w.lengthMeters ?? 0), 0);
return (

View File

@@ -98,7 +98,7 @@ export const TrainStatsBar = ({
<SimpleGrid cols={{ base: 1, xs: 3 }} spacing="lg">
<StatTile
icon={<Weight size={15} />}
label="Weight"
label="Gross weight"
pct={weightPct}
current={weightUsed.toFixed(1)}
max={weightMax?.toFixed(1) ?? "∞"}

View File

@@ -130,7 +130,9 @@ export const UnassignedBookingsPanel = ({
{bookings.map((booking) => {
const isActive = selectedBookingId === booking.id;
const weight = Number(booking.cargoTotalWeightVgm ?? 0);
// GROSS (cargo + wagon tare) so this badge shares the axis every other
// weight on the page uses — cargo-only here read ~25% light.
const weight = Number(booking.grossWeightTons ?? booking.cargoTotalWeightVgm ?? 0);
const fits = booking.canAssign;
const blockReason = booking.blockReason;

View File

@@ -36,8 +36,12 @@ export const WagonCard = ({
const hasAllocations = Boolean(allocation);
const isBulk = (allocation?.loadType ?? "").toUpperCase().includes("BULK");
const weightUsed = allocation?.allocatedWeightTons ?? 0;
const weightMax = wagon.capacityTons ?? 0;
// GROSS on both sides: loaded cargo + wagon tare, against the wagon's max
// gross (rated payload + tare). Keeps the wagon axis identical to the train
// axis in TrainStatsBar.
const tare = wagon.tareWeightTons ?? 0;
const weightUsed = (allocation?.allocatedWeightTons ?? 0) + tare;
const weightMax = (wagon.capacityTons ?? 0) + tare;
const weightPercent = weightMax ? (weightUsed / weightMax) * 100 : 0;
const wagonType = wagon.wagonType?.code || "UNKNOWN";

View File

@@ -134,7 +134,11 @@ export interface TrainSchedulePreviewResponse {
deferredBookings?: DeferredBookingRow[];
summary: {
totalBookings: number;
/** Cargo VGM only — display gross instead. */
totalWeightTons: number;
/** GROSS: cargo + the tare of every wagon in the plan. */
grossWeightTons: number;
totalTareTons: number;
wagonType: string;
wagonsNeeded: number;
totalLengthMeters: number;
@@ -845,6 +849,8 @@ export interface CompositionUnassignedBooking {
freightType: FreightType | null;
priorityScore: number;
cargoTotalWeightVgm: number;
/** GROSS: cargo VGM + tare of every wagon the booking occupies. */
grossWeightTons: number;
status: string | null;
schedulingStatus: SchedulingStatus | null;
wagonsRequired: number;