mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 14:15:44 +00:00
feat: add wagon usage computation and maintenance logging features
- Implemented utility to calculate wagon usage metrics for train schedules. - Created for sending wagons to maintenance with optional notes. - Added unit tests for train builder maintenance functionalities, including formatting train run labels and building maintenance notes. - Developed component for merging train schedules with detailed previews and reasons for merging. - Introduced component for selecting wagons with search functionality and selection limits. - Created for displaying and filtering audit logs, including detailed views of individual log entries. - Added for handling API interactions related to audit logs, including fetching logs and entity types.
This commit is contained in:
@@ -23,6 +23,7 @@ import {
|
||||
CalendarClock,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Merge,
|
||||
Container as ContainerIcon,
|
||||
Eye,
|
||||
FileText,
|
||||
@@ -54,6 +55,7 @@ import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPl
|
||||
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
|
||||
import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityRideAlongPanel";
|
||||
import { LegCapacityPanel } from "@/components/trainScheduling/LegCapacityPanel";
|
||||
import MergeScheduleTrainModal from "@/components/trainScheduling/MergeScheduleTrainModal";
|
||||
import ScheduleHistoryPanel from "@/components/trainScheduling/ScheduleHistoryPanel";
|
||||
// import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
|
||||
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
|
||||
@@ -114,6 +116,7 @@ export default function TrainScheduleV2DetailPage() {
|
||||
const [containerPlacements, setContainerPlacements] = useState<ContainerPlacement[]>([]);
|
||||
const [maintenanceOpen, setMaintenanceOpen] = useState(false);
|
||||
const [windowSettingsOpen, setWindowSettingsOpen] = useState(false);
|
||||
const [mergeModalOpen, setMergeModalOpen] = useState(false);
|
||||
const [gatepassSecuredAt, setGatepassSecuredAt] = useState("");
|
||||
const [gatepassReference, setGatepassReference] = useState("");
|
||||
const [gatepassFileUrl, setGatepassFileUrl] = useState("");
|
||||
@@ -400,7 +403,6 @@ export default function TrainScheduleV2DetailPage() {
|
||||
const canDispatch =
|
||||
schedule.status === "SCHEDULED" &&
|
||||
hasPermission(authUser, FREIGHT_PERMS.trainScheduling.dispatch);
|
||||
|
||||
// Dispatch readiness: bookings with no wagon, and wagon-loaded bookings whose
|
||||
// cargo staff never marked loaded. Both are warnings, not blockers — staff can
|
||||
// still dispatch after confirming.
|
||||
@@ -667,6 +669,7 @@ export default function TrainScheduleV2DetailPage() {
|
||||
assignedBookings={(schedule.bookings ?? []).map((b) => ({
|
||||
id: b.id,
|
||||
reference: b.reference ?? b.id.slice(0, 8),
|
||||
customer: b.customer,
|
||||
weightTons: b.weightTons,
|
||||
isGovernment: b.isGovernment,
|
||||
wagonsRequired: b.wagonsRequired,
|
||||
@@ -940,7 +943,7 @@ export default function TrainScheduleV2DetailPage() {
|
||||
{schedule.trainNumber ? (
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lh={1.2}>
|
||||
Voyage No.
|
||||
Train No.
|
||||
</Text>
|
||||
<Text
|
||||
ff="monospace"
|
||||
@@ -952,6 +955,33 @@ export default function TrainScheduleV2DetailPage() {
|
||||
</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
{schedule.voyageNumber ? (
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lh={1.2}>
|
||||
Voyage No.
|
||||
</Text>
|
||||
<Text
|
||||
ff="monospace"
|
||||
fw={800}
|
||||
lh={1.1}
|
||||
style={{ fontSize: 32, color: "#0f172a" }}
|
||||
>
|
||||
{schedule.voyageNumber}
|
||||
</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
{/* Merging rewrites the consist, so it is offered only while
|
||||
the departure can still be edited. */}
|
||||
{canEditBookings ? (
|
||||
<Button
|
||||
variant="light"
|
||||
size="compact-sm"
|
||||
leftSection={<Merge size={14} />}
|
||||
onClick={() => setMergeModalOpen(true)}
|
||||
>
|
||||
Merge
|
||||
</Button>
|
||||
) : null}
|
||||
{schedule.direction ? (
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lh={1.2}>
|
||||
@@ -1369,6 +1399,15 @@ export default function TrainScheduleV2DetailPage() {
|
||||
onSaved={() => void detailQuery.refetch()}
|
||||
/>
|
||||
|
||||
<MergeScheduleTrainModal
|
||||
scheduleId={scheduleId ?? null}
|
||||
currentTrainId={schedule.trainSet?.trainId ?? null}
|
||||
scheduleReference={schedule.reference ?? null}
|
||||
opened={mergeModalOpen}
|
||||
onClose={() => setMergeModalOpen(false)}
|
||||
onMerged={() => void detailQuery.refetch()}
|
||||
/>
|
||||
|
||||
<SwitchGovernmentBookingModal
|
||||
key={switchTarget?.id ?? "none"}
|
||||
opened={Boolean(switchTarget)}
|
||||
|
||||
@@ -134,6 +134,9 @@ export default function TrainScheduleV2ListPage() {
|
||||
// confirmation.
|
||||
const [dispatchTarget, setDispatchTarget] =
|
||||
useState<TrainScheduleListItem | null>(null);
|
||||
// Cancelling is likewise irreversible — confirmed before the mutation fires.
|
||||
const [cancelTarget, setCancelTarget] =
|
||||
useState<TrainScheduleListItem | null>(null);
|
||||
const [editDateSchedule, setEditDateSchedule] =
|
||||
useState<TrainScheduleListItem | null>(null);
|
||||
const [routeId, setRouteId] = useState("");
|
||||
@@ -424,9 +427,7 @@ export default function TrainScheduleV2ListPage() {
|
||||
cell: ({ row }) => (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<MetricChip value={row.original.bookingsCount} label="bkg" />
|
||||
{/* Wagon SLOTS this schedule's bookings occupy — not the coupled
|
||||
consist. A built train shows 0 here until bookings are allocated. */}
|
||||
<MetricChip value={row.original.wagonCount} label="wgn used" />
|
||||
<WagonChips schedule={row.original} />
|
||||
<MetricChip value={`${row.original.totalWeightTons}T`} label="" subtle />
|
||||
</Group>
|
||||
),
|
||||
@@ -503,21 +504,7 @@ export default function TrainScheduleV2ListPage() {
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<Ban size={15} />}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await cancel.mutateAsync({
|
||||
id: schedule.id,
|
||||
freightType: schedule.freightType ?? "CONTAINER",
|
||||
});
|
||||
toast({ title: "Schedule cancelled" });
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Cancel failed",
|
||||
description: parseError(err, "Could not cancel"),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}}
|
||||
onClick={() => setCancelTarget(schedule)}
|
||||
>
|
||||
Cancel schedule
|
||||
</Menu.Item>
|
||||
@@ -969,10 +956,118 @@ export default function TrainScheduleV2ListPage() {
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{/* Cancelling a schedule is destructive and cannot be undone, so it is
|
||||
confirmed here rather than firing straight from the row menu. */}
|
||||
<Modal
|
||||
opened={cancelTarget != null}
|
||||
onClose={() => setCancelTarget(null)}
|
||||
title="Cancel this schedule?"
|
||||
centered
|
||||
radius="md"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
<Text span fw={600} c="dark">
|
||||
{cancelTarget?.trainNumber ?? cancelTarget?.reference ?? "This train"}
|
||||
</Text>{" "}
|
||||
will be cancelled and removed from the active schedule board. This
|
||||
cannot be undone.
|
||||
</Text>
|
||||
{cancelTarget?.bookingsCount ? (
|
||||
<Text size="sm" c="red.7" fw={500}>
|
||||
{cancelTarget.bookingsCount} booking
|
||||
{cancelTarget.bookingsCount === 1 ? "" : "s"} on this train will
|
||||
need to be moved to another schedule.
|
||||
</Text>
|
||||
) : null}
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={() => setCancelTarget(null)}>
|
||||
Keep schedule
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
leftSection={<Ban size={16} />}
|
||||
loading={cancel.isPending}
|
||||
onClick={async () => {
|
||||
if (!cancelTarget) return;
|
||||
try {
|
||||
await cancel.mutateAsync({
|
||||
id: cancelTarget.id,
|
||||
freightType: cancelTarget.freightType ?? "CONTAINER",
|
||||
});
|
||||
toast({ title: "Schedule cancelled" });
|
||||
setCancelTarget(null);
|
||||
void schedulesQuery.refetch();
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Cancel failed",
|
||||
description: parseError(err, "Could not cancel"),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
Cancel schedule
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The row's wagon chips, matching the detail page's wagon plan: used is slots
|
||||
* carrying a booking allocation (never the coupled consist size), and remaining
|
||||
* excludes wagons reserved by bookings that have not paid yet — that space is
|
||||
* claimed, so it is not bookable.
|
||||
*
|
||||
* Schedules whose train set has not been built yet have no consist to measure,
|
||||
* so both figures fall back to the schedule's planned `maxWagons` ceiling.
|
||||
* Without that fallback an unbuilt 37-wagon schedule reads "0 bookable" even
|
||||
* though every one of its wagons is still free.
|
||||
*/
|
||||
function WagonChips({ schedule }: { schedule: TrainScheduleListItem }) {
|
||||
// Pre-deploy API rows carry only wagonCount; fall back so the chip still
|
||||
// renders rather than reading 0 used on every train.
|
||||
const total = schedule.wagonsTotal ?? schedule.wagonCount;
|
||||
const used = schedule.wagonsUsed;
|
||||
const reserved = schedule.wagonsReserved ?? 0;
|
||||
|
||||
// Until the train set is built there is no consist to measure against, so
|
||||
// `wagonsRemaining` (consist minus claimed) is 0 on every unbuilt schedule —
|
||||
// which reads as "fully booked" when in fact nothing is booked at all. Before
|
||||
// a consist exists, capacity is the planned ceiling minus what bookings have
|
||||
// already claimed.
|
||||
const planCeiling = schedule.maxWagons ?? 0;
|
||||
const remaining =
|
||||
total === 0 && planCeiling > 0
|
||||
? Math.max(0, planCeiling - Math.max(used ?? 0, reserved))
|
||||
: schedule.wagonsRemaining;
|
||||
|
||||
if (used == null) {
|
||||
return <MetricChip value={total} label="wgn" subtle />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* An unbuilt consist has no "used out of coupled" to show; the plan
|
||||
ceiling is the only meaningful denominator at that point. */}
|
||||
<MetricChip
|
||||
value={total === 0 && planCeiling > 0 ? `${used}/${planCeiling}` : `${used}/${total}`}
|
||||
label={total === 0 && planCeiling > 0 ? "wgn planned" : "wgn used"}
|
||||
/>
|
||||
{reserved > used ? (
|
||||
<MetricChip value={reserved} label="reserved" subtle />
|
||||
) : null}
|
||||
{remaining != null ? (
|
||||
<MetricChip value={remaining} label="bookable" subtle />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricChip({
|
||||
value,
|
||||
label,
|
||||
@@ -1078,7 +1173,7 @@ function ScheduleCard({
|
||||
</Group>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<MetricChip value={schedule.bookingsCount} label="bkg" />
|
||||
<MetricChip value={schedule.wagonCount} label="wgn used" />
|
||||
<WagonChips schedule={schedule} />
|
||||
<MetricChip value={`${schedule.totalWeightTons}T`} label="" subtle />
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
Reference in New Issue
Block a user