mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 02:58:11 +00:00
add dispute functionality for contract duty and implement collection dates
This commit is contained in:
@@ -563,6 +563,11 @@ export function canCreateSchedule(user: AuthUser | null | undefined): boolean {
|
|||||||
return hasPermission(user, FREIGHT_PERMS.trainScheduling.create);
|
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 {
|
export function canViewFleet(user: AuthUser | null | undefined): boolean {
|
||||||
return hasPermission(user, FREIGHT_PERMS.fleet.view);
|
return hasPermission(user, FREIGHT_PERMS.fleet.view);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,10 +24,13 @@ import {
|
|||||||
CalendarClock,
|
CalendarClock,
|
||||||
Clock,
|
Clock,
|
||||||
Eye,
|
Eye,
|
||||||
|
Lock,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Navigation,
|
Navigation,
|
||||||
Pencil,
|
Pencil,
|
||||||
|
Play,
|
||||||
Send,
|
Send,
|
||||||
|
Unlock,
|
||||||
Train,
|
Train,
|
||||||
Weight,
|
Weight,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -56,7 +59,7 @@ import { api } from "@/services/api";
|
|||||||
import { formatRouteLabel } from "@/services/routes.service";
|
import { formatRouteLabel } from "@/services/routes.service";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { useAuth } from "@/auth/useAuth";
|
import { useAuth } from "@/auth/useAuth";
|
||||||
import { canCreateSchedule } from "@/lib/permissions";
|
import { canCreateSchedule, canUpdateSchedule } from "@/lib/permissions";
|
||||||
import type {
|
import type {
|
||||||
FreightType,
|
FreightType,
|
||||||
TrainScheduleListFilters,
|
TrainScheduleListFilters,
|
||||||
@@ -103,6 +106,7 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const canCreate = canCreateSchedule(user);
|
const canCreate = canCreateSchedule(user);
|
||||||
|
const canUpdate = canUpdateSchedule(user);
|
||||||
const { viewMode, setViewMode } = useFleetViewMode("train-scheduling-v2");
|
const { viewMode, setViewMode } = useFleetViewMode("train-scheduling-v2");
|
||||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
@@ -121,6 +125,12 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
const [sortDir, setSortDir] = useState<"desc" | "asc">("desc");
|
const [sortDir, setSortDir] = useState<"desc" | "asc">("desc");
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [windowSettingsId, setWindowSettingsId] = useState<string | null>(null);
|
const [windowSettingsId, setWindowSettingsId] = useState<string | null>(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<TrainScheduleListItem | null>(null);
|
||||||
|
const [windowTarget, setWindowTarget] =
|
||||||
|
useState<TrainScheduleListItem | null>(null);
|
||||||
const [editDateSchedule, setEditDateSchedule] =
|
const [editDateSchedule, setEditDateSchedule] =
|
||||||
useState<TrainScheduleListItem | null>(null);
|
useState<TrainScheduleListItem | null>(null);
|
||||||
const [routeId, setRouteId] = useState("");
|
const [routeId, setRouteId] = useState("");
|
||||||
@@ -200,6 +210,12 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const create = useMutation(api.trainScheduling.createSchedule.mutationOptions());
|
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());
|
// const cancel = useMutation(api.trainScheduling.cancelSchedule.mutationOptions());
|
||||||
|
|
||||||
// Intercity (same-country / DOMESTIC) routes cannot be scheduled yet — the
|
// Intercity (same-country / DOMESTIC) routes cannot be scheduled yet — the
|
||||||
@@ -468,6 +484,38 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
Booking window settings
|
Booking window settings
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
) : null}
|
) : 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" ? (
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={
|
||||||
|
schedule.bookingWindowStatus === "CLOSED" ? (
|
||||||
|
<Unlock size={15} />
|
||||||
|
) : (
|
||||||
|
<Lock size={15} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={() => setWindowTarget(schedule)}
|
||||||
|
>
|
||||||
|
{schedule.bookingWindowStatus === "CLOSED"
|
||||||
|
? "Open booking window"
|
||||||
|
: "Close booking window"}
|
||||||
|
</Menu.Item>
|
||||||
|
) : 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" ? (
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<Play size={15} />}
|
||||||
|
onClick={() => setDispatchTarget(schedule)}
|
||||||
|
>
|
||||||
|
Start (dispatch) train
|
||||||
|
</Menu.Item>
|
||||||
|
) : null}
|
||||||
{/* Cancel schedule — hidden for now (frontend only; the
|
{/* Cancel schedule — hidden for now (frontend only; the
|
||||||
cancelSchedule mutation is untouched). Restore by
|
cancelSchedule mutation is untouched). Restore by
|
||||||
uncommenting.
|
uncommenting.
|
||||||
@@ -853,6 +901,125 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
onClose={() => setEditDateSchedule(null)}
|
onClose={() => setEditDateSchedule(null)}
|
||||||
onSaved={() => void schedulesQuery.refetch()}
|
onSaved={() => void schedulesQuery.refetch()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={windowTarget != null}
|
||||||
|
onClose={() => setWindowTarget(null)}
|
||||||
|
title={
|
||||||
|
windowTarget?.bookingWindowStatus === "CLOSED"
|
||||||
|
? "Open booking window?"
|
||||||
|
: "Close booking window?"
|
||||||
|
}
|
||||||
|
centered
|
||||||
|
radius="md"
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{windowTarget?.bookingWindowStatus === "CLOSED" ? (
|
||||||
|
<>
|
||||||
|
Customers will be able to book onto{" "}
|
||||||
|
<Text span fw={600} c="dark">
|
||||||
|
{windowTarget?.trainNumber ?? windowTarget?.reference ?? "this departure"}
|
||||||
|
</Text>{" "}
|
||||||
|
again, up to its remaining capacity.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
No further bookings will be accepted onto{" "}
|
||||||
|
<Text span fw={600} c="dark">
|
||||||
|
{windowTarget?.trainNumber ?? windowTarget?.reference ?? "this departure"}
|
||||||
|
</Text>
|
||||||
|
. Bookings already aboard are unaffected, and you can reopen the
|
||||||
|
window from this menu.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button variant="default" onClick={() => setWindowTarget(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color={windowTarget?.bookingWindowStatus === "CLOSED" ? "edr-green" : "orange"}
|
||||||
|
loading={setWindow.isPending}
|
||||||
|
onClick={async () => {
|
||||||
|
if (!windowTarget) return;
|
||||||
|
const next =
|
||||||
|
windowTarget.bookingWindowStatus === "CLOSED" ? "OPEN" : "CLOSED";
|
||||||
|
try {
|
||||||
|
await setWindow.mutateAsync({ id: windowTarget.id, status: next });
|
||||||
|
toast({
|
||||||
|
title:
|
||||||
|
next === "OPEN" ? "Booking window opened" : "Booking window closed",
|
||||||
|
});
|
||||||
|
setWindowTarget(null);
|
||||||
|
void schedulesQuery.refetch();
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: "Could not update the booking window",
|
||||||
|
description: parseError(err, "Update failed"),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{windowTarget?.bookingWindowStatus === "CLOSED"
|
||||||
|
? "Open window"
|
||||||
|
: "Close window"}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={dispatchTarget != null}
|
||||||
|
onClose={() => setDispatchTarget(null)}
|
||||||
|
title="Dispatch this train?"
|
||||||
|
centered
|
||||||
|
radius="md"
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
<Text span fw={600} c="dark">
|
||||||
|
{dispatchTarget?.trainNumber ?? dispatchTarget?.reference ?? "This train"}
|
||||||
|
</Text>{" "}
|
||||||
|
departs {dispatchTarget?.origin ?? "its origin"} for{" "}
|
||||||
|
{dispatchTarget?.destination ?? "its destination"} and its booking
|
||||||
|
window closes. This cannot be undone.
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
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.
|
||||||
|
</Text>
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button variant="default" onClick={() => setDispatchTarget(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={<Play size={16} />}
|
||||||
|
loading={dispatchSchedule.isPending}
|
||||||
|
onClick={async () => {
|
||||||
|
if (!dispatchTarget) return;
|
||||||
|
try {
|
||||||
|
await dispatchSchedule.mutateAsync(dispatchTarget.id);
|
||||||
|
toast({ title: "Train dispatched" });
|
||||||
|
setDispatchTarget(null);
|
||||||
|
void schedulesQuery.refetch();
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: "Dispatch failed",
|
||||||
|
description: parseError(err, "Could not dispatch"),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Dispatch train
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -203,6 +203,8 @@ export interface TrainScheduleListItem {
|
|||||||
totalLengthMeters: number;
|
totalLengthMeters: number;
|
||||||
bookingsCount: number;
|
bookingsCount: number;
|
||||||
status: TrainScheduleStatus | string;
|
status: TrainScheduleStatus | string;
|
||||||
|
/** Whether the departure is still taking bookings (orthogonal to `status`). */
|
||||||
|
bookingWindowStatus?: "OPEN" | "FULL" | "CLOSED" | string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TrainScheduleSortField =
|
export type TrainScheduleSortField =
|
||||||
|
|||||||
@@ -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
|
// 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
|
// 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.
|
// 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", () => {
|
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");
|
expect(JSON.stringify(res.body)).to.include("must be different");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Guard: cannot request wagons a yard doesn't have (E2E_AWASH holds none).
|
// Filing is COUNT-ONLY: the request is deliberately not capped by what
|
||||||
apiPost(
|
// the source yard holds today, because OCC fulfils in instalments (see
|
||||||
superAdmin,
|
// createRequest). So an empty source is still a legitimate request —
|
||||||
"/api/wagon-transfer-requests",
|
// the stock check lives on the fulfil path, which leaves the shortfall
|
||||||
{ fromYardId: awash, toYardId: kality, wagonTypeId: cw4, quantity: 1, reason: "x" },
|
// open rather than rejecting the request outright.
|
||||||
false,
|
apiPost(superAdmin, "/api/wagon-transfer-requests", {
|
||||||
).then((res) => {
|
fromYardId: awash,
|
||||||
expect(res.status, "empty-source rejected").to.eq(400);
|
toYardId: kality,
|
||||||
expect(JSON.stringify(res.body)).to.match(/no available wagons/i);
|
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.
|
// 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 }) => {
|
requestByReason(REASON_A).then(({ rows }) => {
|
||||||
const reqId = rows[0].id;
|
const reqId = rows[0].id;
|
||||||
|
|
||||||
// Wrong count: request is for 3, offer 2.
|
// Wrong count: OCC fulfils in instalments, so offering FEWER than the
|
||||||
availableCw4("KALITY", 2).then(({ rows: two }) => {
|
// 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(
|
apiPost(
|
||||||
superAdmin,
|
superAdmin,
|
||||||
`/api/wagon-transfer-requests/${reqId}/fulfill`,
|
`/api/wagon-transfer-requests/${reqId}/fulfill`,
|
||||||
{ wagonIds: two.map((w) => w.id) },
|
{ wagonIds: four.map((w) => w.id) },
|
||||||
false,
|
false,
|
||||||
).then((res) => {
|
).then((res) => {
|
||||||
expect(res.status, "wrong count rejected").to.eq(400);
|
expect(res.status, "over-count rejected").to.eq(400);
|
||||||
expect(JSON.stringify(res.body)).to.include("Select exactly 3");
|
expect(JSON.stringify(res.body)).to.include("still owed");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,43 @@ describe("train-builder: adjust-consist headroom guard", { retries: 0 }, () => {
|
|||||||
before(() => {
|
before(() => {
|
||||||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||||||
cy.task("db:seedFile", "seed-adjust-consist.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)", () => {
|
it("operations schedules the dedicated 4-wagon built train (TRN-ADJ-1)", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user