add permissions and fix issues

This commit is contained in:
Marshal
2026-07-23 20:24:20 +00:00
parent 40f16f3cec
commit 668b5e1c9d
40 changed files with 18634 additions and 342 deletions

View File

@@ -1,5 +1,6 @@
import { Link, useParams } from "react-router-dom";
import { isAxiosError } from "axios";
import { useState } from "react";
import {
ArrowLeft,
CalendarClock,
@@ -7,9 +8,11 @@ import {
Flag,
MapPin,
Navigation,
PackageCheck,
Train,
} from "lucide-react";
import {
Alert,
Badge,
Box,
Button,
@@ -25,7 +28,9 @@ import {
} from "@mantine/core";
import { PageContainer } from "@/components/page";
import { LogPassYardWorkModal } from "@/components/trainScheduling/LogPassYardWorkModal";
import { RouteCorridorTrack } from "@/components/trainScheduling/RouteCorridorTrack";
import type { TrackStation } from "@/types/trainScheduling";
import {
RouteCorridor,
StatusPill,
@@ -148,6 +153,18 @@ export default function TrainScheduleTrackPage() {
const recordCheckpoint = useMutation(
api.trainScheduling.recordCheckpoint.mutationOptions(),
);
// Yard work drives the log-pass modal: which bookings board/alight per stop.
const yardWorkQuery = useQuery(
api.trainScheduling.yardWork.queryOptions({
input: { scheduleId: scheduleId ?? "" },
enabled: Boolean(scheduleId) && trackQuery.data?.status === "DISPATCHED",
}),
);
const [yardModal, setYardModal] = useState<{
station: TrackStation;
isFinal: boolean;
alreadyLogged: boolean;
} | null>(null);
if (trackQuery.isLoading) {
return (
@@ -181,8 +198,26 @@ export default function TrainScheduleTrackPage() {
const inTransit = track.status === "DISPATCHED";
const arrived = track.status === "ARRIVED";
// Yard work at a station: boarders not yet loaded, and loaded bookings that
// alight there. When either exists, logging the pass goes through the modal
// so the operator sees (and can act on) both lists; empty yards log directly.
const yardWorkFor = (station: TrackStation | undefined) =>
yardWorkQuery.data?.yards.find((y) => y.yardId === station?.yardId);
const stationHasWork = (station: TrackStation | undefined) => {
const yard = yardWorkFor(station);
return Boolean(
yard &&
(yard.toLoad.some((r) => !r.loadedAt) || yard.toUnload.some((r) => r.canUnload)),
);
};
const handleLog = (sequenceNo: number) => {
const station = track.stations.find((s) => s.sequenceNo === sequenceNo);
const isFinal = sequenceNo === track.stations[totalStations - 1]?.sequenceNo;
if (station && stationHasWork(station)) {
setYardModal({ station, isFinal, alreadyLogged: false });
return;
}
recordCheckpoint.mutate(
{ id: scheduleId, payload: { sequenceNo } },
{
@@ -203,6 +238,15 @@ export default function TrainScheduleTrackPage() {
);
};
// "Forgot to load" catch: while the train sits at the current station, any
// boarder there that is still unloaded can be loaded until the next pass.
const currentStationObj = track.stations.find(
(s) => s.sequenceNo === track.currentSequenceNo,
);
const currentYard = canLog ? yardWorkFor(currentStationObj) : undefined;
const forgottenBoarders =
currentYard?.toLoad.filter((r) => !r.loadedAt) ?? [];
return (
<PageContainer>
<Button
@@ -416,6 +460,43 @@ export default function TrainScheduleTrackPage() {
}
onLogCheckpoint={handleLog}
/>
{/* Cargo the operator forgot: boarders at the CURRENT station stay
loadable until the next pass is logged. */}
{currentStationObj && forgottenBoarders.length > 0 ? (
<Alert
color="yellow"
variant="light"
radius="md"
icon={<PackageCheck size={16} />}
title={`${forgottenBoarders.length} booking${
forgottenBoarders.length === 1 ? "" : "s"
} at ${currentStationObj.label} not loaded yet`}
>
<Group justify="space-between" align="center" wrap="wrap" gap="sm">
<Text size="sm">
The train is at {currentStationObj.label} cargo boarding here can
still be loaded before the next station is logged.
</Text>
<Button
size="compact-sm"
variant="light"
color="yellow"
onClick={() =>
setYardModal({
station: currentStationObj,
isFinal:
currentStationObj.sequenceNo ===
track.stations[totalStations - 1]?.sequenceNo,
alreadyLogged: true,
})
}
>
Open yard work
</Button>
</Group>
</Alert>
) : null}
</Stack>
</Paper>
@@ -505,6 +586,15 @@ export default function TrainScheduleTrackPage() {
</Timeline>
)}
</Paper>
<LogPassYardWorkModal
opened={yardModal !== null}
onClose={() => setYardModal(null)}
scheduleId={scheduleId}
station={yardModal?.station ?? null}
isFinal={yardModal?.isFinal ?? false}
alreadyLogged={yardModal?.alreadyLogged ?? false}
/>
</PageContainer>
);
}

View File

@@ -398,11 +398,8 @@ export default function TrainScheduleV2DetailPage() {
!b.loadedAt &&
!["IN_TRANSIT", "COMPLETED"].includes(b.status ?? ""),
).length;
// Import-Djibouti trains are HARD-blocked from dispatch until loading is
// confirmed in the workspace — surface it as a blocker, not just a warning.
const loadingBlocksDispatch =
schedule.requiresLoadingConfirmation === true &&
schedule.loadingConfirmed !== true;
// No loading hard-block: bookings may board mid-corridor, so loading happens
// per yard from the track page's log-pass flow. Everything below is advisory.
const hasDispatchWarnings =
unassignedCount > 0 || unloadedCount > 0 || intercityNotLoadedCount > 0;
@@ -1271,22 +1268,6 @@ export default function TrainScheduleV2DetailPage() {
undone.
</Text>
{loadingBlocksDispatch ? (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertTriangle size={18} />}
title="Loading not confirmed"
>
This import train cannot depart until loading is confirmed. Use{" "}
<Text span fw={700}>
Confirm loading
</Text>{" "}
in the Workspace tab first.
</Alert>
) : null}
{hasDispatchWarnings ? (
<Alert
color="orange"
@@ -1309,8 +1290,9 @@ export default function TrainScheduleV2DetailPage() {
<Text span fw={700}>
{unloadedCount}
</Text>{" "}
wagon-assigned booking{unloadedCount === 1 ? "" : "s"} still marked
unloaded
wagon-assigned booking{unloadedCount === 1 ? "" : "s"} not loaded
yet mid-route boarders load from the track page when the train
reaches their yard
</List.Item>
) : null}
{intercityNotLoadedCount > 0 ? (
@@ -1350,7 +1332,6 @@ export default function TrainScheduleV2DetailPage() {
color="edr-green"
leftSection={<Send size={16} />}
loading={dispatch.isPending}
disabled={loadingBlocksDispatch}
onClick={() => void runDispatch()}
>
{hasDispatchWarnings ? "Dispatch anyway" : "Dispatch train"}

View File

@@ -55,6 +55,8 @@ import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query";
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 type {
FreightType,
TrainScheduleListFilters,
@@ -99,6 +101,8 @@ const parseError = (error: unknown, fallback: string) => {
export default function TrainScheduleV2ListPage() {
const navigate = useNavigate();
const { toast } = useToast();
const { user } = useAuth();
const canCreate = canCreateSchedule(user);
const { viewMode, setViewMode } = useFleetViewMode("train-scheduling-v2");
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [search, setSearch] = useState("");
@@ -550,9 +554,11 @@ export default function TrainScheduleV2ListPage() {
title="Train Schedules"
subtitle="Operational train scheduling with full allocation workflow."
action={
<Button leftSection={<Train size={18} />} onClick={() => setCreateOpen(true)}>
New schedule
</Button>
canCreate ? (
<Button leftSection={<Train size={18} />} onClick={() => setCreateOpen(true)}>
New schedule
</Button>
) : undefined
}
/>