From 70215a9f37d5b2d80d3a1faa3b6e2899ac2ba1c1 Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 7 Aug 2026 12:10:43 +0000 Subject: [PATCH] feat(wagons): audited maintenance/availability toggle --- .../3310000000000-WagonStatusLogs.ts | 32 +++ .../wagons/dto/bulk-set-wagon-status.dto.ts | 16 +- .../entities/wagon-status-log.entity.ts | 32 +++ .../src/modules/wagons/wagons.controller.ts | 21 +- .../src/modules/wagons/wagons.module.ts | 2 + .../src/modules/wagons/wagons.service.ts | 29 ++- .../src/seed/freight-permissions.registry.ts | 10 + .../components/wagons/WagonStatusActions.tsx | 226 ++++++++++++++++++ .../backoffice/src/lib/permissions.ts | 2 + .../src/pages/fleet/FleetResourcePage.tsx | 43 ++-- .../backoffice/src/services/api.ts | 17 +- .../backoffice/src/services/wagon.service.ts | 20 +- 12 files changed, 419 insertions(+), 31 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3310000000000-WagonStatusLogs.ts create mode 100644 apps/edr-freight-api/src/modules/wagons/entities/wagon-status-log.entity.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/wagons/WagonStatusActions.tsx diff --git a/apps/edr-freight-api/src/migrations/3310000000000-WagonStatusLogs.ts b/apps/edr-freight-api/src/migrations/3310000000000-WagonStatusLogs.ts new file mode 100644 index 000000000..10c3d4799 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3310000000000-WagonStatusLogs.ts @@ -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 { + 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 { + await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_status_logs`); + } +} diff --git a/apps/edr-freight-api/src/modules/wagons/dto/bulk-set-wagon-status.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/bulk-set-wagon-status.dto.ts index 6f28418aa..f2e8b6814 100644 --- a/apps/edr-freight-api/src/modules/wagons/dto/bulk-set-wagon-status.dto.ts +++ b/apps/edr-freight-api/src/modules/wagons/dto/bulk-set-wagon-status.dto.ts @@ -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; } diff --git a/apps/edr-freight-api/src/modules/wagons/entities/wagon-status-log.entity.ts b/apps/edr-freight-api/src/modules/wagons/entities/wagon-status-log.entity.ts new file mode 100644 index 000000000..2aa4a0683 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/entities/wagon-status-log.entity.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts b/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts index 19c0bda8f..e4492e5cb 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts @@ -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); } } diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.module.ts b/apps/edr-freight-api/src/modules/wagons/wagons.module.ts index 8c8a0d11f..ec915a553 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.module.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.module.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts index 038ff83b3..2d8950fb4 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts @@ -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 { + return this.dataSource.getRepository(WagonStatusLog).find({ + where: { wagonId }, + order: { createdAt: 'DESC' }, + take: 100, + }); + } + } diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 11f0ee40c..b103dead4 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -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, diff --git a/apps/edr-freight-web/backoffice/src/components/wagons/WagonStatusActions.tsx b/apps/edr-freight-web/backoffice/src/components/wagons/WagonStatusActions.tsx new file mode 100644 index 000000000..10c7bd82a --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/wagons/WagonStatusActions.tsx @@ -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; + 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 ? ( + + ) : null} + + + setHistoryOpen(true)} + > + + + + + + {target === AVAILABLE ? "Mark available" : "Send to maintenance"} + + } + radius="lg" + centered + > + + + + Wagon{" "} + + {wagonNumber} + + + {formatFleetCell(status, "statusBadge")} + + {formatFleetCell(target ?? "", "statusBadge")} + +