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:
marshalyordanos
2026-08-12 09:36:50 +03:00
parent 35e5404b41
commit 5da36eb128
77 changed files with 6275 additions and 296 deletions

View File

@@ -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)}

View File

@@ -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>