From dd87c2a7223b2656328248c9494176ac142ac52f Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 10 Jul 2026 23:29:28 +0000 Subject: [PATCH] wagon work space, container validation --- .../contracts/contract-booking.service.ts | 144 ++++++ .../wagons/dto/bulk-set-wagon-status.dto.ts | 12 + .../wagons/dto/bulk-transfer-wagons.dto.ts | 11 + .../src/modules/wagons/wagons.controller.ts | 18 + .../src/modules/wagons/wagons.service.ts | 100 +++- .../wagons/WagonYardWorkspaceModal.tsx | 441 ++++++++++++++++++ .../bookings/DocumentClearanceDetailPage.tsx | 4 + .../ContractTemplateEditorPage.tsx | 27 +- .../pages/contracts/GlClearanceDetailPage.tsx | 57 ++- .../src/pages/fleet/FleetResourcePage.tsx | 81 ++-- .../backoffice/src/services/api.ts | 24 + .../backoffice/src/services/wagon.service.ts | 6 + 12 files changed, 878 insertions(+), 47 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/wagons/dto/bulk-set-wagon-status.dto.ts create mode 100644 apps/edr-freight-api/src/modules/wagons/dto/bulk-transfer-wagons.dto.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index a0da43784..c09f2ccb0 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, + ConflictException, ForbiddenException, Inject, Injectable, @@ -192,6 +193,11 @@ export class ContractBookingService { // their only chance to hard-block an unbalanceable set. Entry order is // irrelevant (the check sorts by weight before pairing). await this.assert20ftPairableAtCreate(dto); + // A container number may appear once per train (same day + route). + await this.assertContainerNumbersAvailable(dto, { + originYardId: route?.originYardId ?? null, + destinationYardId: route?.destinationYardId ?? null, + }); } // Denormalize route/direction/freight onto the booking for the scheduling engine. @@ -575,6 +581,15 @@ export class ContractBookingService { if (freightType === 'CONTAINER') { await this.assertWithinMaxCapacity(contract, dto); await this.assert20ftPairableAtCreate(dto); + // A container number may appear once per train (same day + route). + await this.assertContainerNumbersAvailable( + dto, + { + originYardId: booking.originYardId, + destinationYardId: booking.destinationYardId, + }, + booking.id, + ); await this.persistContainers(booking.id, contract, dto); } await this.bookingsRepository.update(booking.id, { @@ -653,6 +668,10 @@ export class ContractBookingService { Boolean(contract.customsClearingEnabled); await this.finalizeContractBooking(booking.id, contract, generalCustoms); await this.maybeCompleteContract(contract); + } else if (freightType === 'CONTAINER') { + // Resubmit only re-picks the shipment day — the persisted container + // numbers must be free on the newly chosen train day too. + await this.assertPersistedContainersAvailable(booking, dto.scheduledDate); } // Binding day + open-departure validation, status OPERATION_REQUEST_PENDING @@ -1344,6 +1363,131 @@ export class ContractBookingService { * balanced onto wagons (pair diff over the global cap). Same rule the * shipment-form preview reports as `pairingErrors`, enforced server-side. */ + /** + * A physical container rides one train only. Reject the submission when a + * container number is entered twice in the same booking (the portal checks + * this client-side, the API must not trust it) or already sits on another + * customer's active booking for the same train — same shipment day AND same + * route (origin/destination yards). + */ + private async assertContainerNumbersAvailable( + dto: CreateBookingUnderContractDto, + route: { originYardId?: string | null; destinationYardId?: string | null }, + excludeBookingId?: string, + ): Promise { + const numbers = (dto.containers ?? []).flatMap((line) => + (line.units ?? []) + .map((u) => (u.containerNumber ?? '').trim().toUpperCase()) + .filter((n) => n.length > 0), + ); + if (!numbers.length) return; + + const seen = new Set(); + const withinBooking = new Set(); + for (const n of numbers) { + if (seen.has(n)) withinBooking.add(n); + seen.add(n); + } + if (withinBooking.size) { + throw new BadRequestException( + `Duplicate container number(s) in this booking: ${[...withinBooking].join(', ')} — each container can only be entered once.`, + ); + } + + // Intercity bookings have no shipment day yet — nothing to clash with. + if (!dto.scheduledDate) return; + + await this.assertNumbersFreeOnTrain( + numbers, + dto.scheduledDate, + route, + excludeBookingId, + ); + } + + /** + * Same train guard for a booking whose containers are already persisted + * (resubmit after OPERATION_CHANGES_REQUESTED only re-picks the day): its + * stored numbers must be free on the newly chosen day for its route. + */ + private async assertPersistedContainersAvailable( + booking: Booking, + scheduledDate: string, + ): Promise { + const rows: Array<{ containerNumber: string }> = await this.dataSource + .getRepository(BookingContainerUnit) + .createQueryBuilder('unit') + .innerJoin(BookingContainer, 'line', 'line.id = unit.booking_container_id') + .select('unit.container_number', 'containerNumber') + .where('line.booking_id = :bookingId', { bookingId: booking.id }) + .getRawMany(); + const numbers = rows.map((r) => r.containerNumber).filter(Boolean); + if (!numbers.length) return; + await this.assertNumbersFreeOnTrain( + numbers, + scheduledDate, + { + originYardId: booking.originYardId, + destinationYardId: booking.destinationYardId, + }, + booking.id, + ); + } + + /** + * Reject when any of `numbers` sits on another active booking of the same + * train — same day and same route. Bookings without route yards (legacy + * rows) are matched on the day alone rather than let through. + */ + private async assertNumbersFreeOnTrain( + numbers: string[], + scheduledDate: string, + route: { originYardId?: string | null; destinationYardId?: string | null }, + excludeBookingId?: string, + ): Promise { + const qb = this.dataSource + .getRepository(BookingContainerUnit) + .createQueryBuilder('unit') + .innerJoin(BookingContainer, 'line', 'line.id = unit.booking_container_id') + .innerJoin(Booking, 'b', 'b.id = line.booking_id') + .select('unit.container_number', 'containerNumber') + .addSelect('b.reference', 'reference') + .where('unit.container_number IN (:...numbers)', { numbers }) + .andWhere('b.scheduled_date::date = :day::date', { day: scheduledDate }) + .andWhere('b.status NOT IN (:...terminal)', { + terminal: TERMINAL_BOOKING_STATUSES, + }) + .andWhere('b.deleted_at IS NULL'); + if (route.originYardId && route.destinationYardId) { + // Same train = same day + same corridor. A clashing booking whose yards + // were never denormalized still blocks (NULL yards match any route). + qb.andWhere( + '(b.origin_yard_id IS NULL OR b.origin_yard_id = :originYardId)', + { originYardId: route.originYardId }, + ).andWhere( + '(b.destination_yard_id IS NULL OR b.destination_yard_id = :destinationYardId)', + { destinationYardId: route.destinationYardId }, + ); + } + if (excludeBookingId) { + qb.andWhere('b.id != :excludeBookingId', { excludeBookingId }); + } + const clashes: Array<{ containerNumber: string; reference: string }> = + await qb.getRawMany(); + + if (clashes.length) { + const detail = [ + ...new Map(clashes.map((c) => [c.containerNumber, c])).values(), + ] + .map((c) => `${c.containerNumber} (booking ${c.reference})`) + .join(', '); + throw new ConflictException( + `Container(s) already booked on this route for ${scheduledDate}: ${detail}. ` + + 'A container can only be on one booking per train — remove it or pick another shipment day.', + ); + } + } + private async assert20ftPairableAtCreate( dto: CreateBookingUnderContractDto, ): Promise { 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 new file mode 100644 index 000000000..6f28418aa --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/dto/bulk-set-wagon-status.dto.ts @@ -0,0 +1,12 @@ +import { WagonStatus } from '@edr/types'; +import { ArrayNotEmpty, IsArray, IsEnum, IsUUID } from 'class-validator'; + +export class BulkSetWagonStatusDto { + @IsArray() + @ArrayNotEmpty() + @IsUUID('4', { each: true }) + wagonIds!: string[]; + + @IsEnum(WagonStatus) + status!: WagonStatus; +} diff --git a/apps/edr-freight-api/src/modules/wagons/dto/bulk-transfer-wagons.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/bulk-transfer-wagons.dto.ts new file mode 100644 index 000000000..bf1f7f783 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/dto/bulk-transfer-wagons.dto.ts @@ -0,0 +1,11 @@ +import { ArrayNotEmpty, IsArray, IsUUID } from 'class-validator'; + +export class BulkTransferWagonsDto { + @IsArray() + @ArrayNotEmpty() + @IsUUID('4', { each: true }) + wagonIds!: string[]; + + @IsUUID() + toYardId!: string; +} 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 1d5287dba..556907cde 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts @@ -10,12 +10,16 @@ import { Query, } from '@nestjs/common'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CurrentUser } from '@edr/api-common'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import { FleetManage, FleetView } from '../../common/booking-guards'; import { CreateWagonDto } from './dto/create-wagon.dto'; import { ListWagonsQueryDto } from './dto/list-wagons-query.dto'; import { UpdateWagonDto } from './dto/update-wagon.dto'; import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.dto'; import { ReorderWagonsDto } from './dto/reorder-wagons.dto'; +import { BulkTransferWagonsDto } from './dto/bulk-transfer-wagons.dto'; +import { BulkSetWagonStatusDto } from './dto/bulk-set-wagon-status.dto'; import { WagonsService } from './wagons.service'; @ApiTags('wagons') @@ -78,6 +82,20 @@ export class WagonsController { unassignFromTrain(@Param('id', ParseUUIDPipe) id: string) { return this.wagonsService.unassignFromTrain(id); } + + @Post('bulk-transfer') + @FleetManage() + @ApiOperation({ summary: 'Transfer multiple wagons to a destination yard' }) + bulkTransfer(@Body() dto: BulkTransferWagonsDto, @CurrentUser() user: TCurrentUser) { + return this.wagonsService.bulkTransfer(dto, user?.id); + } + + @Post('bulk-status') + @FleetManage() + @ApiOperation({ summary: 'Set the status of multiple wagons' }) + bulkSetStatus(@Body() dto: BulkSetWagonStatusDto) { + return this.wagonsService.bulkSetStatus(dto); + } } // Separate controller for train‑specific reorder (registered in module) 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 b010c0351..ad39fbf7a 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts @@ -1,15 +1,18 @@ import { WagonMovementKind, WagonStatus } from '@edr/types'; import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, DataSource, FindOptionsOrder, FindOptionsWhere, ILike } from 'typeorm'; +import { Repository, DataSource, FindOptionsOrder, FindOptionsWhere, ILike, In } from 'typeorm'; import { CreateWagonDto } from './dto/create-wagon.dto'; import { ListWagonsQueryDto } from './dto/list-wagons-query.dto'; import { UpdateWagonDto } from './dto/update-wagon.dto'; import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.dto'; import { ReorderWagonsDto } from './dto/reorder-wagons.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 { WagonMovement } from './entities/wagon-movement.entity'; import { Train } from '../trains/entities/train.entity'; +import { Yard } from '../rule-engine/entities/yard.entity'; @Injectable() export class WagonsService { @@ -168,6 +171,101 @@ export class WagonsService { return this.wagonRepo.save(wagon); } + /** + * Relocate many wagons to one destination yard in a single transaction. Each + * wagon whose yard actually changes gets a `wagon_movements` ledger row (kind + * `Manual`) so the yard history stays auditable — mirrors the single-wagon + * `update` path. Wagons already in the destination yard are skipped. + */ + async bulkTransfer( + dto: BulkTransferWagonsDto, + userId?: string | null, + ): Promise<{ moved: number }> { + const { wagonIds, toYardId } = dto; + if (!wagonIds.length) return { moved: 0 }; + + const yard = await this.dataSource + .getRepository(Yard) + .findOne({ where: { id: toYardId } }); + if (!yard) throw new NotFoundException('Destination yard not found'); + + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + try { + const wagons = await queryRunner.manager.find(Wagon, { + where: { id: In(wagonIds) }, + }); + if (wagons.length !== wagonIds.length) { + throw new NotFoundException('One or more wagons not found'); + } + + let moved = 0; + for (const wagon of wagons) { + const previousYardId = wagon.currentYardId ?? null; + if (previousYardId === toYardId) continue; + wagon.currentYardId = toYardId; + // Drop the eager relation so the scalar FK wins on save (see `update`). + wagon.currentYard = null; + await queryRunner.manager.save(Wagon, wagon); + await queryRunner.manager.save( + queryRunner.manager.create(WagonMovement, { + wagonId: wagon.id, + fromYardId: previousYardId, + toYardId, + kind: WagonMovementKind.Manual, + movedByUserId: userId ?? null, + occurredAt: new Date(), + }), + ); + moved++; + } + + await queryRunner.commitTransaction(); + return { moved }; + } catch (err) { + await queryRunner.rollbackTransaction(); + throw err; + } finally { + await queryRunner.release(); + } + } + + /** + * Set the same status on many wagons in one transaction (e.g. flip a batch + * 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 }> { + const { wagonIds, status } = dto; + if (!wagonIds.length) return { updated: 0 }; + + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + try { + const wagons = await queryRunner.manager.find(Wagon, { + where: { id: In(wagonIds) }, + }); + if (wagons.length !== wagonIds.length) { + throw new NotFoundException('One or more wagons not found'); + } + + for (const wagon of wagons) { + wagon.status = status; + } + await queryRunner.manager.save(Wagon, wagons); + + await queryRunner.commitTransaction(); + return { updated: wagons.length }; + } catch (err) { + await queryRunner.rollbackTransaction(); + throw err; + } finally { + await queryRunner.release(); + } + } + async reorderWagons(_trainId: string, dto: ReorderWagonsDto): Promise { const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); diff --git a/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx b/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx new file mode 100644 index 000000000..ef9621376 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx @@ -0,0 +1,441 @@ +import { Freight } from "@edr/types"; +import { + Badge, + Box, + Button, + Card, + Divider, + Grid, + Group, + Loader, + Modal, + NumberInput, + Select, + SimpleGrid, + Stack, + Text, + ThemeIcon, + Title, +} from "@mantine/core"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { ArrowRightLeft, PackageCheck, Repeat, Warehouse } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; + +import { api } from "@/services/api"; +import { useToast } from "@/hooks/use-toast"; +import type { Wagon } from "@/services/wagon.service"; + +export interface WagonYardWorkspaceModalProps { + opened: boolean; + onClose: () => void; +} + +const numberOrZero = (v: number | string): number => { + const n = typeof v === "number" ? v : Number(v); + return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0; +}; + +/** + * Yard workspace: pick a yard + wagon type (the two selects filter each other + * to only in-inventory combinations), see how many wagons of that type sit in + * that yard and how they split Available / Assigned, then bulk-transfer a + * quantity to another yard or flip a quantity between Available and Assigned. + */ +const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalProps) => { + const { toast } = useToast(); + + const { data: wagons = [], isLoading: wagonsLoading } = useQuery( + api.wagons.list.queryOptions({ input: {} }), + ); + const { data: yards = [] } = useQuery(api.routes.yards.queryOptions()); + const { data: wagonTypes = [] } = useQuery(api.wagonTypes.list.queryOptions()); + + const [yardId, setYardId] = useState(null); + const [typeId, setTypeId] = useState(null); + + const [transferYardId, setTransferYardId] = useState(null); + const [transferQty, setTransferQty] = useState(1); + const [toAssignedQty, setToAssignedQty] = useState(1); + const [toAvailableQty, setToAvailableQty] = useState(1); + + const transfer = useMutation(api.wagons.bulkTransfer.mutationOptions()); + const setStatus = useMutation(api.wagons.bulkSetStatus.mutationOptions()); + + const yardLabel = useMemo(() => { + const byId = new Map(yards.map((y) => [y.id, y.label || y.code || y.id])); + return (id: string) => byId.get(id) ?? id; + }, [yards]); + + const typeLabel = useMemo(() => { + const byId = new Map( + wagonTypes.map((t) => [t.id, `${t.code}${t.name ? ` - ${t.name}` : ""}`]), + ); + return (id: string) => byId.get(id) ?? id; + }, [wagonTypes]); + + // Only wagons that currently sit in a yard participate in the workspace. + const yardWagons = useMemo( + () => wagons.filter((w): w is Wagon & { currentYardId: string } => Boolean(w.currentYardId)), + [wagons], + ); + + // Each select is constrained by the other's current value so only real + // (yard, type) combinations that hold stock can be picked. + const yardOptions = useMemo(() => { + const ids = new Set(); + for (const w of yardWagons) { + if (typeId && w.wagonTypeId !== typeId) continue; + ids.add(w.currentYardId); + } + return [...ids] + .map((id) => ({ value: id, label: yardLabel(id) })) + .sort((a, b) => a.label.localeCompare(b.label)); + }, [yardWagons, typeId, yardLabel]); + + const typeOptions = useMemo(() => { + const ids = new Set(); + for (const w of yardWagons) { + if (yardId && w.currentYardId !== yardId) continue; + ids.add(w.wagonTypeId); + } + return [...ids] + .map((id) => ({ value: id, label: typeLabel(id) })) + .sort((a, b) => a.label.localeCompare(b.label)); + }, [yardWagons, yardId, typeLabel]); + + const matching = useMemo(() => { + if (!yardId || !typeId) return [] as Wagon[]; + return yardWagons.filter((w) => w.currentYardId === yardId && w.wagonTypeId === typeId); + }, [yardWagons, yardId, typeId]); + + const availableWagons = useMemo( + () => matching.filter((w) => w.status === Freight.WagonStatus.Available), + [matching], + ); + const assignedWagons = useMemo( + () => matching.filter((w) => w.status === Freight.WagonStatus.Assigned), + [matching], + ); + // Available first so a partial transfer moves idle wagons before assigned ones. + const transferPool = useMemo(() => { + const rest = matching.filter( + (w) => + w.status !== Freight.WagonStatus.Available && + w.status !== Freight.WagonStatus.Assigned, + ); + return [...availableWagons, ...assignedWagons, ...rest]; + }, [matching, availableWagons, assignedWagons]); + + const total = matching.length; + const availableCount = availableWagons.length; + const assignedCount = assignedWagons.length; + + const destinationYardOptions = useMemo( + () => + yards + .filter((y) => y.id !== yardId) + .map((y) => ({ value: y.id, label: y.label || y.code || y.id })) + .sort((a, b) => a.label.localeCompare(b.label)), + [yards, yardId], + ); + + const bothSelected = Boolean(yardId && typeId); + + // Reset the action inputs whenever the yard/type selection changes. + useEffect(() => { + setTransferYardId(null); + setTransferQty(1); + setToAssignedQty(1); + setToAvailableQty(1); + }, [yardId, typeId]); + + // Reset the whole workspace when it is reopened. + useEffect(() => { + if (!opened) { + setYardId(null); + setTypeId(null); + } + }, [opened]); + + const showError = (err: unknown, fallback: string) => { + const message = + (err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? fallback; + toast({ title: fallback, description: String(message), variant: "destructive" }); + }; + + const handleTransfer = async () => { + const n = numberOrZero(transferQty); + if (!transferYardId || n < 1) return; + const ids = transferPool.slice(0, n).map((w) => w.id); + if (!ids.length) return; + try { + const res = await transfer.mutateAsync({ wagonIds: ids, toYardId: transferYardId }); + toast({ title: `Transferred ${res.moved} wagon(s) to ${yardLabel(transferYardId)}` }); + setTransferQty(1); + setTransferYardId(null); + } catch (err) { + showError(err, "Transfer failed"); + } + }; + + const handleFlip = async ( + pool: Wagon[], + qty: number | string, + status: Freight.WagonStatus, + label: string, + reset: () => void, + ) => { + const n = numberOrZero(qty); + if (n < 1) return; + const ids = pool.slice(0, n).map((w) => w.id); + if (!ids.length) return; + try { + const res = await setStatus.mutateAsync({ wagonIds: ids, status }); + toast({ title: `${res.updated} wagon(s) set to ${label}` }); + reset(); + } catch (err) { + showError(err, "Status update failed"); + } + }; + + const busy = transfer.isPending || setStatus.isPending; + + return ( + + + + +
+ Wagon Yard Workspace + + Move and re-status wagons by yard and type + +
+ + } + > + + {/* ---- Selectors ---- */} + + + + + + + {wagonsLoading ? ( + + + + ) : !bothSelected ? ( + + + Select a yard and a wagon type to see how many wagons are there and act on them. + + + ) : ( + <> + {/* ---- Counts ---- */} + + + + + + + + + + {/* ---- Transfer ---- */} + + + + + + + Transfer to another yard + + + + { + setListFilterValues((prev) => ({ + ...prev, + [filter.key]: value ?? "ALL", + })); + setPagination((prev) => ({ ...prev, pageIndex: 0 })); + }} + size="sm" + radius="lg" + w={200} + searchable={filter.data.length > 8} + comboboxProps={{ withinPortal: true }} + styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }} + /> ))} ) : hasStatusColumn && statusFilterOptions.length > 1 ? ( @@ -586,6 +598,13 @@ const FleetResourcePage = () => {
+ {slug === "wagons" ? ( + setWagonWorkspaceOpen(false)} + /> + ) : null} + {slug === "wagons" ? ( [["wagons"]], ), + + bulkTransfer: endpoint< + { wagonIds: string[]; toYardId: string }, + { moved: number } + >( + "wagons", + "bulkTransfer", + ({ wagonIds, toYardId }) => + wagonService.bulkTransfer(wagonIds, toYardId).then((r) => r.data), + undefined, + () => [["wagons"]], + ), + + bulkSetStatus: endpoint< + { wagonIds: string[]; status: Wagon["status"] }, + { updated: number } + >( + "wagons", + "bulkSetStatus", + ({ wagonIds, status }) => + wagonService.bulkSetStatus(wagonIds, status).then((r) => r.data), + undefined, + () => [["wagons"]], + ), }, trains: { diff --git a/apps/edr-freight-web/backoffice/src/services/wagon.service.ts b/apps/edr-freight-web/backoffice/src/services/wagon.service.ts index e30320988..e096a3126 100644 --- a/apps/edr-freight-web/backoffice/src/services/wagon.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/wagon.service.ts @@ -83,4 +83,10 @@ export const wagonService = { create: (data: Partial) => apiClient.post('/wagons', data), update: (id: string, data: Partial) => apiClient.patch(`/wagons/${id}`, data), delete: (id: string) => apiClient.delete(`/wagons/${id}`), + /** 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 }), };