mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 19:58:11 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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` : "—"}
|
||||
|
||||
@@ -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)`;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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) ?? "∞"}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user