feat(wagons): audited maintenance/availability toggle

This commit is contained in:
Marshal
2026-08-07 12:10:43 +00:00
parent 756325c814
commit 70215a9f37
12 changed files with 419 additions and 31 deletions

View File

@@ -0,0 +1,226 @@
import { useState } from "react";
import { Freight } from "@edr/types";
import {
ActionIcon,
Button,
Center,
Group,
Loader,
Modal,
Stack,
Table,
Text,
Textarea,
Tooltip,
} from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Activity, ArrowRight } from "lucide-react";
import { api } from "@/services/api";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { useToast } from "@/hooks/use-toast";
import { formatFleetCell } from "@/components/fleet/fleetFormat";
import type { FleetRecord } from "@/services/fleet/fleet.service";
import type { WagonStatusLog } from "@/services/wagon.service";
export interface WagonStatusActionsProps {
record: FleetRecord;
/** Caller's wagons-update permission — the toggle hides without it. */
canUpdate: boolean;
}
const AVAILABLE = Freight.WagonStatus.Available;
const MAINTENANCE = Freight.WagonStatus.Maintenance;
const fmt = (iso: string) => {
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
};
/**
* Wagons-only row actions on the fleet desk: an availability/maintenance
* toggle (with confirm + optional note, audited server-side) and the wagon's
* status-change history.
*/
const WagonStatusActions = ({ record, canUpdate }: WagonStatusActionsProps) => {
const r = record as unknown as Record<string, unknown>;
const id = r.id ? String(r.id) : "";
const wagonNumber = r.wagonNumber ? String(r.wagonNumber) : "";
const status = String(r.status ?? "");
const { toast } = useToast();
const { user } = useAuth();
// Dedicated statusToggle key lets operations/OCC flip readiness without
// holding full wagon edit; full editors keep the button too.
const canToggle =
canUpdate || hasPermission(user, FREIGHT_PERMS.wagons.statusToggle);
const [confirmOpen, setConfirmOpen] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false);
const [note, setNote] = useState("");
// Only these two statuses toggle — ASSIGNED/DETAINED/... have their own flows.
const target =
status === MAINTENANCE ? AVAILABLE : status === AVAILABLE ? MAINTENANCE : null;
const setStatus = useMutation(api.wagons.bulkSetStatus.mutationOptions());
const { data: logs = [], isLoading: logsLoading } = useQuery(
api.wagons.statusHistory.queryOptions({
input: { id },
enabled: historyOpen && Boolean(id),
}),
);
const closeConfirm = () => {
setConfirmOpen(false);
setNote("");
};
const handleConfirm = async () => {
if (!target || !id) return;
try {
await setStatus.mutateAsync({
wagonIds: [id],
status: target,
note: note.trim() || undefined,
});
toast({
title:
target === AVAILABLE
? `Wagon ${wagonNumber} marked available`
: `Wagon ${wagonNumber} sent to maintenance`,
});
closeConfirm();
} catch (err: unknown) {
const message =
(err as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Status change failed";
toast({
title: "Status change failed",
description: String(message),
variant: "destructive",
});
}
};
return (
<>
{canToggle && target ? (
<Button
size="compact-xs"
variant="light"
color={target === AVAILABLE ? "edr-green" : "orange"}
styles={{ label: { fontWeight: 500 } }}
onClick={() => setConfirmOpen(true)}
>
{target === AVAILABLE ? "Mark available" : "Send to maintenance"}
</Button>
) : null}
<Tooltip label="Status history">
<ActionIcon
variant="subtle"
color="gray"
size="sm"
onClick={() => setHistoryOpen(true)}
>
<Activity size={16} strokeWidth={2} />
</ActionIcon>
</Tooltip>
<Modal
opened={confirmOpen}
onClose={closeConfirm}
title={
<Text fw={600}>
{target === AVAILABLE ? "Mark available" : "Send to maintenance"}
</Text>
}
radius="lg"
centered
>
<Stack gap="md">
<Group gap={8} wrap="nowrap">
<Text size="sm">
Wagon{" "}
<Text span fw={700}>
{wagonNumber}
</Text>
</Text>
{formatFleetCell(status, "statusBadge")}
<ArrowRight size={14} />
{formatFleetCell(target ?? "", "statusBadge")}
</Group>
<Textarea
label="Note"
placeholder="Optional note (e.g. reason for maintenance)"
value={note}
onChange={(e) => setNote(e.currentTarget.value)}
autosize
minRows={2}
/>
<Group justify="flex-end">
<Button variant="default" onClick={closeConfirm}>
Cancel
</Button>
<Button loading={setStatus.isPending} onClick={handleConfirm}>
Confirm
</Button>
</Group>
</Stack>
</Modal>
<Modal
opened={historyOpen}
onClose={() => setHistoryOpen(false)}
title={<Text fw={600}>{`Status history — ${wagonNumber}`.trim()}</Text>}
radius="lg"
size="lg"
centered
>
{logsLoading ? (
<Center py="xl">
<Loader size="sm" />
</Center>
) : logs.length === 0 ? (
<Text c="dimmed" ta="center" py="lg" size="sm">
No status changes recorded yet.
</Text>
) : (
<Table verticalSpacing="xs" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Date</Table.Th>
<Table.Th>Change</Table.Th>
<Table.Th>Note</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(logs as WagonStatusLog[]).map((log) => (
<Table.Tr key={log.id}>
<Table.Td>
<Text size="sm">{fmt(log.createdAt)}</Text>
</Table.Td>
<Table.Td>
<Group gap={6} wrap="nowrap">
{formatFleetCell(log.fromStatus, "statusBadge")}
<ArrowRight size={13} />
{formatFleetCell(log.toStatus, "statusBadge")}
</Group>
</Table.Td>
<Table.Td>
<Text size="sm" c={log.note ? undefined : "dimmed"}>
{log.note ?? "—"}
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Modal>
</>
);
};
export default WagonStatusActions;

View File

@@ -174,6 +174,8 @@ export const FREIGHT_PERMS = {
transferView: "edr_freight_app:wagons:transfer_view",
transferCancel: "edr_freight_app:wagons:transfer_cancel",
transferCloseShort: "edr_freight_app:wagons:transfer_close_short",
/** Maintenance ⇄ availability flip on the wagons desk (audited). */
statusToggle: "edr_freight_app:wagons:status_toggle",
},
trains: {
view: "edr_freight_app:trains:view",

View File

@@ -23,6 +23,7 @@ import FleetRecordActions from "@/components/fleet/FleetRecordActions";
import FleetToolbar from "@/components/fleet/FleetToolbar";
import { matchesDayRange } from "@/hooks/useListControls";
import WagonMovementHistoryModal from "@/components/fleet/WagonMovementHistoryModal";
import WagonStatusActions from "@/components/wagons/WagonStatusActions";
import WagonYardWorkspaceModal from "@/components/wagons/WagonYardWorkspaceModal";
import { formatFleetCell, registerFleetOptionLabels } from "@/components/fleet/fleetFormat";
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
@@ -385,27 +386,33 @@ const FleetResourcePage = () => {
base.push({
id: "actions",
header: "Actions",
size: 160,
// Wagons carry the inline maintenance toggle, which needs more room.
size: config.slug === "wagons" ? 240 : 160,
meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` },
cell: ({ row }) => (
<div onClick={(e) => e.stopPropagation()} data-stop-row-click>
<FleetRecordActions
record={row.original}
config={config}
layout="compact"
onEdit={
canUpdate
? (record) => {
setEditing(record);
setFormOpen(true);
}
: undefined
}
onRemove={canDelete ? setRemoveTarget : undefined}
onPurge={canPurge ? setPurgeTarget : undefined}
onAssignDriver={canUpdate ? setAssigningDriver : undefined}
onHistory={setHistoryTarget}
/>
<Group gap={4} wrap="nowrap">
{config.slug === "wagons" ? (
<WagonStatusActions record={row.original} canUpdate={canUpdate} />
) : null}
<FleetRecordActions
record={row.original}
config={config}
layout="compact"
onEdit={
canUpdate
? (record) => {
setEditing(record);
setFormOpen(true);
}
: undefined
}
onRemove={canDelete ? setRemoveTarget : undefined}
onPurge={canPurge ? setPurgeTarget : undefined}
onAssignDriver={canUpdate ? setAssigningDriver : undefined}
onHistory={setHistoryTarget}
/>
</Group>
</div>
),
});

View File

@@ -216,6 +216,7 @@ import {
type Wagon,
type WagonListFilters,
type WagonMovementRecord,
type WagonStatusLog,
type WagonTransferRequest,
type CreateTransferRequestPayload,
type BulkFulfillResult,
@@ -1795,15 +1796,23 @@ export const api = {
),
bulkSetStatus: endpoint<
{ wagonIds: string[]; status: Wagon["status"] },
{ wagonIds: string[]; status: Wagon["status"]; note?: string },
{ updated: number }
>(
"wagons",
"bulkSetStatus",
({ wagonIds, status }) =>
wagonService.bulkSetStatus(wagonIds, status).then((r) => r.data),
({ wagonIds, status, note }) =>
wagonService.bulkSetStatus(wagonIds, status, note).then((r) => r.data),
undefined,
() => [["wagons"]],
// The fleet desk lists wagons under the "fleet" key root, not "wagons".
() => [["wagons"], QUERY_KEYS.FLEET.list("wagons")],
),
statusHistory: endpoint<{ id: string }, WagonStatusLog[]>(
"wagons",
"statusHistory",
({ id }) => wagonService.getStatusHistory(id).then((r) => r.data),
({ id }) => ["wagons", "status-history", id],
),
},

View File

@@ -94,6 +94,17 @@ export interface WagonMovementRecord {
wagon?: { id: string; wagonNumber?: string } | null;
}
/** One row of the wagon status audit trail. Returned newest first by the API. */
export interface WagonStatusLog {
id: string;
wagonId: string;
fromStatus: Freight.WagonStatus;
toStatus: Freight.WagonStatus;
changedByUserId: string | null;
note: string | null;
createdAt: string;
}
export const wagonService = {
/** One page ({items, meta}); 10 rows unless `pageSize` says otherwise. */
getAll: (filters: WagonListFilters = {}) =>
@@ -128,9 +139,12 @@ export const wagonService = {
/** Relocate many wagons to one yard in a single call (writes movement ledger). */
bulkTransfer: (wagonIds: string[], toYardId: string) =>
apiClient.post<{ moved: number }>('/wagons/bulk-transfer', { wagonIds, toYardId }),
/** Set the same status on many wagons in a single call. */
bulkSetStatus: (wagonIds: string[], status: Freight.WagonStatus) =>
apiClient.post<{ updated: number }>('/wagons/bulk-status', { wagonIds, status }),
/** Set the same status on many wagons in a single call (writes audit rows). */
bulkSetStatus: (wagonIds: string[], status: Freight.WagonStatus, note?: string) =>
apiClient.post<{ updated: number }>('/wagons/bulk-status', { wagonIds, status, note }),
/** Status audit trail for one wagon, newest first. */
getStatusHistory: (id: string) =>
apiClient.get<WagonStatusLog[]>(`/wagons/${id}/status-history`),
};
/**