mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #1161 from Tria-plc/freight_feature/usermanagement
feat(wagons): audited maintenance/availability toggle
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Audit trail for wagon status flips (Available ⇄ Maintenance and any other
|
||||
* bulk-status change): who moved which wagon from what to what, when, and why.
|
||||
* Written inside the same transaction as the status update itself.
|
||||
*/
|
||||
export class WagonStatusLogs3310000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.wagon_status_logs (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
wagon_id uuid NOT NULL REFERENCES freight.wagons(id),
|
||||
from_status varchar(30) NOT NULL,
|
||||
to_status varchar(30) NOT NULL,
|
||||
changed_by_user_id uuid,
|
||||
note text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_wagon_status_logs_wagon
|
||||
ON freight.wagon_status_logs (wagon_id, created_at DESC)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_status_logs`);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
import { WagonStatus } from '@edr/types';
|
||||
import { ArrayNotEmpty, IsArray, IsEnum, IsUUID } from 'class-validator';
|
||||
import {
|
||||
ArrayNotEmpty,
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class BulkSetWagonStatusDto {
|
||||
@IsArray()
|
||||
@@ -9,4 +17,10 @@ export class BulkSetWagonStatusDto {
|
||||
|
||||
@IsEnum(WagonStatus)
|
||||
status!: WagonStatus;
|
||||
|
||||
/** Reason shown in the wagon's status history (maintenance/availability flips). */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
note?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
|
||||
import { Wagon } from './wagon.entity';
|
||||
|
||||
/**
|
||||
* One wagon status flip (Available ⇄ Maintenance, Detained, …) — the audit
|
||||
* trail behind the maintenance/availability buttons on the wagons desk.
|
||||
* Written in the same transaction as the status change.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'wagon_status_logs' })
|
||||
@Index(['wagonId', 'createdAt'])
|
||||
export class WagonStatusLog extends BaseEntity {
|
||||
@Column({ name: 'wagon_id', type: 'uuid' })
|
||||
wagonId!: string;
|
||||
|
||||
@ManyToOne(() => Wagon)
|
||||
@JoinColumn({ name: 'wagon_id' })
|
||||
wagon?: Wagon;
|
||||
|
||||
@Column({ name: 'from_status', type: 'varchar', length: 30 })
|
||||
fromStatus!: string;
|
||||
|
||||
@Column({ name: 'to_status', type: 'varchar', length: 30 })
|
||||
toStatus!: string;
|
||||
|
||||
@Column({ name: 'changed_by_user_id', type: 'uuid', nullable: true })
|
||||
changedByUserId?: string | null;
|
||||
|
||||
@Column({ name: 'note', type: 'text', nullable: true })
|
||||
note?: string | null;
|
||||
}
|
||||
@@ -119,9 +119,22 @@ export class WagonsController {
|
||||
}
|
||||
|
||||
@Post('bulk-status')
|
||||
@FleetManage(FREIGHT_PERMS.wagons.update)
|
||||
@ApiOperation({ summary: 'Set the status of multiple wagons' })
|
||||
bulkSetStatus(@Body() dto: BulkSetWagonStatusDto) {
|
||||
return this.wagonsService.bulkSetStatus(dto);
|
||||
// One-of: the dedicated maintenance⇄availability key, full wagon edit, or
|
||||
// the legacy coarse fleet:manage — operations/OCC hold statusToggle only.
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.wagons.statusToggle,
|
||||
FREIGHT_PERMS.wagons.update,
|
||||
FREIGHT_PERMS.fleet.manage,
|
||||
])
|
||||
@ApiOperation({ summary: 'Set the status of multiple wagons (audited in wagon_status_logs)' })
|
||||
bulkSetStatus(@Body() dto: BulkSetWagonStatusDto, @CurrentUser() user: TCurrentUser) {
|
||||
return this.wagonsService.bulkSetStatus(dto, user?.id);
|
||||
}
|
||||
|
||||
@Get(':id/status-history')
|
||||
@FleetView(FREIGHT_PERMS.wagons.view)
|
||||
@ApiOperation({ summary: 'Status-flip history of a wagon (maintenance ⇄ availability audit)' })
|
||||
statusHistory(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.wagonsService.statusHistory(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Wagon } from './entities/wagon.entity';
|
||||
import { WagonMovement } from './entities/wagon-movement.entity';
|
||||
import { WagonStatusLog } from './entities/wagon-status-log.entity';
|
||||
import { WagonTransferRequest } from './entities/wagon-transfer-request.entity';
|
||||
import { Train } from '../trains/entities/train.entity';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
@@ -16,6 +17,7 @@ import { WagonTransferRequestsService } from './wagon-transfer-requests.service'
|
||||
TypeOrmModule.forFeature([
|
||||
Wagon,
|
||||
WagonMovement,
|
||||
WagonStatusLog,
|
||||
WagonTransferRequest,
|
||||
Train,
|
||||
Yard,
|
||||
|
||||
@@ -15,6 +15,7 @@ import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.dto';
|
||||
import { BulkTransferWagonsDto } from './dto/bulk-transfer-wagons.dto';
|
||||
import { BulkSetWagonStatusDto } from './dto/bulk-set-wagon-status.dto';
|
||||
import { Wagon } from './entities/wagon.entity';
|
||||
import { WagonStatusLog } from './entities/wagon-status-log.entity';
|
||||
import { WagonMovement } from './entities/wagon-movement.entity';
|
||||
import { Train } from '../trains/entities/train.entity';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
@@ -440,7 +441,10 @@ export class WagonsService {
|
||||
* from Available to Assigned in the yard workspace). Only the `status` column
|
||||
* is touched — train assignment is managed through the assign/unassign flow.
|
||||
*/
|
||||
async bulkSetStatus(dto: BulkSetWagonStatusDto): Promise<{ updated: number }> {
|
||||
async bulkSetStatus(
|
||||
dto: BulkSetWagonStatusDto,
|
||||
changedByUserId?: string,
|
||||
): Promise<{ updated: number }> {
|
||||
const { wagonIds, status } = dto;
|
||||
if (!wagonIds.length) return { updated: 0 };
|
||||
|
||||
@@ -466,10 +470,24 @@ export class WagonsService {
|
||||
);
|
||||
}
|
||||
|
||||
// Audit trail rides the same transaction — a status flip without its
|
||||
// history row can't happen. No-change wagons write no log row.
|
||||
const logs = wagons
|
||||
.filter((w) => w.status !== status)
|
||||
.map((w) =>
|
||||
queryRunner.manager.create(WagonStatusLog, {
|
||||
wagonId: w.id,
|
||||
fromStatus: w.status,
|
||||
toStatus: status,
|
||||
changedByUserId: changedByUserId ?? null,
|
||||
note: dto.note ?? null,
|
||||
}),
|
||||
);
|
||||
for (const wagon of wagons) {
|
||||
wagon.status = status;
|
||||
}
|
||||
await queryRunner.manager.save(Wagon, wagons);
|
||||
if (logs.length) await queryRunner.manager.save(WagonStatusLog, logs);
|
||||
|
||||
await queryRunner.commitTransaction();
|
||||
return { updated: wagons.length };
|
||||
@@ -481,4 +499,13 @@ export class WagonsService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Status-flip history of one wagon, newest first (maintenance/availability audit). */
|
||||
async statusHistory(wagonId: string): Promise<WagonStatusLog[]> {
|
||||
return this.dataSource.getRepository(WagonStatusLog).find({
|
||||
where: { wagonId },
|
||||
order: { createdAt: 'DESC' },
|
||||
take: 100,
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -644,6 +644,13 @@ export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
"edr_freight_app:wagons:hard_delete",
|
||||
"Permanently delete wagon",
|
||||
),
|
||||
// Maintenance ⇄ availability flip on the wagons desk — its own key so
|
||||
// operations/OCC can flip readiness without holding full wagon edit.
|
||||
perm(
|
||||
"e1b00001-0001-4000-8000-00000000000c",
|
||||
"edr_freight_app:wagons:status_toggle",
|
||||
"Flip wagon between maintenance and available",
|
||||
),
|
||||
perm(
|
||||
"e1c00001-0001-4000-8000-000000000001",
|
||||
"edr_freight_app:trains:view",
|
||||
@@ -1511,6 +1518,8 @@ export const FREIGHT_PERMS = {
|
||||
transferView: "edr_freight_app:wagons:transfer_view",
|
||||
/** Withdraw a request that has not moved any wagon yet. */
|
||||
transferCancel: "edr_freight_app:wagons:transfer_cancel",
|
||||
/** Maintenance ⇄ availability flip on the wagons desk (audited). */
|
||||
statusToggle: "edr_freight_app:wagons:status_toggle",
|
||||
/** End a request short — anyone who can fulfil may also do this. */
|
||||
transferCloseShort: "edr_freight_app:wagons:transfer_close_short",
|
||||
// Admin: read every staffer's transfer history. Without it, a user only sees
|
||||
@@ -1777,6 +1786,7 @@ const FLEET_GRANULAR_KEYS: string[] = [
|
||||
FREIGHT_PERMS.wagons.create,
|
||||
FREIGHT_PERMS.wagons.update,
|
||||
FREIGHT_PERMS.wagons.delete,
|
||||
FREIGHT_PERMS.wagons.statusToggle,
|
||||
FREIGHT_PERMS.trains.view,
|
||||
FREIGHT_PERMS.trains.create,
|
||||
FREIGHT_PERMS.trains.update,
|
||||
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
|
||||
@@ -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,10 +386,15 @@ 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>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
{config.slug === "wagons" ? (
|
||||
<WagonStatusActions record={row.original} canUpdate={canUpdate} />
|
||||
) : null}
|
||||
<FleetRecordActions
|
||||
record={row.original}
|
||||
config={config}
|
||||
@@ -406,6 +412,7 @@ const FleetResourcePage = () => {
|
||||
onAssignDriver={canUpdate ? setAssigningDriver : undefined}
|
||||
onHistory={setHistoryTarget}
|
||||
/>
|
||||
</Group>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -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],
|
||||
),
|
||||
},
|
||||
|
||||
|
||||
@@ -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`),
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user