add per-container handling options for hazardous, reefer, and return services

- Introduced new boolean fields (isHazardous, isReefer, isReturn) in UnitDraft and related interfaces to allow individual container handling options.
- Updated emptyUnit function to initialize these new fields.
- Modified GlCreateBookingForm to handle and display these options for each container.
- Adjusted calculations for hazardous, reefer, and return quantities based on the new handling options.
- Updated the schema for container units and booking container lines to include handling options.
- Added migration to support the new return flag in the database.
- Enhanced various components to reflect gross weight calculations, ensuring consistency across the application.
This commit is contained in:
Marshal
2026-07-18 19:20:45 +00:00
parent a7041ee70f
commit 0dead281ce
28 changed files with 585 additions and 250 deletions

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