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 (
+
+