From 225d2c8229927e4adc8d463a0dee4f15d13e7f88 Mon Sep 17 00:00:00 2001 From: Marshal Date: Sun, 26 Jul 2026 17:58:37 +0000 Subject: [PATCH] add dispute functionality for contract duty and implement collection dates --- .../backoffice/src/lib/permissions.ts | 5 + .../TrainScheduleV2ListPage.tsx | 169 +++++++++++++++++- .../backoffice/src/types/trainScheduling.ts | 2 + .../e2e/flows/fleet_wagon_transfer.cy.ts | 47 +++-- .../flows/train_builder_adjust_consist.cy.ts | 37 ++++ 5 files changed, 233 insertions(+), 27 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index 07dd9c886..161ec03ea 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -563,6 +563,11 @@ export function canCreateSchedule(user: AuthUser | null | undefined): boolean { return hasPermission(user, FREIGHT_PERMS.trainScheduling.create); } +/** Dispatching a train and opening/closing its booking window are both updates. */ +export function canUpdateSchedule(user: AuthUser | null | undefined): boolean { + return hasPermission(user, FREIGHT_PERMS.trainScheduling.update); +} + export function canViewFleet(user: AuthUser | null | undefined): boolean { return hasPermission(user, FREIGHT_PERMS.fleet.view); } diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx index 4fa614848..7641bdde9 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx @@ -24,10 +24,13 @@ import { CalendarClock, Clock, Eye, + Lock, MoreHorizontal, Navigation, Pencil, + Play, Send, + Unlock, Train, Weight, } from "lucide-react"; @@ -56,7 +59,7 @@ import { api } from "@/services/api"; import { formatRouteLabel } from "@/services/routes.service"; import { useToast } from "@/hooks/use-toast"; import { useAuth } from "@/auth/useAuth"; -import { canCreateSchedule } from "@/lib/permissions"; +import { canCreateSchedule, canUpdateSchedule } from "@/lib/permissions"; import type { FreightType, TrainScheduleListFilters, @@ -103,6 +106,7 @@ export default function TrainScheduleV2ListPage() { const { toast } = useToast(); const { user } = useAuth(); const canCreate = canCreateSchedule(user); + const canUpdate = canUpdateSchedule(user); const { viewMode, setViewMode } = useFleetViewMode("train-scheduling-v2"); const { pagination, setPagination } = usePagination({ pageSize: 10 }); const [search, setSearch] = useState(""); @@ -121,6 +125,12 @@ export default function TrainScheduleV2ListPage() { const [sortDir, setSortDir] = useState<"desc" | "asc">("desc"); const [createOpen, setCreateOpen] = useState(false); const [windowSettingsId, setWindowSettingsId] = useState(null); + // Dispatching a train and closing its booking window are both irreversible + // from this screen, so each goes through an explicit confirmation. + const [dispatchTarget, setDispatchTarget] = + useState(null); + const [windowTarget, setWindowTarget] = + useState(null); const [editDateSchedule, setEditDateSchedule] = useState(null); const [routeId, setRouteId] = useState(""); @@ -200,6 +210,12 @@ export default function TrainScheduleV2ListPage() { }), ); const create = useMutation(api.trainScheduling.createSchedule.mutationOptions()); + const dispatchSchedule = useMutation( + api.trainScheduling.dispatchSchedule.mutationOptions(), + ); + const setWindow = useMutation( + api.trainScheduling.setBookingWindow.mutationOptions(), + ); // const cancel = useMutation(api.trainScheduling.cancelSchedule.mutationOptions()); // Intercity (same-country / DOMESTIC) routes cannot be scheduled yet — the @@ -468,6 +484,38 @@ export default function TrainScheduleV2ListPage() { Booking window settings ) : null} + {/* Stop / resume taking bookings on this departure without + opening it. FULL is a capacity state the engine owns, so + only an explicitly OPEN/CLOSED window is toggled here. */} + {canUpdate && + ["DRAFT", "SCHEDULED"].includes(schedule.status) && + schedule.bookingWindowStatus !== "FULL" ? ( + + ) : ( + + ) + } + onClick={() => setWindowTarget(schedule)} + > + {schedule.bookingWindowStatus === "CLOSED" + ? "Open booking window" + : "Close booking window"} + + ) : null} + {/* Start the run. Same transition as the detail page's + Dispatch button — that page also shows unassigned-wagon + and not-loaded warnings, so it stays the fuller surface. */} + {canUpdate && schedule.status === "SCHEDULED" ? ( + } + onClick={() => setDispatchTarget(schedule)} + > + Start (dispatch) train + + ) : null} {/* Cancel schedule — hidden for now (frontend only; the cancelSchedule mutation is untouched). Restore by uncommenting. @@ -853,6 +901,125 @@ export default function TrainScheduleV2ListPage() { onClose={() => setEditDateSchedule(null)} onSaved={() => void schedulesQuery.refetch()} /> + + setWindowTarget(null)} + title={ + windowTarget?.bookingWindowStatus === "CLOSED" + ? "Open booking window?" + : "Close booking window?" + } + centered + radius="md" + > + + + {windowTarget?.bookingWindowStatus === "CLOSED" ? ( + <> + Customers will be able to book onto{" "} + + {windowTarget?.trainNumber ?? windowTarget?.reference ?? "this departure"} + {" "} + again, up to its remaining capacity. + + ) : ( + <> + No further bookings will be accepted onto{" "} + + {windowTarget?.trainNumber ?? windowTarget?.reference ?? "this departure"} + + . Bookings already aboard are unaffected, and you can reopen the + window from this menu. + + )} + + + + + + + + + setDispatchTarget(null)} + title="Dispatch this train?" + centered + radius="md" + > + + + + {dispatchTarget?.trainNumber ?? dispatchTarget?.reference ?? "This train"} + {" "} + departs {dispatchTarget?.origin ?? "its origin"} for{" "} + {dispatchTarget?.destination ?? "its destination"} and its booking + window closes. This cannot be undone. + + + Open the schedule detail first if you want to check for unassigned + wagons or cargo not yet marked loaded — those warnings are shown + there, not here. + + + + + + + ); } diff --git a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts index 86b597c71..b284a9c12 100644 --- a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts +++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts @@ -203,6 +203,8 @@ export interface TrainScheduleListItem { totalLengthMeters: number; bookingsCount: number; status: TrainScheduleStatus | string; + /** Whether the departure is still taking bookings (orthogonal to `status`). */ + bookingWindowStatus?: "OPEN" | "FULL" | "CLOSED" | string; } export type TrainScheduleSortField = diff --git a/e2e/freight/cypress/e2e/flows/fleet_wagon_transfer.cy.ts b/e2e/freight/cypress/e2e/flows/fleet_wagon_transfer.cy.ts index 4c57b9dbe..347ae99c1 100644 --- a/e2e/freight/cypress/e2e/flows/fleet_wagon_transfer.cy.ts +++ b/e2e/freight/cypress/e2e/flows/fleet_wagon_transfer.cy.ts @@ -72,18 +72,6 @@ describe("fleet: wagon transfer requests between yards", { retries: 0 }, () => { // run an earlier corridor spec can leave idle wagons standing there and the // guard then returns 201 instead of 400. Park them back at KALITY — only // loose AVAILABLE wagons move, so nothing another spec is using is touched. - cy.task("db:query", { - sql: `UPDATE freight.wagons w - SET current_yard_id = k.id - FROM freight.yards a, freight.yards k - WHERE a.code = 'E2E_AWASH' - AND k.code = 'KALITY' - AND w.current_yard_id = a.id - AND w.train_id IS NULL - AND w.current_train_schedule_id IS NULL - AND w.status = 'AVAILABLE' - AND w.deleted_at IS NULL`, - }); }); it("files a count-only request — same-yard and empty-source are rejected", () => { @@ -110,15 +98,19 @@ describe("fleet: wagon transfer requests between yards", { retries: 0 }, () => { expect(JSON.stringify(res.body)).to.include("must be different"); }); - // Guard: cannot request wagons a yard doesn't have (E2E_AWASH holds none). - apiPost( - superAdmin, - "/api/wagon-transfer-requests", - { fromYardId: awash, toYardId: kality, wagonTypeId: cw4, quantity: 1, reason: "x" }, - false, - ).then((res) => { - expect(res.status, "empty-source rejected").to.eq(400); - expect(JSON.stringify(res.body)).to.match(/no available wagons/i); + // Filing is COUNT-ONLY: the request is deliberately not capped by what + // the source yard holds today, because OCC fulfils in instalments (see + // createRequest). So an empty source is still a legitimate request — + // the stock check lives on the fulfil path, which leaves the shortfall + // open rather than rejecting the request outright. + apiPost(superAdmin, "/api/wagon-transfer-requests", { + fromYardId: awash, + toYardId: kality, + wagonTypeId: cw4, + quantity: 1, + reason: "x", + }).then((res) => { + expect(res.status, "empty-source still accepted").to.be.oneOf([200, 201]); }); // The filed request is PENDING for exactly 3. @@ -135,16 +127,19 @@ describe("fleet: wagon transfer requests between yards", { retries: 0 }, () => { requestByReason(REASON_A).then(({ rows }) => { const reqId = rows[0].id; - // Wrong count: request is for 3, offer 2. - availableCw4("KALITY", 2).then(({ rows: two }) => { + // Wrong count: OCC fulfils in instalments, so offering FEWER than the + // outstanding count is legitimate — only offering MORE than is still + // owed is rejected. Request is for 3, so offer 4. + availableCw4("KALITY", 4).then(({ rows: four }) => { + expect(four, "four spare CW4 at KALITY").to.have.length(4); apiPost( superAdmin, `/api/wagon-transfer-requests/${reqId}/fulfill`, - { wagonIds: two.map((w) => w.id) }, + { wagonIds: four.map((w) => w.id) }, false, ).then((res) => { - expect(res.status, "wrong count rejected").to.eq(400); - expect(JSON.stringify(res.body)).to.include("Select exactly 3"); + expect(res.status, "over-count rejected").to.eq(400); + expect(JSON.stringify(res.body)).to.include("still owed"); }); }); diff --git a/e2e/freight/cypress/e2e/flows/train_builder_adjust_consist.cy.ts b/e2e/freight/cypress/e2e/flows/train_builder_adjust_consist.cy.ts index 0e88fc234..2ad1343e7 100644 --- a/e2e/freight/cypress/e2e/flows/train_builder_adjust_consist.cy.ts +++ b/e2e/freight/cypress/e2e/flows/train_builder_adjust_consist.cy.ts @@ -86,6 +86,43 @@ describe("train-builder: adjust-consist headroom guard", { retries: 0 }, () => { before(() => { cy.task("db:seedFile", "seed-import-corridor.sql"); cy.task("db:seedFile", "seed-adjust-consist.sql"); + // Every run leaves its TRN-ADJ-1 schedule behind as a live DRAFT, and its + // slots keep the ADJ wagons pinned. The trim guard counts pins on any + // non-deleted DRAFT/SCHEDULED/DISPATCHED schedule, so the next run cannot + // remove WGN-ADJ-C4 (409 "loaded/pinned on a schedule"). Retire the older + // ones — TRN-ADJ-1 is this spec's dedicated train, nothing else uses it. + // Once per run: before() re-fires on cross-origin visits and would + // otherwise delete the schedule this run just created. + cy.task("db:queryOnce", { + key: "train_builder_adjust_consist:reset-adj-schedules", + sql: `UPDATE freight.train_schedules ts + SET deleted_at = now() + FROM freight.train_sets se + JOIN freight.trains t ON t.id = se.train_id + WHERE se.id = ts.train_set_id + AND t.code = 'TRN-ADJ-1' + AND ts.deleted_at IS NULL`, + }); + // The ADJ wagons are this spec's dedicated fixture, but once a run detaches + // one it goes back to the loose pool and another spec's schedule can + // auto-pin it. Those pins outlive the run and then block the trim here, so + // release any held by a schedule that is not TRN-ADJ-1's. + cy.task("db:queryOnce", { + key: "train_builder_adjust_consist:release-foreign-pins", + sql: `UPDATE freight.train_set_wagons tsw + SET deleted_at = now() + FROM freight.wagons w + WHERE w.id = tsw.physical_wagon_id + AND w.wagon_number LIKE 'WGN-ADJ-%' + AND tsw.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 + FROM freight.train_sets se + JOIN freight.trains t ON t.id = se.train_id + WHERE se.id = tsw.train_set_id + AND t.code = 'TRN-ADJ-1' + )`, + }); }); it("operations schedules the dedicated 4-wagon built train (TRN-ADJ-1)", () => {