From ed3c8307bb2acc15fa5707715f6989f613b45b90 Mon Sep 17 00:00:00 2001 From: Marshal Date: Tue, 21 Jul 2026 23:49:10 +0000 Subject: [PATCH] refactor(train-scheduling): rename and restructure container movement logic --- .../dto/move-container-item.dto.ts | 14 - .../dto/move-wagon-load.dto.ts | 11 + .../train-scheduling.controller.ts | 15 +- .../train-scheduling.service.spec.ts | 224 ++++++------ .../train-scheduling.service.ts | 323 +++++++----------- .../backoffice/src/auth/types.ts | 1 + .../InteractiveTrainConsist.tsx | 156 ++++----- .../compositionEditor/TrainConsistView.tsx | 35 +- .../compositionEditor/WagonCard.tsx | 154 ++++----- .../backoffice/src/constants/URLS.ts | 4 +- .../backoffice/src/lib/permissions.ts | 30 +- .../backoffice/src/services/api.ts | 18 +- .../src/services/trainScheduling.service.ts | 8 +- 13 files changed, 470 insertions(+), 523 deletions(-) delete mode 100644 apps/edr-freight-api/src/modules/train-scheduling/dto/move-container-item.dto.ts create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/dto/move-wagon-load.dto.ts diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/move-container-item.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/move-container-item.dto.ts deleted file mode 100644 index c21780d06..000000000 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/move-container-item.dto.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { IsOptional, IsUUID } from 'class-validator'; - -export class MoveContainerItemDto { - @IsUUID() - targetTrainSetWagonId!: string; - - /** - * Swap with this container on the target wagon instead of requiring free - * space there. Same-wagon swaps exchange the two slot positions. - */ - @IsUUID() - @IsOptional() - swapWithItemId?: string; -} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/move-wagon-load.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/move-wagon-load.dto.ts new file mode 100644 index 000000000..a93a8afc6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/move-wagon-load.dto.ts @@ -0,0 +1,11 @@ +import { IsUUID } from 'class-validator'; + +export class MoveWagonLoadDto { + /** + * Where the source wagon's whole load goes: a train-set wagon slot (empty → + * move, loaded → swap the two loads) or an empty consist-only physical wagon + * of the built train (→ the slot repins onto it). + */ + @IsUUID() + targetWagonId!: string; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index dd9fbcf7c..b1f79733e 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -28,7 +28,7 @@ import { GetEligibleBookingsDto } from "./dto/get-eligible-bookings.dto"; import { GetEligibleBulkBookingsDto } from "./dto/get-eligible-bulk-bookings.dto"; import { GetEligibleContainerBookingsDto } from "./dto/get-eligible-container-bookings.dto"; import { PinWagonsDto } from "./dto/pin-wagons.dto"; -import { MoveContainerItemDto } from "./dto/move-container-item.dto"; +import { MoveWagonLoadDto } from "./dto/move-wagon-load.dto"; import { UpdateContainerItemDto } from "./dto/update-container-item.dto"; import { UpdateImportLoadingStatusDto } from "./dto/update-import-loading-status.dto"; import { PreviewBulkTrainScheduleDto } from "./dto/preview-bulk-train-schedule.dto"; @@ -375,17 +375,18 @@ export class TrainSchedulingController { return this.trainSchedulingService.updateContainerItem(id, itemId, dto); } - @Post("schedules/:id/container-items/:itemId/move") + @Post("schedules/:id/wagons/:wagonId/move-load") @TrainSchedulingManage() @ApiOperation({ - summary: "Move a container to another wagon (optionally swapping two containers)", + summary: + "Move a wagon's whole load to another wagon (empty → move/repin, loaded → swap loads)", }) - moveContainerItem( + moveWagonLoad( @Param("id", ParseUUIDPipe) id: string, - @Param("itemId", ParseUUIDPipe) itemId: string, - @Body() dto: MoveContainerItemDto, + @Param("wagonId", ParseUUIDPipe) wagonId: string, + @Body() dto: MoveWagonLoadDto, ) { - return this.trainSchedulingService.moveContainerItem(id, itemId, dto); + return this.trainSchedulingService.moveWagonLoad(id, wagonId, dto); } @Get("schedules/:id/unassigned-bookings") diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts index 8bf26b7f3..b6410d0cf 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts @@ -5,7 +5,6 @@ import { Wagon } from '../wagons/entities/wagon.entity'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity'; import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity'; -import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity'; import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity'; import { TrainSchedulingService } from './train-scheduling.service'; @@ -1083,72 +1082,75 @@ describe('TrainSchedulingService', () => { }); }); - describe('moveContainerItem — staff rearrange', () => { - const wagon1 = { id: 'w1', sequenceNo: 1, capacityTons: 61 }; - const wagon2 = { id: 'w2', sequenceNo: 2, capacityTons: 61 }; - const schedule = { + describe('moveWagonLoad — staff rearrange', () => { + const containerType = { + code: 'NX70', + supportedLoadTypes: ['CONTAINER'], + supportsContainer: true, + }; + let slotA: Record; + let slotB: Record; + let allocsByWagon: Record>>; + let allocRepo: { find: jest.Mock; update: jest.Mock }; + let slotRepo: { update: jest.Mock }; + let wagonRepo: { findOne: jest.Mock }; + + const makeSchedule = (over: Record = {}) => ({ id: 'sched-1', status: 'SCHEDULED', - trainSet: { wagons: [wagon1, wagon2] }, - }; - - let sourceAlloc: Record; - let item: Record; - let itemRepo: { findOne: jest.Mock; update: jest.Mock; count: jest.Mock }; - let allocRepo: { - find: jest.Mock; - findOne: jest.Mock; - create: jest.Mock; - save: jest.Mock; - update: jest.Mock; - delete: jest.Mock; - }; - let wagon2Allocs: Array>; + trainSetId: 'ts-1', + trainSet: { trainId: 'train-1', wagons: [slotA, slotB] }, + ...over, + }); beforeEach(() => { - sourceAlloc = { - id: 'alloc-1', - trainSetWagonId: 'w1', - bookingId: 'b1', - allocatedWeightTons: 20, - loadType: 'CONTAINER', - status: 'PLANNED', - containerItems: [], + slotA = { + id: 'wA', + sequenceNo: 1, + capacityTons: 61, + lengthMeters: 14, + assignedWeightTons: 40, + status: 'RESERVED', + boardYardId: 'yard-1', + alightYardId: null, + wagonType: containerType, }; - item = { - id: 'item-1', - wagonBookingAllocationId: 'alloc-1', - positionOnWagon: 1, - grossWeightTons: 20, - containerType: { sizeFt: 20 }, - allocation: sourceAlloc, + slotB = { + id: 'wB', + sequenceNo: 2, + capacityTons: 61, + lengthMeters: 14, + assignedWeightTons: 25, + status: 'RESERVED', + boardYardId: null, + alightYardId: null, + wagonType: containerType, + }; + allocsByWagon = { + // 20ft pair (two allocations sharing wagon A) — must travel together. + wA: [ + { id: 'alloc-a1', trainSetWagonId: 'wA', bookingId: 'b1', allocatedWeightTons: 20, loadType: 'CONTAINER' }, + { id: 'alloc-a2', trainSetWagonId: 'wA', bookingId: 'b2', allocatedWeightTons: 20, loadType: 'CONTAINER' }, + ], + // one 40ft on wagon B. + wB: [ + { id: 'alloc-b1', trainSetWagonId: 'wB', bookingId: 'b3', allocatedWeightTons: 25, loadType: 'CONTAINER' }, + ], }; - sourceAlloc.containerItems = [item]; - wagon2Allocs = []; - trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(schedule); - itemRepo = { - findOne: jest.fn().mockResolvedValue(item), - update: jest.fn().mockResolvedValue(undefined), - count: jest.fn().mockResolvedValue(0), - }; + trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(makeSchedule()); allocRepo = { find: jest.fn().mockImplementation(({ where }: { where: { trainSetWagonId: string } }) => - Promise.resolve(where.trainSetWagonId === 'w1' ? [sourceAlloc] : wagon2Allocs), - ), - findOne: jest.fn().mockImplementation(({ where }: { where: { id?: string } }) => - Promise.resolve(where.id === 'alloc-1' ? { ...sourceAlloc } : null), - ), - create: jest.fn((v: unknown) => v), - save: jest.fn().mockImplementation((v: Record) => - Promise.resolve({ ...v, id: 'alloc-new' }), + Promise.resolve(allocsByWagon[where.trainSetWagonId] ?? []), ), update: jest.fn().mockResolvedValue(undefined), - delete: jest.fn().mockResolvedValue(undefined), }; + slotRepo = { update: jest.fn().mockResolvedValue(undefined) }; + wagonRepo = { findOne: jest.fn().mockResolvedValue(null) }; dataSource.getRepository.mockImplementation((entity: unknown) => { - if (entity === WagonAllocationContainerItem) return itemRepo; if (entity === WagonBookingAllocation) return allocRepo; + if (entity === TrainSetWagon) return slotRepo; + if (entity === Wagon) return wagonRepo; return { find: jest.fn().mockResolvedValue([]) }; }); dataSource.transaction.mockImplementation( @@ -1164,61 +1166,87 @@ describe('TrainSchedulingService', () => { }); it('rejects moves on a dispatched train', async () => { - trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({ - ...schedule, - status: 'DISPATCHED', - }); + trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue( + makeSchedule({ status: 'DISPATCHED' }), + ); await expect( - service.moveContainerItem('sched-1', 'item-1', { targetTrainSetWagonId: 'w2' }), + service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'wB' }), ).rejects.toThrow(BadRequestException); expect(dataSource.transaction).not.toHaveBeenCalled(); }); - it('rejects a target wagon that has no TEU room left', async () => { - wagon2Allocs = [ - { - id: 'alloc-2', - trainSetWagonId: 'w2', - bookingId: 'b2', - allocatedWeightTons: 25, - loadType: 'CONTAINER', - containerItems: [{ id: 'item-40', containerType: { sizeFt: 40 } }], - }, - ]; + it('404s when the target is neither a slot nor a consist wagon of this train', async () => { await expect( - service.moveContainerItem('sched-1', 'item-1', { targetTrainSetWagonId: 'w2' }), - ).rejects.toThrow(/no room/); + service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'nope' }), + ).rejects.toThrow(/not part of this schedule/); }); - it('rejects a bulk-loaded target wagon', async () => { - wagon2Allocs = [ - { - id: 'alloc-2', - trainSetWagonId: 'w2', - bookingId: 'b2', - allocatedWeightTons: 40, - loadType: 'BULK', - containerItems: [], - }, - ]; - await expect( - service.moveContainerItem('sched-1', 'item-1', { targetTrainSetWagonId: 'w2' }), - ).rejects.toThrow(/bulk/); - }); + it('swaps two loaded wagons: every allocation crosses over, load fields swap', async () => { + await service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'wB' }); - it('moves a container to an empty wagon and re-homes its allocation', async () => { - await service.moveContainerItem('sched-1', 'item-1', { targetTrainSetWagonId: 'w2' }); - - // A new allocation for the booking was created on the target wagon… - expect(allocRepo.save).toHaveBeenCalledWith( - expect.objectContaining({ trainSetWagonId: 'w2', bookingId: 'b1' }), - ); - // …the container item now hangs off it… - expect(itemRepo.update).toHaveBeenCalledWith('item-1', { - wagonBookingAllocationId: 'alloc-new', + // The 20ft pair moved together onto wagon B… + expect(allocRepo.update).toHaveBeenCalledWith('alloc-a1', { trainSetWagonId: 'wB' }); + expect(allocRepo.update).toHaveBeenCalledWith('alloc-a2', { trainSetWagonId: 'wB' }); + // …and the 40ft came back to wagon A. + expect(allocRepo.update).toHaveBeenCalledWith('alloc-b1', { trainSetWagonId: 'wA' }); + // Load-coupled slot fields follow their loads. + expect(slotRepo.update).toHaveBeenCalledWith('wB', { + assignedWeightTons: 40, + status: 'RESERVED', + boardYardId: 'yard-1', + alightYardId: null, }); - // …and the emptied source allocation was deleted, not left at 0 items. - expect(allocRepo.delete).toHaveBeenCalledWith('alloc-1'); + expect(slotRepo.update).toHaveBeenCalledWith('wA', { + assignedWeightTons: 25, + status: 'RESERVED', + boardYardId: null, + alightYardId: null, + }); + }); + + it('repins the slot onto an empty consist-only wagon (the 404 case)', async () => { + wagonRepo.findOne.mockResolvedValue({ + id: 'phys-9', + wagonTypeId: 'wt-1', + wagonNumber: 'WGN-9', + wagonType: { ...containerType, capacityTons: 70, lengthMeters: 14 }, + }); + + await service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'phys-9' }); + + expect(wagonRepo.findOne).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: 'phys-9', trainId: 'train-1' } }), + ); + // Repin: wagon identity moves onto the slot; allocations stay put. + expect(slotRepo.update).toHaveBeenCalledWith('wA', { + physicalWagonId: 'phys-9', + wagonTypeId: 'wt-1', + capacityTons: 70, + lengthMeters: 14, + }); + expect(allocRepo.update).not.toHaveBeenCalled(); + }); + + it('rejects a bulk load onto a wagon whose type only supports containers', async () => { + allocsByWagon.wA = [ + { id: 'alloc-bulk', trainSetWagonId: 'wA', bookingId: 'b9', allocatedWeightTons: 50, loadType: 'BULK' }, + ]; + allocsByWagon.wB = []; + + await expect( + service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'wB' }), + ).rejects.toThrow(/cannot carry a bulk load/); + }); + + it('rejects when the incoming load exceeds the receiving wagon payload', async () => { + allocsByWagon.wA = [ + { id: 'alloc-heavy', trainSetWagonId: 'wA', bookingId: 'b9', allocatedWeightTons: 70, loadType: 'CONTAINER' }, + ]; + allocsByWagon.wB = []; + + await expect( + service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'wB' }), + ).rejects.toThrow(/over its/); }); }); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 6f154fb81..13624691a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -77,7 +77,7 @@ import { TrainScheduleFreightType, } from './dto/list-train-schedules-query.dto'; import { PinWagonsDto } from './dto/pin-wagons.dto'; -import { MoveContainerItemDto } from './dto/move-container-item.dto'; +import { MoveWagonLoadDto } from './dto/move-wagon-load.dto'; import { UpdateContainerItemDto } from './dto/update-container-item.dto'; import { UpdateImportLoadingStatusDto } from './dto/update-import-loading-status.dto'; import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.dto'; @@ -7336,226 +7336,165 @@ export class TrainSchedulingService { } /** - * Staff rearrange: move one container to another wagon of the same train, or - * swap two containers (cross-wagon, or same-wagon to exchange slot positions). - * Capacity is re-validated here — one wagon holds 2 TEU (one 40ft or two - * 20ft) and the wagon's rated payload is never exceeded — so a drag on the - * consist can't silently overload a wagon. Allocation rows follow the items: - * the booking gets an allocation on the target wagon (created if missing), - * weights shift with the container, and an allocation left with no items is - * deleted. + * Staff rearrange: relocate a wagon's ENTIRE load (all its allocations — + * a 40ft, a 20ft pair, or a bulk load) to another wagon of the same train. + * Whole-load moves keep every packing rule intact by construction (a valid + * load stays valid on any wagon whose type supports it), which is what lets + * a 20ft pair travel together and swap places with a 40ft, and lets bulk + * swap with containers. + * + * Three shapes, picked from the target: + * - target is an empty consist-only wagon (coupled on the built train, no + * slot row): REPIN — the source slot simply points at that physical wagon + * (type/capacity/length follow), and the wagon it left shows as empty. + * - target is an empty slot: allocations repoint to it and the load-coupled + * slot fields (assigned weight, status, board/alight leg) move across. + * - target is a loaded slot: the two loads swap wagons the same way. + * + * Validated per direction: the receiving wagon's type must support the + * incoming load type, and the incoming cargo must fit its rated payload. */ - async moveContainerItem( + async moveWagonLoad( scheduleId: string, - itemId: string, - dto: MoveContainerItemDto, + sourceWagonId: string, + dto: MoveWagonLoadDto, ): Promise { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!schedule) { throw new NotFoundException(`Train schedule ${scheduleId} not found`); } if (['DISPATCHED', 'ARRIVED'].includes(schedule.status)) { - throw new BadRequestException('Cannot rearrange containers on a dispatched train'); + throw new BadRequestException('Cannot rearrange loads on a dispatched train'); } - - const wagonById = new Map((schedule.trainSet?.wagons ?? []).map((w) => [w.id, w])); - const itemRepo = this.dataSource.getRepository(WagonAllocationContainerItem); - const allocRepo = this.dataSource.getRepository(WagonBookingAllocation); - - const item = await itemRepo.findOne({ - where: { id: itemId }, - relations: { allocation: true, containerType: true }, - }); - const sourceWagon = item?.allocation - ? wagonById.get(item.allocation.trainSetWagonId) - : undefined; - if (!item?.allocation || !sourceWagon) { - throw new NotFoundException(`Container item ${itemId} not found on this schedule`); - } - const targetWagon = wagonById.get(dto.targetTrainSetWagonId); - if (!targetWagon) { - throw new NotFoundException('Target wagon is not part of this schedule'); - } - - const loadAllocations = (trainSetWagonId: string) => - allocRepo.find({ - where: { trainSetWagonId }, - relations: { containerItems: { containerType: true } }, - }); - const [sourceAllocs, targetAllocs] = await Promise.all([ - loadAllocations(sourceWagon.id), - loadAllocations(targetWagon.id), - ]); - if ( - targetAllocs.some((a) => (a.loadType ?? '').toUpperCase().includes('BULK')) - ) { - throw new BadRequestException( - `Wagon #${targetWagon.sequenceNo} carries a bulk load — containers cannot ride it`, - ); - } - - const swapItem = dto.swapWithItemId - ? targetAllocs - .flatMap((a) => a.containerItems ?? []) - .find((it) => it.id === dto.swapWithItemId) - : undefined; - if (dto.swapWithItemId && !swapItem) { - throw new BadRequestException('The container to swap with is not on the target wagon'); - } - if (swapItem?.id === item.id) { - throw new BadRequestException('Cannot swap a container with itself'); - } - if (sourceWagon.id === targetWagon.id && !swapItem) { + if (sourceWagonId === dto.targetWagonId) { return this.getTrainScheduleById(scheduleId); } - // TEU per container: 40ft fills a wagon (2), 20ft takes half (1). One - // wagon never exceeds 2 TEU — the same rule the auto-allocation packs by. - const MAX_TEU_PER_WAGON = 2; - const teuOf = (it: { containerType?: { sizeFt?: number | null } | null }) => - (it.containerType?.sizeFt ?? 20) >= 40 ? 2 : 1; - const itemsOf = (allocs: WagonBookingAllocation[]) => - allocs.flatMap((a) => a.containerItems ?? []); - // Weight a container carries into the move: its own gross when recorded, - // otherwise an even share of its allocation's weight. - const weightOf = ( - it: WagonAllocationContainerItem, - alloc: WagonBookingAllocation, - siblings: number, - ) => - Number(it.grossWeightTons) || - Number(alloc.allocatedWeightTons) / Math.max(1, siblings); - - const sourceAlloc = sourceAllocs.find((a) => a.id === item.wagonBookingAllocationId); - if (!sourceAlloc) { - throw new NotFoundException(`Container item ${itemId} not found on this schedule`); + const slots = schedule.trainSet?.wagons ?? []; + const source = slots.find((w) => w.id === sourceWagonId); + if (!source) { + throw new NotFoundException('Source wagon is not part of this schedule'); } - const itemWeight = weightOf(item, sourceAlloc, (sourceAlloc.containerItems ?? []).length); - const swapAlloc = swapItem - ? targetAllocs.find((a) => a.id === swapItem.wagonBookingAllocationId) - : undefined; - const swapWeight = - swapItem && swapAlloc - ? weightOf(swapItem, swapAlloc, (swapAlloc.containerItems ?? []).length) - : 0; - if (sourceWagon.id !== targetWagon.id) { - const targetTeu = itemsOf(targetAllocs) - .filter((it) => it.id !== swapItem?.id) - .reduce((sum, it) => sum + teuOf(it), 0); - if (targetTeu + teuOf(item) > MAX_TEU_PER_WAGON) { + const allocRepo = this.dataSource.getRepository(WagonBookingAllocation); + const loadAllocations = (trainSetWagonId: string) => + allocRepo.find({ where: { trainSetWagonId } }); + const sourceAllocs = await loadAllocations(source.id); + if (!sourceAllocs.length) { + throw new BadRequestException('Source wagon has no load to move'); + } + + // Target: a slot of this train set, or an empty consist-only wagon of the + // built train (physical wagon with no slot row yet). + const targetSlot = slots.find((w) => w.id === dto.targetWagonId) ?? null; + const consistWagon = targetSlot + ? null + : schedule.trainSet?.trainId + ? await this.dataSource.getRepository(Wagon).findOne({ + where: { id: dto.targetWagonId, trainId: schedule.trainSet.trainId }, + relations: { wagonType: true }, + }) + : null; + if (!targetSlot && !consistWagon) { + throw new NotFoundException('Target wagon is not part of this schedule'); + } + const targetAllocs = targetSlot ? await loadAllocations(targetSlot.id) : []; + + const loadTypesOf = (allocs: WagonBookingAllocation[]) => [ + ...new Set(allocs.map((a) => (a.loadType ?? 'CONTAINER').toUpperCase())), + ]; + const cargoOf = (allocs: WagonBookingAllocation[]) => + allocs.reduce((sum, a) => sum + Number(a.allocatedWeightTons || 0), 0); + const wagonLabel = (slot: { sequenceNo: number } | null, wagon: Wagon | null) => + slot ? `#${slot.sequenceNo}` : (wagon?.wagonNumber ?? 'the target wagon'); + const checkReceives = ( + allocs: WagonBookingAllocation[], + label: string, + wagonType: { code?: string; supportedLoadTypes?: string[]; supportsContainer?: boolean } | null | undefined, + capacityTons: number, + ) => { + const incoming = loadTypesOf(allocs); + // Unknown type or no declared support list → staff decides; don't block. + if (wagonType) { + const supported = (wagonType.supportedLoadTypes ?? []).map((t) => t.toUpperCase()); + for (const loadType of incoming) { + const ok = + supported.includes(loadType) || + (loadType === 'CONTAINER' && wagonType.supportsContainer) || + supported.length === 0; + if (!ok) { + throw new BadRequestException( + `Wagon ${label} (${wagonType.code ?? 'unknown type'}) cannot carry a ${loadType.toLowerCase()} load`, + ); + } + } + } + const cargo = cargoOf(allocs); + if (capacityTons > 0 && cargo > capacityTons + 0.001) { throw new BadRequestException( - `Wagon #${targetWagon.sequenceNo} has no room — a wagon holds one 40ft or two 20ft containers`, + `Wagon ${label} would carry ${roundTons(cargo)}T — over its ${roundTons(capacityTons)}T payload`, ); } - if (swapItem) { - const sourceTeu = itemsOf(sourceAllocs) - .filter((it) => it.id !== item.id) - .reduce((sum, it) => sum + teuOf(it), 0); - if (sourceTeu + teuOf(swapItem) > MAX_TEU_PER_WAGON) { - throw new BadRequestException( - `Wagon #${sourceWagon.sequenceNo} has no room for the swapped container — a wagon holds one 40ft or two 20ft containers`, - ); - } - } + }; - const cargoOn = (allocs: WagonBookingAllocation[]) => - allocs.reduce((sum, a) => sum + Number(a.allocatedWeightTons || 0), 0); - const checkPayload = ( - wagon: { sequenceNo: number; capacityTons?: number | null }, - cargoAfter: number, - ) => { - const capacity = Number(wagon.capacityTons ?? 0); - if (capacity > 0 && cargoAfter > capacity + 0.001) { - throw new BadRequestException( - `Wagon #${wagon.sequenceNo} would carry ${roundTons(cargoAfter)}T — over its ${capacity}T payload`, - ); - } - }; - checkPayload(targetWagon, cargoOn(targetAllocs) - swapWeight + itemWeight); - if (swapItem) { - checkPayload(sourceWagon, cargoOn(sourceAllocs) - itemWeight + swapWeight); - } + // What the target must be able to receive… + checkReceives( + sourceAllocs, + wagonLabel(targetSlot, consistWagon), + targetSlot ? targetSlot.wagonType : consistWagon?.wagonType, + Number(targetSlot ? targetSlot.capacityTons : (consistWagon?.wagonType?.capacityTons ?? 0)), + ); + // …and, on a swap, what comes back to the source. + if (targetAllocs.length) { + checkReceives( + targetAllocs, + `#${source.sequenceNo}`, + source.wagonType, + Number(source.capacityTons), + ); } await this.dataSource.transaction(async (manager) => { - const items = manager.getRepository(WagonAllocationContainerItem); + const slotRepo = manager.getRepository(TrainSetWagon); const allocs = manager.getRepository(WagonBookingAllocation); - // Same-wagon swap: the containers only trade slot positions. - if (sourceWagon.id === targetWagon.id && swapItem) { - const a = item.positionOnWagon ?? null; - const b = swapItem.positionOnWagon ?? null; - await items.update(item.id, { positionOnWagon: b }); - await items.update(swapItem.id, { positionOnWagon: a }); + // Empty consist wagon: repin the loaded slot onto that physical wagon. + // Allocations and load fields stay put; only the wagon identity changes. + if (consistWagon) { + await slotRepo.update(source.id, { + physicalWagonId: consistWagon.id, + wagonTypeId: consistWagon.wagonTypeId, + capacityTons: roundTons(Number(consistWagon.wagonType?.capacityTons ?? source.capacityTons)), + lengthMeters: roundTons(Number(consistWagon.wagonType?.lengthMeters ?? source.lengthMeters)), + }); return; } - const moveOne = async ( - moving: WagonAllocationContainerItem, - toWagonId: string, - weight: number, - ) => { - // Re-read the source allocation — the other leg of a swap may have - // already shifted weight on it within this transaction. - const from = await allocs.findOne({ - where: { id: moving.wagonBookingAllocationId }, - }); - if (!from) return; - let to = await allocs.findOne({ - where: { trainSetWagonId: toWagonId, bookingId: from.bookingId }, - }); - if (!to) { - to = await allocs.save( - allocs.create({ - trainSetWagonId: toWagonId, - bookingId: from.bookingId, - allocatedWeightTons: 0, - loadType: from.loadType ?? 'CONTAINER', - status: from.status ?? 'PLANNED', - }), - ); - } - await items.update(moving.id, { wagonBookingAllocationId: to.id }); - await allocs.update(to.id, { - allocatedWeightTons: roundTons(Number(to.allocatedWeightTons) + weight), - }); - const remaining = await items.count({ - where: { wagonBookingAllocationId: from.id }, - }); - if (remaining === 0) { - await allocs.delete(from.id); - } else { - await allocs.update(from.id, { - allocatedWeightTons: roundTons( - Math.max(0, Number(from.allocatedWeightTons) - weight), - ), - }); - } + const target = targetSlot as TrainSetWagon; + // Load-coupled slot fields travel with the load; wagon identity stays. + const loadFieldsOf = (slot: TrainSetWagon) => ({ + assignedWeightTons: slot.assignedWeightTons, + status: slot.status, + boardYardId: slot.boardYardId ?? null, + alightYardId: slot.alightYardId ?? null, + }); + const emptyLoadFields = { + assignedWeightTons: 0, + status: 'PLANNED', + boardYardId: null, + alightYardId: null, }; + const sourceLoadFields = loadFieldsOf(source); + const targetLoadFields = targetAllocs.length ? loadFieldsOf(target) : emptyLoadFields; - await moveOne(item, targetWagon.id, itemWeight); - if (swapItem) { - await moveOne(swapItem, sourceWagon.id, swapWeight); + for (const alloc of sourceAllocs) { + await allocs.update(alloc.id, { trainSetWagonId: target.id }); } - - // Keep slot positions dense (1..n) on both touched wagons. - const renumber = async (trainSetWagonId: string) => { - const wagonAllocs = await allocs.find({ - where: { trainSetWagonId }, - relations: { containerItems: true }, - }); - const wagonItems = wagonAllocs - .flatMap((a) => a.containerItems ?? []) - .sort((x, y) => (x.positionOnWagon ?? 99) - (y.positionOnWagon ?? 99)); - for (let i = 0; i < wagonItems.length; i += 1) { - if (wagonItems[i].positionOnWagon !== i + 1) { - await items.update(wagonItems[i].id, { positionOnWagon: i + 1 }); - } - } - }; - await renumber(sourceWagon.id); - await renumber(targetWagon.id); + for (const alloc of targetAllocs) { + await allocs.update(alloc.id, { trainSetWagonId: source.id }); + } + await slotRepo.update(target.id, sourceLoadFields); + await slotRepo.update(source.id, targetLoadFields); }); return this.getTrainScheduleById(scheduleId); diff --git a/apps/edr-freight-web/backoffice/src/auth/types.ts b/apps/edr-freight-web/backoffice/src/auth/types.ts index 2dac243b4..a361c2abc 100644 --- a/apps/edr-freight-web/backoffice/src/auth/types.ts +++ b/apps/edr-freight-web/backoffice/src/auth/types.ts @@ -23,6 +23,7 @@ interface AuthEmployeePosition { permissions?: AuthPermission[]; /** Some IAM payloads nest the position record instead of flattening its key. */ position?: { id?: string; key?: string; name?: LocaleText }; + positionType?: { id?: string; key?: string; name?: LocaleText } | null; } interface AuthEmployeeRecord { diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx index de74db9a6..1e1bb706e 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx @@ -15,14 +15,12 @@ import { freightBrand } from "@/theme/freight-brand"; type Wagon = NonNullable["wagons"][number]; type Locomotive = NonNullable["locomotive"]; -export interface ContainerMove { - itemId: string; +export interface WagonLoadMove { + sourceWagonId: string; targetWagonId: string; - /** Present when the drop landed on another container — swap the two. */ - swapWithItemId?: string; } -type DragState = { itemId: string; sourceWagonId: string } | null; +type DragState = { sourceWagonId: string } | null; interface InteractiveTrainConsistProps { wagons: Wagon[]; @@ -33,9 +31,9 @@ interface InteractiveTrainConsistProps { onSelectWagon: (wagon: Wagon) => void; /** Booking id to highlight across the train (e.g. selected in the side panel). */ highlightBookingId?: string | null; - /** Containers become draggable between wagons (drop on a container = swap). */ + /** Wagon loads become draggable: drop on an empty wagon to move, a loaded one to swap. */ canRearrange?: boolean; - onMoveContainer?: (move: ContainerMove) => void; + onMoveLoad?: (move: WagonLoadMove) => void; } const wagonItems = (wagon: Wagon) => @@ -171,7 +169,7 @@ function WagonCar({ onSelect, drag, onDragChange, - onMoveContainer, + onMoveLoad, canRearrange, }: { wagon: Wagon; @@ -181,7 +179,7 @@ function WagonCar({ onSelect: () => void; drag: DragState; onDragChange: (drag: DragState) => void; - onMoveContainer?: (move: ContainerMove) => void; + onMoveLoad?: (move: WagonLoadMove) => void; canRearrange: boolean; }) { const [dropHover, setDropHover] = useState(false); @@ -203,11 +201,12 @@ function WagonCar({ const blocks = items.slice(0, 2); const containerNumbers = items.map((c) => c.containerNumber?.trim() || "—"); - // Where a dragged container may land: another wagon, not bulk-loaded, with a - // free half (the API re-checks TEU/weight — this only paints the hint). - const dropEligible = Boolean( - drag && drag.sourceWagonId !== wagon.id && !isBulk && items.length < 2, - ); + // The whole load drags as one unit (a 20ft pair never splits). Any OTHER + // wagon is a drop target: empty → move (a consist-only wagon repins), loaded + // → the two loads swap. The API validates wagon type + payload weight. + const draggable = canRearrange && !isEmpty; + const beingDragged = drag?.sourceWagonId === wagon.id; + const dropEligible = Boolean(drag && !beingDragged); const endDrag = () => { onDragChange(null); setDropHover(false); @@ -238,7 +237,7 @@ function WagonCar({ onDrop={(e) => { if (dropEligible && drag) { e.preventDefault(); - onMoveContainer?.({ itemId: drag.itemId, targetWagonId: wagon.id }); + onMoveLoad?.({ sourceWagonId: drag.sourceWagonId, targetWagonId: wagon.id }); } endDrag(); }} @@ -302,8 +301,27 @@ function WagonCar({ )} - {/* body */} - + {/* body — the cargo area is the drag handle for the wagon's whole load */} + { + e.stopPropagation(); + e.dataTransfer.effectAllowed = "move"; + // Firefox needs data set for the drag to start. + e.dataTransfer.setData("text/plain", wagon.id); + onDragChange({ sourceWagonId: wagon.id }); + }} + onDragEnd={endDrag} + style={{ + flex: 1, + padding: "3px 7px", + display: "flex", + alignItems: "center", + cursor: draggable ? "grab" : undefined, + opacity: beingDragged ? 0.35 : 1, + transition: "opacity 120ms ease", + }} + > {isEmpty ? ( Available @@ -333,83 +351,29 @@ function WagonCar({ ) : ( - {blocks.length ? ( - blocks.map((item, i) => { - const isDragged = drag?.itemId === item.id; - const swapEligible = Boolean(drag && drag.itemId !== item.id); - return ( - { - e.stopPropagation(); - e.dataTransfer.effectAllowed = "move"; - // Firefox needs data set for the drag to start. - e.dataTransfer.setData("text/plain", item.id); - onDragChange({ itemId: item.id, sourceWagonId: wagon.id }); - }} - onDragEnd={endDrag} - onDragOver={(e) => { - if (swapEligible) { - e.preventDefault(); - e.stopPropagation(); - e.dataTransfer.dropEffect = "move"; - } - }} - onDrop={(e) => { - if (swapEligible && drag) { - e.preventDefault(); - e.stopPropagation(); - onMoveContainer?.({ - itemId: drag.itemId, - targetWagonId: wagon.id, - swapWithItemId: item.id, - }); - } - endDrag(); - }} - style={{ - flex: 1, - minWidth: 0, - height: 26, - borderRadius: 4, - background: CONTAINER_GRADIENTS[i % CONTAINER_GRADIENTS.length], - border: `1px solid ${CONTAINER_BORDERS[i % CONTAINER_BORDERS.length]}`, - boxShadow: "inset 0 1px 0 rgba(255,255,255,0.3)", - display: "flex", - alignItems: "center", - justifyContent: "center", - padding: "0 2px", - cursor: canRearrange ? "grab" : undefined, - opacity: isDragged ? 0.35 : 1, - transition: "opacity 120ms ease", - }} - > - - {item.containerNumber?.trim() || "—"} - - - ); - }) - ) : ( - - - — - - + {(blocks.length ? blocks.map((c) => c.containerNumber?.trim() || "—") : ["—"]).map( + (cn, i) => ( + + + {cn} + + + ), )} )} @@ -563,7 +527,7 @@ export const InteractiveTrainConsist = ({ onSelectWagon, highlightBookingId, canRearrange = false, - onMoveContainer, + onMoveLoad, }: InteractiveTrainConsistProps) => { const [drag, setDrag] = useState(null); return ( @@ -597,7 +561,7 @@ export const InteractiveTrainConsist = ({ onSelect={() => onSelectWagon(wagon)} drag={drag} onDragChange={setDrag} - onMoveContainer={onMoveContainer} + onMoveLoad={onMoveLoad} canRearrange={canRearrange} /> diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainConsistView.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainConsistView.tsx index 80120d075..5dd55680b 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainConsistView.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainConsistView.tsx @@ -5,7 +5,7 @@ import { Hand, MousePointerClick, TrainFront } from "lucide-react"; import type { TrainScheduleDetail } from "@/types/trainScheduling"; import { TrainStatsBar } from "./TrainStatsBar"; import { WagonCard } from "./WagonCard"; -import { InteractiveTrainConsist, type ContainerMove } from "./InteractiveTrainConsist"; +import { InteractiveTrainConsist, type WagonLoadMove } from "./InteractiveTrainConsist"; import { RemoveBookingModal } from "./RemoveBookingModal"; import { useMutation } from "@tanstack/react-query"; import { api } from "@/services/api"; @@ -57,33 +57,34 @@ export const TrainConsistView = ({ const removeWagonMutation = useMutation( api.trainScheduling.removeWagonSlot.mutationOptions(), ); - const moveContainerMutation = useMutation( - api.trainScheduling.moveContainerItem.mutationOptions(), + const moveLoadMutation = useMutation( + api.trainScheduling.moveWagonLoad.mutationOptions(), ); const trainSet = scheduleDetail.trainSet; const wagons = trainSet?.wagons ?? []; const canRearrange = !["DISPATCHED", "ARRIVED"].includes(scheduleDetail.status); - const handleMoveContainer = async (move: ContainerMove) => { - if (moveContainerMutation.isPending) return; + const handleMoveLoad = async (move: WagonLoadMove) => { + if (moveLoadMutation.isPending) return; + const targetLoaded = + (wagons.find((w) => w.id === move.targetWagonId)?.allocations?.length ?? 0) > 0; try { - await moveContainerMutation.mutateAsync({ + await moveLoadMutation.mutateAsync({ scheduleId, - itemId: move.itemId, - targetTrainSetWagonId: move.targetWagonId, - swapWithItemId: move.swapWithItemId, + wagonId: move.sourceWagonId, + targetWagonId: move.targetWagonId, }); - toast({ title: move.swapWithItemId ? "Containers swapped" : "Container moved" }); + toast({ title: targetLoaded ? "Wagon loads swapped" : "Load moved" }); } catch (error) { const message = isAxiosError(error) ? ((error.response?.data as { message?: string | string[] } | undefined)?.message ?? null) : null; toast({ - title: "Could not move container", + title: "Could not move the load", description: Array.isArray(message) ? message.join(", ") - : (message ?? "The move was rejected — check the wagon's space and load."), + : (message ?? "The move was rejected — check the wagon's type and payload."), variant: "destructive", }); } @@ -187,7 +188,7 @@ export const TrainConsistView = ({ - Drag a container to move it — drop on a container to swap + Drag a wagon's cargo onto an empty wagon to move it — onto a loaded one to swap ) : null} @@ -197,7 +198,7 @@ export const TrainConsistView = ({ - + setSelectedWagonId((prev) => (prev === w.id ? null : w.id))} highlightBookingId={highlightBookingId} - canRearrange={canRearrange && !moveContainerMutation.isPending} - onMoveContainer={(move) => void handleMoveContainer(move)} + canRearrange={canRearrange && !moveLoadMutation.isPending} + onMoveLoad={(move) => void handleMoveLoad(move)} /> @@ -234,7 +235,7 @@ export const TrainConsistView = ({ onRemoveBooking={handleRemoveBooking} onRemoveWagon={handleRemoveWagon} wagons={wagons} - onMoveContainer={canRearrange ? (move) => void handleMoveContainer(move) : undefined} + onMoveLoad={canRearrange ? (move) => void handleMoveLoad(move) : undefined} /> ) : wagons.length ? ( diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/WagonCard.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/WagonCard.tsx index 541e01a09..d1d1e8d0b 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/WagonCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/WagonCard.tsx @@ -1,5 +1,4 @@ import { - ActionIcon, Badge, Box, Button, @@ -10,7 +9,6 @@ import { Stack, Text, ThemeIcon, - Tooltip, } from "@mantine/core"; import { ArrowLeftRight, @@ -24,7 +22,7 @@ import { } from "lucide-react"; import type { TrainScheduleDetail } from "@/types/trainScheduling"; import { ContainerNumberInput } from "./ContainerNumberInput"; -import type { ContainerMove } from "./InteractiveTrainConsist"; +import type { WagonLoadMove } from "./InteractiveTrainConsist"; import { freightBrand } from "@/theme/freight-brand"; type Wagon = NonNullable["wagons"][number]; @@ -36,13 +34,12 @@ interface WagonCardProps { scheduleStatus?: string; onRemoveBooking: (wagon: Wagon) => void; onRemoveWagon: (wagonId: string) => void; - /** All wagons of the consist — targets for the per-container move menu. */ + /** All wagons of the consist — targets for the move-load menu. */ wagons?: Wagon[]; - onMoveContainer?: (move: ContainerMove) => void; + onMoveLoad?: (move: WagonLoadMove) => void; } -const itemCountOf = (w: Wagon) => - (w.allocations ?? []).reduce((sum, a) => sum + (a.containerItems?.length ?? 0), 0); +const itemAllocCount = (w: Wagon) => w.allocations?.length ?? 0; const isBulkWagon = (w: Wagon) => (w.allocations ?? []).some((a) => (a.loadType ?? "").toUpperCase().includes("BULK")); @@ -55,7 +52,7 @@ export const WagonCard = ({ onRemoveBooking, onRemoveWagon, wagons, - onMoveContainer, + onMoveLoad, }: WagonCardProps) => { const isDispatched = scheduleStatus === "DISPATCHED"; const allocation = wagon.allocations?.[0]; @@ -134,67 +131,20 @@ export const WagonCard = ({ Containers - {allocation.containerItems.map((item, idx) => { - const targets = (wagons ?? []).filter( - (w) => w.id !== wagon.id && !isBulkWagon(w) && itemCountOf(w) < 2, - ); - return ( - - - - #{idx + 1} - - - {!isDispatched && onMoveContainer ? ( - - - - - - - - - - Move to wagon - {targets.length ? ( - targets.map((w) => { - const count = itemCountOf(w); - return ( - - onMoveContainer({ - itemId: item.id, - targetWagonId: w.id, - }) - } - > - - - #{w.sequenceNo} ·{" "} - {w.physicalWagonNumber ?? w.wagonType?.code ?? "Wagon"} - - - {count ? `${count}/2` : "empty"} - - - - ); - }) - ) : ( - No wagon has free space - )} - - - ) : null} - - ); - })} + {allocation.containerItems.map((item, idx) => ( + + + + #{idx + 1} + + + + ))} ) : null} @@ -226,16 +176,62 @@ export const WagonCard = ({ {!isDispatched ? ( - + + {onMoveLoad ? ( + + + + + + Move this wagon's load to + {(wagons ?? []) + .filter((w) => w.id !== wagon.id) + .sort((a, b) => itemAllocCount(a) - itemAllocCount(b)) + .map((w) => { + const loaded = itemAllocCount(w) > 0; + return ( + + onMoveLoad({ sourceWagonId: wagon.id, targetWagonId: w.id }) + } + > + + + #{w.sequenceNo} ·{" "} + {w.physicalWagonNumber ?? w.wagonType?.code ?? "Wagon"} + + + {loaded ? "swap" : "empty"} + + + + ); + })} + + + ) : null} + + ) : null} ) : ( diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 6a1ce5c9e..ade491a7b 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -399,8 +399,8 @@ export const URL_CONSTANTS = { `/train-scheduling/schedules/${scheduleId}/wagons/${wagonId}`, UPDATE_CONTAINER_ITEM: (scheduleId: string, itemId: string) => `/train-scheduling/schedules/${scheduleId}/container-items/${itemId}`, - MOVE_CONTAINER_ITEM: (scheduleId: string, itemId: string) => - `/train-scheduling/schedules/${scheduleId}/container-items/${itemId}/move`, + MOVE_WAGON_LOAD: (scheduleId: string, wagonId: string) => + `/train-scheduling/schedules/${scheduleId}/wagons/${wagonId}/move-load`, UNASSIGNED_BOOKINGS: (scheduleId: string) => `/train-scheduling/schedules/${scheduleId}/unassigned-bookings`, COMPOSITION_REMOVALS: (scheduleId: string) => diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index 98f998bc0..b11d7bffa 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -353,12 +353,38 @@ export const POSITION_KEYS = { djiboutiGl: "djibouti_gl", } as const; +/** Position-type keys held by the user (e.g. "djibouti-gl-officer"). */ +export function getPositionTypeKeys( + user: AuthUser | null | undefined, +): string[] { + if (!user) return []; + const keys = new Set(); + for (const emp of user.employee ?? []) { + for (const pos of emp.positions ?? []) { + if (pos.positionType?.key) keys.add(pos.positionType.key); + } + } + return [...keys]; +} + +// GL staff are identified by the root position key (department heads) OR by +// their position-type key (sub-positions: director/chief/officer) — both +// forms get the clearance-only locked view. +const ET_GL_TYPE_PREFIX = "commercial-global-logistics-(et)"; +const DJ_GL_TYPE_PREFIX = "djibouti-gl"; + export function isEthiopianGl(user: AuthUser | null | undefined): boolean { - return hasPosition(user, POSITION_KEYS.ethiopianGl); + return ( + hasPosition(user, POSITION_KEYS.ethiopianGl) || + getPositionTypeKeys(user).some((k) => k.startsWith(ET_GL_TYPE_PREFIX)) + ); } export function isDjiboutiGl(user: AuthUser | null | undefined): boolean { - return hasPosition(user, POSITION_KEYS.djiboutiGl); + return ( + hasPosition(user, POSITION_KEYS.djiboutiGl) || + getPositionTypeKeys(user).some((k) => k.startsWith(DJ_GL_TYPE_PREFIX)) + ); } export function isSuperAdmin(user: AuthUser | null | undefined): boolean { diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index eb1ccc91c..71005f6a1 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -815,21 +815,15 @@ export const api = { () => [QUERY_KEYS.TRAIN_SCHEDULING.ROOT], ), - moveContainerItem: endpoint< - { - scheduleId: string; - itemId: string; - targetTrainSetWagonId: string; - swapWithItemId?: string; - }, + moveWagonLoad: endpoint< + { scheduleId: string; wagonId: string; targetWagonId: string }, TrainScheduleDetail >( "train-scheduling", - "move-container-item", - ({ scheduleId, itemId, targetTrainSetWagonId, swapWithItemId }) => - trainSchedulingService.moveContainerItem(scheduleId, itemId, { - targetTrainSetWagonId, - swapWithItemId, + "move-wagon-load", + ({ scheduleId, wagonId, targetWagonId }) => + trainSchedulingService.moveWagonLoad(scheduleId, wagonId, { + targetWagonId, }), undefined, () => [QUERY_KEYS.TRAIN_SCHEDULING.ROOT], diff --git a/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts b/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts index a21a6d9c9..5f9071eb8 100644 --- a/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts @@ -757,13 +757,13 @@ export const trainSchedulingService = { return unwrap(response.data); }, - moveContainerItem: async ( + moveWagonLoad: async ( scheduleId: string, - itemId: string, - payload: { targetTrainSetWagonId: string; swapWithItemId?: string }, + wagonId: string, + payload: { targetWagonId: string }, ): Promise => { const response = await client.post( - URL_CONSTANTS.TRAIN_SCHEDULING.MOVE_CONTAINER_ITEM(scheduleId, itemId), + URL_CONSTANTS.TRAIN_SCHEDULING.MOVE_WAGON_LOAD(scheduleId, wagonId), payload, ); return unwrap(response.data);