mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat(bookings): show allocated wagons in portal
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user