diff --git a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts index d2f1dd7b1..f54dc138e 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts @@ -40,6 +40,68 @@ export class IntercityService { * remaining capacity along all three axes (wagons, weight, length) and each * booking's need, so staff can pick what fits. */ + /** + * Every intercity booking and where it is in its ride-along, across all trains. + * + * The per-schedule candidate list answers "what can THIS train carry"; this + * answers "what is happening to intercity cargo" — which is what a yard + * operator needs when the work is spread over whichever trains happen to pass. + * + * Carries each end's facility state, because a booking whose origin or + * destination has no facility can never be loaded or unloaded there and the + * operator should see that before the train arrives, not when the load is + * refused. + */ + async listBookings() { + return this.dataSource.query( + `SELECT b.id AS "bookingId", + b.reference AS "reference", + b.status AS "status", + b.freight_type AS "freightType", + b.cargo_total_weight_vgm AS "weightTons", + b.loaded_at AS "loadedAt", + b.arrived_at AS "arrivedAt", + company.name AS "customer", + b.train_schedule_id AS "trainScheduleId", + ts.train_number AS "trainNumber", + ts.status AS "scheduleStatus", + oy.id AS "originYardId", + COALESCE(oy.label, oy.code) AS "origin", + oy.has_facility AS "originHasFacility", + dy.id AS "destinationYardId", + COALESCE(dy.label, dy.code) AS "destination", + dy.has_facility AS "destinationHasFacility", + -- Where the train actually is, so the operator knows if the cargo + -- can be worked right now. + cp.yard_id AS "trainAtYardId", + -- Most recent GRN raised for this booking at a facility. + fh.grn_number AS "grnNumber" + FROM freight.bookings b + LEFT JOIN freight.companies company ON company.id = b.company_id + LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id + LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id + LEFT JOIN freight.train_schedules ts + ON ts.id = b.train_schedule_id AND ts.deleted_at IS NULL + LEFT JOIN LATERAL ( + SELECT c.yard_id + FROM freight.train_checkpoint_events c + WHERE c.train_schedule_id = b.train_schedule_id + ORDER BY c.occurred_at DESC, c.created_at DESC + LIMIT 1 + ) cp ON true + LEFT JOIN LATERAL ( + SELECT e.grn_number + FROM freight.facility_handling_events e + WHERE e.booking_id = b.id AND e.deleted_at IS NULL + ORDER BY e.occurred_at DESC + LIMIT 1 + ) fh ON true + WHERE b.deleted_at IS NULL + AND b.trade_direction = 'DOMESTIC' + ORDER BY b.created_at DESC`, + ); + } + async listCandidates(scheduleId: string) { const schedule = await this.getSchedule(scheduleId); const milestoneSeq = await this.routeMilestoneSequence(schedule); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index bbb19dbee..6bc36c4fc 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -459,6 +459,16 @@ export class TrainSchedulingController { return this.trainSchedulingService.dispatchSchedule(id); } + @Get("intercity/bookings") + @TrainSchedulingView() + @ApiOperation({ + summary: + "Every intercity booking with its ride-along state, both yards' facility status, and where its train is", + }) + listIntercityBookings() { + return this.intercityService.listBookings(); + } + @Get("schedules/:id/intercity-candidates") @TrainSchedulingView() @ApiOperation({ diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 960d0036e..16cbd8507 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -25,6 +25,7 @@ import { Users, Wallet, LifeBuoy, + TrainFront, } from "lucide-react"; import { useEffect } from "react"; import { @@ -122,6 +123,7 @@ import InterchangeDocumentsPage from "./pages/warehouses/InterchangeDocumentsPag import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage"; import LoadedInventoryPage from "./pages/warehouses/LoadedInventoryPage"; import LoadingQueuePage from "./pages/warehouses/LoadingQueuePage"; +import IntercityPage from "./pages/warehouses/IntercityPage"; import WarehouseDashboardPage from "./pages/warehouses/WarehouseDashboardPage"; import WarehouseDetailPage from "./pages/warehouses/WarehouseDetailPage"; import WarehouseInventoryPage from "./pages/warehouses/WarehouseInventoryPage"; @@ -441,6 +443,18 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ }, ], }, + { + label: "Intercity", + href: "/dashboard/intercity", + icon: , + children: [ + { + label: "Intercity Cargo", + href: "/dashboard/intercity", + icon: , + }, + ], + }, { label: "Warehouse Management", icon: , @@ -933,6 +947,7 @@ const App = () => { } /> } /> } /> + } /> } /> } /> `/train-scheduling/schedules/${id}/bookings/${bookingId}/unload`, + INTERCITY_BOOKINGS: "/train-scheduling/intercity/bookings", INTERCITY_CANDIDATES: (id: string) => `/train-scheduling/schedules/${id}/intercity-candidates`, INTERCITY_ACCEPT: (id: string) => diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/IntercityPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/IntercityPage.tsx new file mode 100644 index 000000000..6a45be0f2 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/IntercityPage.tsx @@ -0,0 +1,291 @@ +import { useMemo, useState } from "react"; +import { + Alert, + Badge, + Card, + Center, + Group, + Loader, + SimpleGrid, + Table, + Tabs, + Text, + Tooltip, +} from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; +import { AlertTriangle, PackageCheck, TrainFront, Warehouse } from "lucide-react"; + +import { PageContainer, PageHeader } from "@/components/page"; +import { api } from "@/services/api"; +import type { IntercityRideAlongRow } from "@/types/trainScheduling"; + +/** + * Intercity cargo across every train. + * + * Intercity bookings never get their own train — they ride whichever + * import/export train passes through their corridor — so the work is spread over + * other people's schedules. This is the one place it's all visible. + */ + +const fmtTons = (t: number | null) => (t == null ? "—" : `${t} t`); + +/** A booking can only be worked where the train actually is. */ +const atOrigin = (r: IntercityRideAlongRow) => + Boolean(r.trainAtYardId) && r.trainAtYardId === r.originYardId; +const atDestination = (r: IntercityRideAlongRow) => + Boolean(r.trainAtYardId) && r.trainAtYardId === r.destinationYardId; + +const isWaiting = (r: IntercityRideAlongRow) => + !r.loadedAt && r.status !== "IN_TRANSIT" && r.status !== "COMPLETED"; +const isRiding = (r: IntercityRideAlongRow) => r.status === "IN_TRANSIT"; +const isDone = (r: IntercityRideAlongRow) => r.status === "COMPLETED"; + +/** Yards with no equipment can never load/unload — surface it before the train arrives. */ +function FacilityCell({ yard, has }: { yard: string | null; has: boolean | null }) { + if (!yard) return ; + if (has) return {yard}; + return ( + + + + + {yard} + + + + ); +} + +function Rows({ rows }: { rows: IntercityRideAlongRow[] }) { + if (rows.length === 0) { + return ( + + Nothing here. + + ); + } + return ( + + + + + Booking + Customer + Load at + Unload at + Train + Weight + GRN + Status + + + + {rows.map((r) => ( + + + + {r.reference ?? r.bookingId.slice(0, 8)} + + + {r.customer ?? "—"} + + + + {atOrigin(r) && isWaiting(r) && ( + + train here + + )} + + + + + + {atDestination(r) && isRiding(r) && ( + + train here + + )} + + + + {r.trainNumber ? ( + + + {r.trainNumber} + + ) : ( + + not on a train + + )} + + + {fmtTons(r.weightTons)} + + + + {r.grnNumber ?? "—"} + + + + + {r.status} + + + + ))} + +
+
+ ); +} + +function Stat({ + icon, + label, + value, + color, +}: { + icon: React.ReactNode; + label: string; + value: React.ReactNode; + color?: string; +}) { + return ( + + + {icon} +
+ + {label} + + + {value} + +
+
+
+ ); +} + +export default function IntercityPage() { + const [tab, setTab] = useState("waiting"); + const { data: rows = [], isLoading } = useQuery( + api.trainScheduling.intercityBookings.queryOptions({ input: undefined }), + ); + + const waiting = useMemo(() => rows.filter(isWaiting), [rows]); + const riding = useMemo(() => rows.filter(isRiding), [rows]); + const done = useMemo(() => rows.filter(isDone), [rows]); + // A booking whose end has no equipment is stuck until someone flags the yard. + const blocked = useMemo( + () => + rows.filter( + (r) => !isDone(r) && (!r.originHasFacility || !r.destinationHasFacility), + ), + [rows], + ); + + return ( + + + + {isLoading ? ( +
+ +
+ ) : ( + <> + + } + label="Waiting to load" + value={waiting.length} + /> + } label="On a train" value={riding.length} /> + } label="Completed" value={done.length} /> + } + label="No facility" + value={blocked.length} + color={blocked.length > 0 ? "red" : undefined} + /> + + + {blocked.length > 0 && ( + } + title={`${blocked.length} booking${blocked.length === 1 ? "" : "s"} cannot be handled`} + mb="md" + > + Their origin or destination yard has no load/unload facility. Mark the yard as + a facility in Configuration → Yards, or the cargo can never be worked there. + + )} + + + setTab(v ?? "waiting")}> + + + {waiting.length} + + } + > + Waiting to load + + + {riding.length} + + } + > + On a train + + + {done.length} + + } + > + Completed + + + + + + + + + + + + + + + Loading and unloading happen on the train's schedule page, where the ride-along + panel confirms the train is at the yard. + + + + )} +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index 71db16fb5..fe323a472 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -702,6 +702,16 @@ export const api = { () => TRAIN_SCHEDULING_INVALIDATIONS, ), + intercityBookings: endpoint< + void, + import("@/types/trainScheduling").IntercityRideAlongRow[] + >( + "train-scheduling", + "intercity-bookings", + () => trainSchedulingService.listIntercityBookings(), + () => ["train-scheduling", "intercity-bookings"], + ), + intercityCandidates: endpoint< { scheduleId: string }, import("@/types/trainScheduling").IntercityCandidatesResult diff --git a/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts b/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts index 76f87a008..1eb43a048 100644 --- a/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts @@ -393,6 +393,15 @@ export const trainSchedulingService = { return unwrap(response.data); }, + listIntercityBookings: async (): Promise< + import("@/types/trainScheduling").IntercityRideAlongRow[] + > => { + const response = await client.get< + import("@/types/trainScheduling").IntercityRideAlongRow[] + >(URL_CONSTANTS.TRAIN_SCHEDULING.INTERCITY_BOOKINGS); + return response.data ?? []; + }, + getIntercityCandidates: async ( scheduleId: string, ): Promise => { diff --git a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts index 270b13636..74c6f7f68 100644 --- a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts +++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts @@ -956,3 +956,27 @@ export interface BookingUnloadResult { status: string; arrivedAt: string; } + +/** An intercity booking's ride-along state, with both ends' facility status. */ +export interface IntercityRideAlongRow { + bookingId: string; + reference: string | null; + status: string; + freightType: string | null; + weightTons: number | null; + loadedAt: string | null; + arrivedAt: string | null; + customer: string | null; + trainScheduleId: string | null; + trainNumber: string | null; + scheduleStatus: string | null; + originYardId: string | null; + origin: string | null; + originHasFacility: boolean | null; + destinationYardId: string | null; + destination: string | null; + destinationHasFacility: boolean | null; + /** Yard the train was last recorded at — cargo can only be worked there. */ + trainAtYardId: string | null; + grnNumber: string | null; +}