feat(bookings): show allocated wagons in portal

This commit is contained in:
Marshal
2026-08-06 20:25:08 +00:00
parent 7db03ea347
commit 4eb0d56faf
5 changed files with 571 additions and 0 deletions

View File

@@ -496,6 +496,22 @@ export class BookingsController {
res.send(buffer);
}
@Get(':id/wagons')
@ApiOperation({
summary:
'Allocated wagons for a booking (JSON) — empty until the paid booking is placed on a train',
})
async wagonAllocations(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
return this.bookingsService.wagonAllocations(id);
}
@Get(':id/customer-trucks')
@ApiOperation({ summary: 'List customer self-haul trucks (multi-truck) for a booking' })
async listCustomerTrucks(

View File

@@ -339,6 +339,63 @@ export class BookingsService {
};
}
/**
* Allocated wagons of a booking as JSON — the portal's "Wagons" tab. Same
* join chain as the carriage acceptance sheet, but structured (containers as
* an array per wagon, bulk load description when the wagon carries bulk).
* Empty array until the booking has been allocated onto a train.
*/
async wagonAllocations(bookingId: string): Promise<unknown[]> {
return this.dataSource.query(
`SELECT tsw.sequence_no AS "sequenceNo",
w.wagon_number AS "wagonNumber",
COALESCE(wt.name, wt.code) AS "wagonType",
wt.code AS "wagonTypeCode",
wt.tare_weight_tons AS "tareWeightTons",
tsw.capacity_tons AS "capacityTons",
tsw.length_meters AS "lengthMeters",
a.allocated_weight_tons AS "allocatedWeightTons",
a.load_type AS "loadType",
a.status AS "status",
s.train_number AS "trainNumber",
s.scheduled_departure_date AS "departureAt",
so.label AS "originStation",
sd.label AS "destinationStation",
bl.cargo_description AS "bulkCargoDescription",
bl.quantity AS "bulkQuantity",
COALESCE(
json_agg(
json_build_object(
'containerNumber', ci.container_number,
'sealNumber', ci.seal_number,
'positionOnWagon', ci.position_on_wagon,
'grossWeightTons', ci.gross_weight_tons
) ORDER BY ci.position_on_wagon, ci.container_number
) FILTER (WHERE ci.id IS NOT NULL),
'[]'
) AS "containers"
FROM freight.wagon_booking_allocations a
JOIN freight.train_set_wagons tsw
ON tsw.id = a.train_set_wagon_id AND tsw.deleted_at IS NULL
LEFT JOIN freight.wagon_types wt ON wt.id = tsw.wagon_type_id
LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id
LEFT JOIN freight.train_schedules s
ON s.train_set_id = tsw.train_set_id AND s.deleted_at IS NULL
LEFT JOIN freight.yards so ON so.id = s.origin_station_id
LEFT JOIN freight.yards sd ON sd.id = s.destination_station_id
LEFT JOIN freight.wagon_allocation_container_items ci
ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL
LEFT JOIN freight.wagon_allocation_bulk_loads bl
ON bl.wagon_booking_allocation_id = a.id AND bl.deleted_at IS NULL
WHERE a.booking_id = $1 AND a.deleted_at IS NULL
GROUP BY tsw.id, a.id, w.wagon_number, wt.name, wt.code, wt.tare_weight_tons,
s.train_number, s.scheduled_departure_date, so.label, sd.label,
bl.cargo_description, bl.quantity
ORDER BY tsw.sequence_no`,
[bookingId],
);
}
/**
* Split the booking amount across its wagons, proportional to allocated weight
* (equal shares when no weights are recorded). The last row absorbs the rounding

View File

@@ -6,6 +6,7 @@ import {
FileText,
LayoutGrid,
Package,
TrainFront,
Truck,
XCircle,
} from "lucide-react";
@@ -46,6 +47,7 @@ import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard";
import { ShipmentTrackingCard } from "./components/ShipmentTrackingCard";
import { StatusHero } from "./components/StatusHero";
import { SupportCard } from "./components/SupportCard";
import { WagonsTab } from "./components/WagonsTab";
import { fmtDate, isNegative, priceTotal } from "./utils";
import { useScrollToHash } from "@/hooks/useScrollToHash";
import { useBookingPayment } from "@/pages/bookings/payments/useBookingPayment";
@@ -167,6 +169,9 @@ export function ReadonlyBookingView({
const showPairedNotice =
!!booking.consolidationPartnerId &&
["SUBMITTED", "PENDING_APPROVAL", "CHANGES_REQUESTED"].includes(status);
// Wagons exist only after payment puts the booking on a train; before that
// the tab would always be an empty state, so it stays hidden.
const showWagonsTab = booking.paymentStatus === "PAID" && !isNegative(status);
return (
<PageShell>
@@ -257,6 +262,11 @@ export function ReadonlyBookingView({
<Tabs.Tab value="cargo" leftSection={<Package size={15} />}>
Cargo
</Tabs.Tab>
{showWagonsTab && (
<Tabs.Tab value="wagons" leftSection={<TrainFront size={15} />}>
Wagons
</Tabs.Tab>
)}
<Tabs.Tab value="logistics" leftSection={<Truck size={15} />}>
Logistics
</Tabs.Tab>
@@ -316,6 +326,12 @@ export function ReadonlyBookingView({
<CargoTab booking={booking} />
</Tabs.Panel>
{showWagonsTab && (
<Tabs.Panel value="wagons">
<WagonsTab bookingId={booking.id} />
</Tabs.Panel>
)}
<Tabs.Panel value="logistics">
<div className="flex flex-col gap-6">
<BodyGrid

View File

@@ -0,0 +1,445 @@
import { Box, Group, SimpleGrid, Skeleton, Table, Text, Tooltip } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import {
Container,
Gauge,
MapPin,
Package,
Route,
Scale,
TrainFront,
TrainTrack,
} from "lucide-react";
import type { ReactNode } from "react";
import {
bookingsService,
type BookingWagonAllocation,
} from "@/services/bookings.service";
import { fmtDate, fmtWeight } from "../utils";
import { CardTitle, SectionCard } from "./layout";
// Mirrors CargoTab's local Flag/StatTile look so the two tabs read as one page.
const STATUS_TONES: Record<
BookingWagonAllocation["status"],
{ bg: string; color: string; label: string }
> = {
PLANNED: { bg: "#F1F4F7", color: "#475569", label: "Planned" },
RESERVED: { bg: "#FFFBEB", color: "#92400E", label: "Reserved" },
LOADED: { bg: "#E8F5EF", color: "#0A6F4D", label: "Loaded" },
DEPARTED: { bg: "#EAF1FE", color: "#1E40AF", label: "Departed" },
};
function StatusPill({ status }: { status: BookingWagonAllocation["status"] }) {
const tone = STATUS_TONES[status] ?? STATUS_TONES.PLANNED;
return (
<Text
component="span"
fz={11}
fw={700}
px={9}
py={3}
style={{ borderRadius: 999, backgroundColor: tone.bg, color: tone.color }}
>
{tone.label}
</Text>
);
}
function StatTile({
icon,
label,
value,
sub,
}: {
icon: ReactNode;
label: string;
value: string;
sub?: string;
}) {
return (
<Box
p={14}
style={{
borderRadius: 12,
border: "1px solid #E6ECF2",
backgroundColor: "#FAFCFE",
}}
>
<Group gap={6} align="center" mb={6} c="#6B7C8E">
{icon}
<Text fz="11px" fw={700} tt="uppercase" style={{ letterSpacing: "0.05em" }}>
{label}
</Text>
</Group>
<Text fz={18} fw={800} c="#10202F" truncate>
{value}
</Text>
{sub && (
<Text fz={12} c="#9AA8B5" mt={2}>
{sub}
</Text>
)}
</Box>
);
}
/** Little consist strip: locomotive + one box per wagon, in marshalling order. */
function ConsistStrip({ wagons }: { wagons: BookingWagonAllocation[] }) {
return (
<Box style={{ overflowX: "auto" }} pb={4}>
<Group gap={5} wrap="nowrap" align="flex-end">
<Box
px={10}
py={8}
style={{
borderRadius: "10px 4px 4px 10px",
backgroundColor: "#10202F",
color: "white",
display: "flex",
alignItems: "center",
gap: 6,
flexShrink: 0,
}}
>
<TrainFront size={16} />
<Text fz={11} fw={800}>
LOCO
</Text>
</Box>
{wagons.map((w) => (
<Tooltip
key={w.sequenceNo}
label={`${w.wagonNumber ?? "Unassigned"} · ${w.wagonType ?? "—"} · ${
STATUS_TONES[w.status]?.label ?? w.status
}`}
withArrow
>
<Box
px={10}
py={8}
ta="center"
style={{
borderRadius: 6,
border: "1.5px solid #C9D6E2",
backgroundColor: STATUS_TONES[w.status]?.bg ?? "#F1F4F7",
flexShrink: 0,
minWidth: 64,
cursor: "default",
}}
>
<Text fz={10} fw={700} c="#6B7C8E">
W{w.sequenceNo}
</Text>
<Text fz={11.5} fw={800} c="#10202F" style={{ fontFamily: "monospace" }}>
{w.wagonNumber ?? "—"}
</Text>
</Box>
</Tooltip>
))}
</Group>
</Box>
);
}
function LoadBar({ allocated, capacity }: { allocated: number; capacity: number }) {
const pct = capacity > 0 ? Math.min(100, Math.round((allocated / capacity) * 100)) : 0;
return (
<Box>
<Group justify="space-between" mb={4}>
<Text fz={11.5} fw={700} c="#6B7C8E">
Load
</Text>
<Text fz={11.5} fw={800} c="#10202F">
{fmtWeight(allocated)}
{capacity > 0 ? ` / ${fmtWeight(capacity)} · ${pct}%` : ""}
</Text>
</Group>
<Box style={{ height: 6, borderRadius: 999, backgroundColor: "#EDF2F7" }}>
<Box
style={{
height: 6,
width: `${pct}%`,
borderRadius: 999,
backgroundColor: pct >= 95 ? "#B45309" : "#0A6F4D",
transition: "width 300ms ease",
}}
/>
</Box>
</Box>
);
}
const th = { color: "#9AA8B5", fontSize: 11 } as const;
function WagonCard({ wagon }: { wagon: BookingWagonAllocation }) {
const allocated = Number(wagon.allocatedWeightTons || 0);
const capacity = Number(wagon.capacityTons || 0);
const containers = wagon.containers ?? [];
return (
<SectionCard>
<Group justify="space-between" align="flex-start" wrap="nowrap" mb="sm">
<Group gap={10} align="center" wrap="nowrap">
<Box
style={{
width: 40,
height: 40,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
borderRadius: 10,
backgroundColor: "#10202F",
color: "white",
flexShrink: 0,
}}
>
<Text fz={9} fw={700} c="#9AA8B5" lh={1}>
WAGON
</Text>
<Text fz={15} fw={800} lh={1.2}>
{wagon.sequenceNo}
</Text>
</Box>
<Box>
<Text fz={16} fw={800} c="#10202F" style={{ fontFamily: "monospace" }}>
{wagon.wagonNumber ?? "Not yet assigned"}
</Text>
<Text fz={12} c="#9AA8B5">
{wagon.wagonType ?? "Wagon type pending"}
{wagon.wagonTypeCode && wagon.wagonType !== wagon.wagonTypeCode
? ` · ${wagon.wagonTypeCode}`
: ""}
</Text>
</Box>
</Group>
<StatusPill status={wagon.status} />
</Group>
<LoadBar allocated={allocated} capacity={capacity} />
<Group gap={16} mt="sm" mb={containers.length || wagon.loadType === "BULK" ? "sm" : 0}>
{Number(wagon.tareWeightTons) > 0 && (
<Group gap={5}>
<Scale size={12} color="#9AA8B5" />
<Text fz={12} c="#475569">
Tare {fmtWeight(Number(wagon.tareWeightTons))}
</Text>
</Group>
)}
{Number(wagon.lengthMeters) > 0 && (
<Group gap={5}>
<Route size={12} color="#9AA8B5" />
<Text fz={12} c="#475569">
{Number(wagon.lengthMeters)} m
</Text>
</Group>
)}
<Group gap={5}>
{wagon.loadType === "BULK" ? (
<Package size={12} color="#9AA8B5" />
) : (
<Container size={12} color="#9AA8B5" />
)}
<Text fz={12} c="#475569">
{wagon.loadType === "BULK" ? "Bulk load" : "Container load"}
</Text>
</Group>
</Group>
{wagon.loadType === "BULK" && (wagon.bulkCargoDescription || wagon.bulkQuantity) && (
<Box
p={10}
style={{ borderRadius: 10, backgroundColor: "#FAFCFE", border: "1px solid #EDF2F7" }}
>
<Text fz={12.5} fw={700} c="#10202F">
{wagon.bulkCargoDescription ?? "Bulk cargo"}
</Text>
{Number(wagon.bulkQuantity) > 0 && (
<Text fz={12} c="#9AA8B5">
Quantity: {Number(wagon.bulkQuantity).toLocaleString()}
</Text>
)}
</Box>
)}
{containers.length > 0 && (
<Box style={{ overflowX: "auto" }}>
<Table verticalSpacing={6} horizontalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th style={th}>Container no.</Table.Th>
<Table.Th style={th}>Seal no.</Table.Th>
<Table.Th style={th}>Gross wt.</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{containers.map((c, i) => (
<Table.Tr key={c.containerNumber ?? i}>
<Table.Td>
<Text fz={12.5} fw={700} c="#10202F" style={{ fontFamily: "monospace" }}>
{c.containerNumber ?? "—"}
</Text>
</Table.Td>
<Table.Td>
<Text fz={12.5} c="#475569">
{c.sealNumber ?? "—"}
</Text>
</Table.Td>
<Table.Td>
<Text fz={12.5} c="#475569">
{Number(c.grossWeightTons) > 0
? fmtWeight(Number(c.grossWeightTons))
: "—"}
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Box>
)}
</SectionCard>
);
}
/**
* "Wagons" tab: the customer's view of their allocated wagons once the paid
* booking has been placed on a train — consist strip in marshalling order,
* per-wagon load/containers, and the train's route summary.
*/
export function WagonsTab({ bookingId }: { bookingId: string }) {
const { data: wagons, isLoading } = useQuery({
queryKey: ["booking-wagons", bookingId],
queryFn: () => bookingsService.getWagons(bookingId),
enabled: !!bookingId,
});
if (isLoading) {
return (
<div className="flex flex-col gap-6" style={{ maxWidth: 980 }}>
<Skeleton height={140} radius={16} />
<SimpleGrid cols={{ base: 1, md: 2 }} spacing={24}>
<Skeleton height={220} radius={16} />
<Skeleton height={220} radius={16} />
</SimpleGrid>
</div>
);
}
if (!wagons?.length) {
return (
<SectionCard style={{ maxWidth: 980 }}>
<Group gap={12} align="center">
<Box
style={{
width: 44,
height: 44,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 12,
backgroundColor: "#F1F4F7",
color: "#6B7C8E",
}}
>
<TrainTrack size={22} />
</Box>
<Box>
<Text fz={15} fw={800} c="#10202F">
No wagons allocated yet
</Text>
<Text fz={13} c="#9AA8B5">
Your wagons will appear here once the shipment is placed on a
train after payment.
</Text>
</Box>
</Group>
</SectionCard>
);
}
const first = wagons[0];
const totalAllocated = wagons.reduce(
(s, w) => s + Number(w.allocatedWeightTons || 0),
0,
);
const totalCapacity = wagons.reduce((s, w) => s + Number(w.capacityTons || 0), 0);
const containerCount = wagons.reduce((s, w) => s + (w.containers?.length ?? 0), 0);
const utilization =
totalCapacity > 0 ? Math.round((totalAllocated / totalCapacity) * 100) : null;
return (
<div className="flex flex-col gap-6" style={{ maxWidth: 980 }}>
<SectionCard>
<Group justify="space-between" align="flex-start" mb="md" wrap="wrap">
<Group gap={10} align="center">
<Box
style={{
width: 36,
height: 36,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 10,
backgroundColor: "#E8F5EF",
color: "#0A6F4D",
}}
>
<TrainFront size={18} />
</Box>
<Box>
<Text fz={15} fw={800} c="#10202F">
{first.trainNumber ? `Train ${first.trainNumber}` : "Your train"}
</Text>
<Group gap={5} align="center">
<MapPin size={11} color="#9AA8B5" />
<Text fz={12} c="#9AA8B5">
{first.originStation ?? "—"} {first.destinationStation ?? "—"}
{first.departureAt ? ` · departs ${fmtDate(first.departureAt)}` : ""}
</Text>
</Group>
</Box>
</Group>
<CardTitle>Your wagons on this train</CardTitle>
</Group>
<ConsistStrip wagons={wagons} />
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing={10} mt="md">
<StatTile
icon={<TrainTrack size={13} />}
label="Wagons"
value={`${wagons.length}`}
sub="allocated to you"
/>
<StatTile
icon={<Scale size={13} />}
label="Allocated weight"
value={fmtWeight(totalAllocated)}
/>
<StatTile
icon={<Container size={13} />}
label="Containers"
value={containerCount ? `${containerCount}` : "—"}
sub={containerCount ? "loaded on wagons" : undefined}
/>
<StatTile
icon={<Gauge size={13} />}
label="Utilization"
value={utilization != null ? `${utilization}%` : "—"}
sub="of wagon capacity"
/>
</SimpleGrid>
</SectionCard>
<SimpleGrid cols={{ base: 1, md: 2 }} spacing={24}>
{wagons.map((w) => (
<WagonCard key={w.sequenceNo} wagon={w} />
))}
</SimpleGrid>
</div>
);
}

View File

@@ -193,6 +193,34 @@ export interface BookingListFilter {
sortOrder?: "ASC" | "DESC";
}
export interface BookingWagonContainer {
containerNumber: string | null;
sealNumber: string | null;
positionOnWagon: number | null;
grossWeightTons: string | null;
}
/** One allocated wagon of a booking, as returned by GET /bookings/:id/wagons. */
export interface BookingWagonAllocation {
sequenceNo: number;
wagonNumber: string | null;
wagonType: string | null;
wagonTypeCode: string | null;
tareWeightTons: string | null;
capacityTons: string | null;
lengthMeters: string | null;
allocatedWeightTons: string | null;
loadType: "CONTAINER" | "BULK";
status: "PLANNED" | "RESERVED" | "LOADED" | "DEPARTED";
trainNumber: string | null;
departureAt: string | null;
originStation: string | null;
destinationStation: string | null;
bulkCargoDescription: string | null;
bulkQuantity: string | null;
containers: BookingWagonContainer[];
}
export const bookingsService = {
list: async (
filter: BookingListFilter | void = {},
@@ -558,6 +586,15 @@ export const bookingsService = {
return data.data as Freight.DayAvailabilityResponse;
},
/**
* Allocated wagons for a paid booking (empty until placed on a train).
* One row per wagon with its containers / bulk load.
*/
getWagons: async (bookingId: string): Promise<BookingWagonAllocation[]> => {
const { data } = await client.get(`/api/bookings/${bookingId}/wagons`);
return (data.data ?? data) as BookingWagonAllocation[];
},
/**
* Upcoming/open booking windows on the signed-in customer's active-contract
* lanes (import booking-day windows + export 24h pre-departure windows).