This commit is contained in:
Marshal
2026-07-14 13:10:00 +00:00
parent 6d0cf50b4d
commit b5a97d344a
36 changed files with 1101 additions and 355 deletions

View File

@@ -1,4 +1,11 @@
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
import {
Fragment,
useCallback,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { useNavigate } from "react-router-dom";
import {
ActionIcon,
@@ -75,6 +82,8 @@ interface ClearanceRow {
freightType: string;
originLabel: string;
destinationLabel: string;
/** Full ordered corridor across the contract's route legs (origin → … → destination). */
routeStops: string[];
contractKind: string;
serviceTypeName: string;
customs: boolean;
@@ -93,6 +102,25 @@ function yardLabel(
return yard.label ?? yard.name ?? yard.code ?? fallback;
}
/**
* Chain the contract's ordered route legs into one corridor of stops —
* origin of the first leg, then each leg's destination (Djibouti → Adama →
* Dire Dawa). A leg whose origin differs from the previous destination inserts
* that stop too, so gapped route lists stay readable.
*/
function contractRouteStops(routes: Freight.IContractRoute[]): string[] {
const stops: string[] = [];
for (const r of routes) {
const origin = yardLabel(r.originYard);
const destination = yardLabel(r.destinationYard);
if (stops.length === 0 || stops[stops.length - 1] !== origin) {
stops.push(origin);
}
stops.push(destination);
}
return stops;
}
function toClearanceRow(contract: Freight.IContract): ClearanceRow {
const routes = [...(contract.routes ?? [])].sort(
(a, b) => a.sortOrder - b.sortOrder,
@@ -109,6 +137,7 @@ function toClearanceRow(contract: Freight.IContract): ClearanceRow {
freightType: contract.freightType ?? "—",
originLabel: yardLabel(first?.originYard),
destinationLabel: yardLabel(last?.destinationYard),
routeStops: contractRouteStops(routes),
contractKind: contract.contractKind,
serviceTypeName: contract.serviceType?.serviceName ?? "—",
customs:
@@ -412,14 +441,23 @@ export default function ContractClearanceListPage() {
const r = row.original;
return (
<Stack gap={4} py={2}>
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={500} truncate maw={120}>
{r.originLabel}
</Text>
<ArrowRight size={14} className="shrink-0 text-muted-foreground" />
<Text size="sm" fw={500} truncate maw={120}>
{r.destinationLabel}
</Text>
<Group gap={6} wrap="wrap">
{(r.routeStops.length >= 2
? r.routeStops
: [r.originLabel, r.destinationLabel]
).map((stop, i) => (
<Fragment key={i}>
{i > 0 ? (
<ArrowRight
size={14}
className="shrink-0 text-muted-foreground"
/>
) : null}
<Text size="sm" fw={500}>
{stop}
</Text>
</Fragment>
))}
</Group>
<Group gap={8} align="center">
<DirectionIcon direction={r.tradeDirection} />
@@ -638,6 +676,11 @@ export default function ContractClearanceListPage() {
`/dashboard/contracts/${row.contractId}/bookings/${row.id}/complete`,
)
}
onRebook={(row) =>
navigate(
`/dashboard/contracts/${row.contractId}/create-booking?copyFrom=${row.id}`,
)
}
onViewContract={(contractId) =>
navigate(`/dashboard/contracts/clearance/${contractId}`)
}
@@ -731,6 +774,7 @@ function ShipmentBookingsTable({
canCreateBooking,
onOpen,
onCreateBooking,
onRebook,
onViewContract,
}: {
rows: ShipmentBookingRow[];
@@ -739,6 +783,7 @@ function ShipmentBookingsTable({
canCreateBooking: boolean;
onOpen: (id: string) => void;
onCreateBooking: (row: ShipmentBookingRow) => void;
onRebook: (row: ShipmentBookingRow) => void;
onViewContract: (contractId: string) => void;
}) {
// A bare initiated instance that has cleared but not yet been created by GL.
@@ -748,6 +793,14 @@ function ShipmentBookingsTable({
!r.bookingCreated &&
r.status === "CLEARANCE_READY";
// A customs shipment whose booking lost its slot — GL rebooks it (customer
// can't self-rebook a customs booking). Copies the expired booking's cargo.
const isRebookable = (r: ShipmentBookingRow) =>
canCreateBooking &&
Boolean(r.contractId) &&
r.customs &&
r.status === "EXPIRED";
const columns = useMemo<ColumnDef<ShipmentBookingRow>[]>(
() => [
{
@@ -877,6 +930,7 @@ function ShipmentBookingsTable({
cell: ({ row }) => {
const r = row.original;
const bookable = isBookable(r);
const rebookable = isRebookable(r);
return (
<Group
justify="flex-end"
@@ -896,6 +950,17 @@ function ShipmentBookingsTable({
Create booking
</Button>
) : null}
{rebookable ? (
<Button
size="compact-sm"
color="grape"
radius="md"
leftSection={<RefreshCw size={14} />}
onClick={() => onRebook(r)}
>
Rebook
</Button>
) : null}
<Menu shadow="md" radius="md" position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon
@@ -919,6 +984,14 @@ function ShipmentBookingsTable({
Create booking
</Menu.Item>
) : null}
{rebookable ? (
<Menu.Item
leftSection={<RefreshCw size={14} />}
onClick={() => onRebook(r)}
>
Rebook (GL)
</Menu.Item>
) : null}
{r.contractId ? (
<Menu.Item
leftSection={<ExternalLink size={14} />}

View File

@@ -16,6 +16,7 @@ import { isAxiosError } from "axios";
import {
AlertTriangle,
CalendarClock,
MapPin,
MoreHorizontal,
Replace,
Ruler,
@@ -29,9 +30,10 @@ import { useNavigate, useParams } from "react-router-dom";
import AvailableWagonsPanel from "@/components/trainBuilder/AvailableWagonsPanel";
import ChangeLocomotivesModal from "@/components/trainBuilder/ChangeLocomotivesModal";
import ChangeYardModal from "@/components/trainBuilder/ChangeYardModal";
import ConsistWagonList from "@/components/trainBuilder/ConsistWagonList";
import TrainConsistStrip from "@/components/trainBuilder/TrainConsistStrip";
import { trainStatusColor, trainStatusLabel } from "@/components/trainBuilder/trainStatus";
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
@@ -62,6 +64,7 @@ export default function TrainBuilderDetailPage() {
const navigate = useNavigate();
const { toast } = useToast();
const [locoModalOpen, setLocoModalOpen] = useState(false);
const [yardModalOpen, setYardModalOpen] = useState(false);
const [disbandOpen, setDisbandOpen] = useState(false);
const compositionQuery = useQuery(
@@ -144,6 +147,13 @@ export default function TrainBuilderDetailPage() {
>
Change locomotives
</Menu.Item>
<Menu.Item
leftSection={<MapPin size={15} />}
disabled={!composition.editable}
onClick={() => setYardModalOpen(true)}
>
Change yard
</Menu.Item>
<Menu.Item
color="red"
leftSection={<Trash2 size={15} />}
@@ -162,8 +172,8 @@ export default function TrainBuilderDetailPage() {
{ label: "Locomotives", value: composition.locomotives.length, icon: TrainFront },
{ label: "Wagons", value: totals.wagonCount, icon: TrainIcon },
{
label: "Max gross / haul limit",
value: `${totals.maxGrossTons}T / ${totals.maxPullWeightTons}T`,
label: "Payload available",
value: `${totals.payloadCapacityTons}T of ${totals.maxPullWeightTons}T`,
icon: Weight,
},
{
@@ -180,33 +190,40 @@ export default function TrainBuilderDetailPage() {
</Alert>
) : null}
<Card>
<Stack gap="md">
<Group justify="space-between" align="center">
<Text fw={600}>Consist</Text>
<Text size="xs" c="dimmed">
{composition.locomotives.length} locomotive
{composition.locomotives.length === 1 ? "" : "s"} · {totals.wagonCount} wagon
{totals.wagonCount === 1 ? "" : "s"}
</Text>
</Group>
<TrainConsistStrip
locomotives={composition.locomotives}
wagons={composition.wagons}
/>
<Stack gap="sm">
<TrainCompositionDiagram
locomotives={composition.locomotives.map((loco) => ({
code: loco.code,
name: loco.name,
maxPullWeightTons: loco.maxPullWeightTons,
}))}
wagons={composition.wagons.map((wagon, index) => ({
sequenceNo: wagon.sequenceNumber ?? index + 1,
capacityTons: wagon.wagonType?.capacityTons ?? 0,
// No bookings at build time — wagons ride empty until allocation.
assignedWeightTons: 0,
tareWeightTons: wagon.wagonType?.tareWeightTons ?? 0,
wagonTypeCode: wagon.wagonType?.code ?? null,
physicalWagonNumber: wagon.wagonNumber,
allocations: [],
}))}
trainNumber={composition.code}
totalLengthMeters={totals.totalLengthMeters}
/>
<Card>
<Grid gap="lg">
<Grid.Col span={{ base: 12, sm: 6 }}>
<UtilizationBar
label="Weight utilization (fully loaded)"
pct={totals.weightUtilizationPct}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6 }}>
<UtilizationBar label="Length utilization" pct={totals.lengthUtilizationPct} />
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6 }}>
<Text size="xs" c="dimmed" mt={4}>
The real weight check happens at allocation: booked cargo weight plus
wagon tare (gross) must stay within the locomotives' haul limit.
</Text>
</Grid.Col>
</Grid>
</Stack>
</Card>
</Card>
</Stack>
<Grid gap="lg" align="stretch">
{composition.editable ? (
@@ -297,6 +314,12 @@ export default function TrainBuilderDetailPage() {
onClose={() => setLocoModalOpen(false)}
/>
<ChangeYardModal
composition={composition}
opened={yardModalOpen}
onClose={() => setYardModalOpen(false)}
/>
<Modal
opened={disbandOpen}
onClose={() => setDisbandOpen(false)}

View File

@@ -183,7 +183,7 @@ export default function TrainBuilderListPage() {
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Text size="sm">
{row.original.wagonCount} wagons · {row.original.maxGrossTons}T ·{" "}
{row.original.wagonCount} wagons · {row.original.totalTareTons}T tare ·{" "}
{row.original.totalLengthMeters}m
</Text>
),

View File

@@ -838,6 +838,12 @@ export default function BatchScheduleDetailPage() {
}).format(new Date(data.scheduleDate)) + " EAT"
: "No date"}
</HeroChip>
{data.train ? (
<HeroChip icon={<TrainFront size={12} />}>
Train {data.train.code}
{data.train.trainName ? `${data.train.trainName}` : ""}
</HeroChip>
) : null}
{data.locomotive ? (
<HeroChip icon={<TrainFront size={12} />}>
Loco {data.locomotive.code} ·{" "}

View File

@@ -757,13 +757,14 @@ export default function TrainScheduleV2DetailPage() {
<Stack gap="md">
<TrainCompositionDiagram
locomotive={schedule.trainSet?.locomotive}
locomotives={locomotives}
wagons={
schedule.trainSet?.wagons?.length
? schedule.trainSet.wagons
: displayWagonPlan
}
freightType={freightType}
trainNumber={schedule.trainNumber}
trainNumber={schedule.train ? schedule.train.code : schedule.trainNumber}
totalLengthMeters={schedule.trainSet?.totalLengthMeters}
/>
<Paper
@@ -1003,6 +1004,16 @@ export default function TrainScheduleV2DetailPage() {
<KpiStrip
items={[
...(schedule.train
? [
{
label: "Train",
value: schedule.train.code,
hint: schedule.train.trainName ?? "Built train (Train Builder)",
icon: Train,
},
]
: []),
{
label: locomotives.length > 1 ? "Locomotives" : "Locomotive",
value: locomotives.length