mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 08:48:11 +00:00
Merge pull request #892 from Tria-plc/freight_feature/usermanagement
refactor(train-scheduling): rename and restructure container movement…
This commit is contained in:
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -28,7 +28,7 @@ import { GetEligibleBookingsDto } from "./dto/get-eligible-bookings.dto";
|
|||||||
import { GetEligibleBulkBookingsDto } from "./dto/get-eligible-bulk-bookings.dto";
|
import { GetEligibleBulkBookingsDto } from "./dto/get-eligible-bulk-bookings.dto";
|
||||||
import { GetEligibleContainerBookingsDto } from "./dto/get-eligible-container-bookings.dto";
|
import { GetEligibleContainerBookingsDto } from "./dto/get-eligible-container-bookings.dto";
|
||||||
import { PinWagonsDto } from "./dto/pin-wagons.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 { UpdateContainerItemDto } from "./dto/update-container-item.dto";
|
||||||
import { UpdateImportLoadingStatusDto } from "./dto/update-import-loading-status.dto";
|
import { UpdateImportLoadingStatusDto } from "./dto/update-import-loading-status.dto";
|
||||||
import { PreviewBulkTrainScheduleDto } from "./dto/preview-bulk-train-schedule.dto";
|
import { PreviewBulkTrainScheduleDto } from "./dto/preview-bulk-train-schedule.dto";
|
||||||
@@ -375,17 +375,18 @@ export class TrainSchedulingController {
|
|||||||
return this.trainSchedulingService.updateContainerItem(id, itemId, dto);
|
return this.trainSchedulingService.updateContainerItem(id, itemId, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post("schedules/:id/container-items/:itemId/move")
|
@Post("schedules/:id/wagons/:wagonId/move-load")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingManage()
|
||||||
@ApiOperation({
|
@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("id", ParseUUIDPipe) id: string,
|
||||||
@Param("itemId", ParseUUIDPipe) itemId: string,
|
@Param("wagonId", ParseUUIDPipe) wagonId: string,
|
||||||
@Body() dto: MoveContainerItemDto,
|
@Body() dto: MoveWagonLoadDto,
|
||||||
) {
|
) {
|
||||||
return this.trainSchedulingService.moveContainerItem(id, itemId, dto);
|
return this.trainSchedulingService.moveWagonLoad(id, wagonId, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("schedules/:id/unassigned-bookings")
|
@Get("schedules/:id/unassigned-bookings")
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { Wagon } from '../wagons/entities/wagon.entity';
|
|||||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||||
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
||||||
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.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 { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
|
||||||
import { TrainSchedulingService } from './train-scheduling.service';
|
import { TrainSchedulingService } from './train-scheduling.service';
|
||||||
|
|
||||||
@@ -1083,72 +1082,75 @@ describe('TrainSchedulingService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('moveContainerItem — staff rearrange', () => {
|
describe('moveWagonLoad — staff rearrange', () => {
|
||||||
const wagon1 = { id: 'w1', sequenceNo: 1, capacityTons: 61 };
|
const containerType = {
|
||||||
const wagon2 = { id: 'w2', sequenceNo: 2, capacityTons: 61 };
|
code: 'NX70',
|
||||||
const schedule = {
|
supportedLoadTypes: ['CONTAINER'],
|
||||||
|
supportsContainer: true,
|
||||||
|
};
|
||||||
|
let slotA: Record<string, unknown>;
|
||||||
|
let slotB: Record<string, unknown>;
|
||||||
|
let allocsByWagon: Record<string, Array<Record<string, unknown>>>;
|
||||||
|
let allocRepo: { find: jest.Mock; update: jest.Mock };
|
||||||
|
let slotRepo: { update: jest.Mock };
|
||||||
|
let wagonRepo: { findOne: jest.Mock };
|
||||||
|
|
||||||
|
const makeSchedule = (over: Record<string, unknown> = {}) => ({
|
||||||
id: 'sched-1',
|
id: 'sched-1',
|
||||||
status: 'SCHEDULED',
|
status: 'SCHEDULED',
|
||||||
trainSet: { wagons: [wagon1, wagon2] },
|
trainSetId: 'ts-1',
|
||||||
};
|
trainSet: { trainId: 'train-1', wagons: [slotA, slotB] },
|
||||||
|
...over,
|
||||||
let sourceAlloc: Record<string, unknown>;
|
});
|
||||||
let item: Record<string, unknown>;
|
|
||||||
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<Record<string, unknown>>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
sourceAlloc = {
|
slotA = {
|
||||||
id: 'alloc-1',
|
id: 'wA',
|
||||||
trainSetWagonId: 'w1',
|
sequenceNo: 1,
|
||||||
bookingId: 'b1',
|
capacityTons: 61,
|
||||||
allocatedWeightTons: 20,
|
lengthMeters: 14,
|
||||||
loadType: 'CONTAINER',
|
assignedWeightTons: 40,
|
||||||
status: 'PLANNED',
|
status: 'RESERVED',
|
||||||
containerItems: [],
|
boardYardId: 'yard-1',
|
||||||
|
alightYardId: null,
|
||||||
|
wagonType: containerType,
|
||||||
};
|
};
|
||||||
item = {
|
slotB = {
|
||||||
id: 'item-1',
|
id: 'wB',
|
||||||
wagonBookingAllocationId: 'alloc-1',
|
sequenceNo: 2,
|
||||||
positionOnWagon: 1,
|
capacityTons: 61,
|
||||||
grossWeightTons: 20,
|
lengthMeters: 14,
|
||||||
containerType: { sizeFt: 20 },
|
assignedWeightTons: 25,
|
||||||
allocation: sourceAlloc,
|
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);
|
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(makeSchedule());
|
||||||
itemRepo = {
|
|
||||||
findOne: jest.fn().mockResolvedValue(item),
|
|
||||||
update: jest.fn().mockResolvedValue(undefined),
|
|
||||||
count: jest.fn().mockResolvedValue(0),
|
|
||||||
};
|
|
||||||
allocRepo = {
|
allocRepo = {
|
||||||
find: jest.fn().mockImplementation(({ where }: { where: { trainSetWagonId: string } }) =>
|
find: jest.fn().mockImplementation(({ where }: { where: { trainSetWagonId: string } }) =>
|
||||||
Promise.resolve(where.trainSetWagonId === 'w1' ? [sourceAlloc] : wagon2Allocs),
|
Promise.resolve(allocsByWagon[where.trainSetWagonId] ?? []),
|
||||||
),
|
|
||||||
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<string, unknown>) =>
|
|
||||||
Promise.resolve({ ...v, id: 'alloc-new' }),
|
|
||||||
),
|
),
|
||||||
update: jest.fn().mockResolvedValue(undefined),
|
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) => {
|
dataSource.getRepository.mockImplementation((entity: unknown) => {
|
||||||
if (entity === WagonAllocationContainerItem) return itemRepo;
|
|
||||||
if (entity === WagonBookingAllocation) return allocRepo;
|
if (entity === WagonBookingAllocation) return allocRepo;
|
||||||
|
if (entity === TrainSetWagon) return slotRepo;
|
||||||
|
if (entity === Wagon) return wagonRepo;
|
||||||
return { find: jest.fn().mockResolvedValue([]) };
|
return { find: jest.fn().mockResolvedValue([]) };
|
||||||
});
|
});
|
||||||
dataSource.transaction.mockImplementation(
|
dataSource.transaction.mockImplementation(
|
||||||
@@ -1164,61 +1166,87 @@ describe('TrainSchedulingService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('rejects moves on a dispatched train', async () => {
|
it('rejects moves on a dispatched train', async () => {
|
||||||
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({
|
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(
|
||||||
...schedule,
|
makeSchedule({ status: 'DISPATCHED' }),
|
||||||
status: 'DISPATCHED',
|
);
|
||||||
});
|
|
||||||
await expect(
|
await expect(
|
||||||
service.moveContainerItem('sched-1', 'item-1', { targetTrainSetWagonId: 'w2' }),
|
service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'wB' }),
|
||||||
).rejects.toThrow(BadRequestException);
|
).rejects.toThrow(BadRequestException);
|
||||||
expect(dataSource.transaction).not.toHaveBeenCalled();
|
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a target wagon that has no TEU room left', async () => {
|
it('404s when the target is neither a slot nor a consist wagon of this train', async () => {
|
||||||
wagon2Allocs = [
|
|
||||||
{
|
|
||||||
id: 'alloc-2',
|
|
||||||
trainSetWagonId: 'w2',
|
|
||||||
bookingId: 'b2',
|
|
||||||
allocatedWeightTons: 25,
|
|
||||||
loadType: 'CONTAINER',
|
|
||||||
containerItems: [{ id: 'item-40', containerType: { sizeFt: 40 } }],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
await expect(
|
await expect(
|
||||||
service.moveContainerItem('sched-1', 'item-1', { targetTrainSetWagonId: 'w2' }),
|
service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'nope' }),
|
||||||
).rejects.toThrow(/no room/);
|
).rejects.toThrow(/not part of this schedule/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a bulk-loaded target wagon', async () => {
|
it('swaps two loaded wagons: every allocation crosses over, load fields swap', async () => {
|
||||||
wagon2Allocs = [
|
await service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'wB' });
|
||||||
{
|
|
||||||
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('moves a container to an empty wagon and re-homes its allocation', async () => {
|
// The 20ft pair moved together onto wagon B…
|
||||||
await service.moveContainerItem('sched-1', 'item-1', { targetTrainSetWagonId: 'w2' });
|
expect(allocRepo.update).toHaveBeenCalledWith('alloc-a1', { trainSetWagonId: 'wB' });
|
||||||
|
expect(allocRepo.update).toHaveBeenCalledWith('alloc-a2', { trainSetWagonId: 'wB' });
|
||||||
// A new allocation for the booking was created on the target wagon…
|
// …and the 40ft came back to wagon A.
|
||||||
expect(allocRepo.save).toHaveBeenCalledWith(
|
expect(allocRepo.update).toHaveBeenCalledWith('alloc-b1', { trainSetWagonId: 'wA' });
|
||||||
expect.objectContaining({ trainSetWagonId: 'w2', bookingId: 'b1' }),
|
// Load-coupled slot fields follow their loads.
|
||||||
);
|
expect(slotRepo.update).toHaveBeenCalledWith('wB', {
|
||||||
// …the container item now hangs off it…
|
assignedWeightTons: 40,
|
||||||
expect(itemRepo.update).toHaveBeenCalledWith('item-1', {
|
status: 'RESERVED',
|
||||||
wagonBookingAllocationId: 'alloc-new',
|
boardYardId: 'yard-1',
|
||||||
|
alightYardId: null,
|
||||||
});
|
});
|
||||||
// …and the emptied source allocation was deleted, not left at 0 items.
|
expect(slotRepo.update).toHaveBeenCalledWith('wA', {
|
||||||
expect(allocRepo.delete).toHaveBeenCalledWith('alloc-1');
|
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/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ import {
|
|||||||
TrainScheduleFreightType,
|
TrainScheduleFreightType,
|
||||||
} from './dto/list-train-schedules-query.dto';
|
} from './dto/list-train-schedules-query.dto';
|
||||||
import { PinWagonsDto } from './dto/pin-wagons.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 { UpdateContainerItemDto } from './dto/update-container-item.dto';
|
||||||
import { UpdateImportLoadingStatusDto } from './dto/update-import-loading-status.dto';
|
import { UpdateImportLoadingStatusDto } from './dto/update-import-loading-status.dto';
|
||||||
import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.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
|
* Staff rearrange: relocate a wagon's ENTIRE load (all its allocations —
|
||||||
* swap two containers (cross-wagon, or same-wagon to exchange slot positions).
|
* a 40ft, a 20ft pair, or a bulk load) to another wagon of the same train.
|
||||||
* Capacity is re-validated here — one wagon holds 2 TEU (one 40ft or two
|
* Whole-load moves keep every packing rule intact by construction (a valid
|
||||||
* 20ft) and the wagon's rated payload is never exceeded — so a drag on the
|
* load stays valid on any wagon whose type supports it), which is what lets
|
||||||
* consist can't silently overload a wagon. Allocation rows follow the items:
|
* a 20ft pair travel together and swap places with a 40ft, and lets bulk
|
||||||
* the booking gets an allocation on the target wagon (created if missing),
|
* swap with containers.
|
||||||
* weights shift with the container, and an allocation left with no items is
|
*
|
||||||
* deleted.
|
* 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,
|
scheduleId: string,
|
||||||
itemId: string,
|
sourceWagonId: string,
|
||||||
dto: MoveContainerItemDto,
|
dto: MoveWagonLoadDto,
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||||
if (!schedule) {
|
if (!schedule) {
|
||||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||||
}
|
}
|
||||||
if (['DISPATCHED', 'ARRIVED'].includes(schedule.status)) {
|
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');
|
||||||
}
|
}
|
||||||
|
if (sourceWagonId === dto.targetWagonId) {
|
||||||
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) {
|
|
||||||
return this.getTrainScheduleById(scheduleId);
|
return this.getTrainScheduleById(scheduleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// TEU per container: 40ft fills a wagon (2), 20ft takes half (1). One
|
const slots = schedule.trainSet?.wagons ?? [];
|
||||||
// wagon never exceeds 2 TEU — the same rule the auto-allocation packs by.
|
const source = slots.find((w) => w.id === sourceWagonId);
|
||||||
const MAX_TEU_PER_WAGON = 2;
|
if (!source) {
|
||||||
const teuOf = (it: { containerType?: { sizeFt?: number | null } | null }) =>
|
throw new NotFoundException('Source wagon is not part of this schedule');
|
||||||
(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 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 allocRepo = this.dataSource.getRepository(WagonBookingAllocation);
|
||||||
const targetTeu = itemsOf(targetAllocs)
|
const loadAllocations = (trainSetWagonId: string) =>
|
||||||
.filter((it) => it.id !== swapItem?.id)
|
allocRepo.find({ where: { trainSetWagonId } });
|
||||||
.reduce((sum, it) => sum + teuOf(it), 0);
|
const sourceAllocs = await loadAllocations(source.id);
|
||||||
if (targetTeu + teuOf(item) > MAX_TEU_PER_WAGON) {
|
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(
|
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[]) =>
|
// What the target must be able to receive…
|
||||||
allocs.reduce((sum, a) => sum + Number(a.allocatedWeightTons || 0), 0);
|
checkReceives(
|
||||||
const checkPayload = (
|
sourceAllocs,
|
||||||
wagon: { sequenceNo: number; capacityTons?: number | null },
|
wagonLabel(targetSlot, consistWagon),
|
||||||
cargoAfter: number,
|
targetSlot ? targetSlot.wagonType : consistWagon?.wagonType,
|
||||||
) => {
|
Number(targetSlot ? targetSlot.capacityTons : (consistWagon?.wagonType?.capacityTons ?? 0)),
|
||||||
const capacity = Number(wagon.capacityTons ?? 0);
|
);
|
||||||
if (capacity > 0 && cargoAfter > capacity + 0.001) {
|
// …and, on a swap, what comes back to the source.
|
||||||
throw new BadRequestException(
|
if (targetAllocs.length) {
|
||||||
`Wagon #${wagon.sequenceNo} would carry ${roundTons(cargoAfter)}T — over its ${capacity}T payload`,
|
checkReceives(
|
||||||
);
|
targetAllocs,
|
||||||
}
|
`#${source.sequenceNo}`,
|
||||||
};
|
source.wagonType,
|
||||||
checkPayload(targetWagon, cargoOn(targetAllocs) - swapWeight + itemWeight);
|
Number(source.capacityTons),
|
||||||
if (swapItem) {
|
);
|
||||||
checkPayload(sourceWagon, cargoOn(sourceAllocs) - itemWeight + swapWeight);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.dataSource.transaction(async (manager) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
const items = manager.getRepository(WagonAllocationContainerItem);
|
const slotRepo = manager.getRepository(TrainSetWagon);
|
||||||
const allocs = manager.getRepository(WagonBookingAllocation);
|
const allocs = manager.getRepository(WagonBookingAllocation);
|
||||||
|
|
||||||
// Same-wagon swap: the containers only trade slot positions.
|
// Empty consist wagon: repin the loaded slot onto that physical wagon.
|
||||||
if (sourceWagon.id === targetWagon.id && swapItem) {
|
// Allocations and load fields stay put; only the wagon identity changes.
|
||||||
const a = item.positionOnWagon ?? null;
|
if (consistWagon) {
|
||||||
const b = swapItem.positionOnWagon ?? null;
|
await slotRepo.update(source.id, {
|
||||||
await items.update(item.id, { positionOnWagon: b });
|
physicalWagonId: consistWagon.id,
|
||||||
await items.update(swapItem.id, { positionOnWagon: a });
|
wagonTypeId: consistWagon.wagonTypeId,
|
||||||
|
capacityTons: roundTons(Number(consistWagon.wagonType?.capacityTons ?? source.capacityTons)),
|
||||||
|
lengthMeters: roundTons(Number(consistWagon.wagonType?.lengthMeters ?? source.lengthMeters)),
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const moveOne = async (
|
const target = targetSlot as TrainSetWagon;
|
||||||
moving: WagonAllocationContainerItem,
|
// Load-coupled slot fields travel with the load; wagon identity stays.
|
||||||
toWagonId: string,
|
const loadFieldsOf = (slot: TrainSetWagon) => ({
|
||||||
weight: number,
|
assignedWeightTons: slot.assignedWeightTons,
|
||||||
) => {
|
status: slot.status,
|
||||||
// Re-read the source allocation — the other leg of a swap may have
|
boardYardId: slot.boardYardId ?? null,
|
||||||
// already shifted weight on it within this transaction.
|
alightYardId: slot.alightYardId ?? null,
|
||||||
const from = await allocs.findOne({
|
});
|
||||||
where: { id: moving.wagonBookingAllocationId },
|
const emptyLoadFields = {
|
||||||
});
|
assignedWeightTons: 0,
|
||||||
if (!from) return;
|
status: 'PLANNED',
|
||||||
let to = await allocs.findOne({
|
boardYardId: null,
|
||||||
where: { trainSetWagonId: toWagonId, bookingId: from.bookingId },
|
alightYardId: null,
|
||||||
});
|
|
||||||
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 sourceLoadFields = loadFieldsOf(source);
|
||||||
|
const targetLoadFields = targetAllocs.length ? loadFieldsOf(target) : emptyLoadFields;
|
||||||
|
|
||||||
await moveOne(item, targetWagon.id, itemWeight);
|
for (const alloc of sourceAllocs) {
|
||||||
if (swapItem) {
|
await allocs.update(alloc.id, { trainSetWagonId: target.id });
|
||||||
await moveOne(swapItem, sourceWagon.id, swapWeight);
|
|
||||||
}
|
}
|
||||||
|
for (const alloc of targetAllocs) {
|
||||||
// Keep slot positions dense (1..n) on both touched wagons.
|
await allocs.update(alloc.id, { trainSetWagonId: source.id });
|
||||||
const renumber = async (trainSetWagonId: string) => {
|
}
|
||||||
const wagonAllocs = await allocs.find({
|
await slotRepo.update(target.id, sourceLoadFields);
|
||||||
where: { trainSetWagonId },
|
await slotRepo.update(source.id, targetLoadFields);
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.getTrainScheduleById(scheduleId);
|
return this.getTrainScheduleById(scheduleId);
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ interface AuthEmployeePosition {
|
|||||||
permissions?: AuthPermission[];
|
permissions?: AuthPermission[];
|
||||||
/** Some IAM payloads nest the position record instead of flattening its key. */
|
/** Some IAM payloads nest the position record instead of flattening its key. */
|
||||||
position?: { id?: string; key?: string; name?: LocaleText };
|
position?: { id?: string; key?: string; name?: LocaleText };
|
||||||
|
positionType?: { id?: string; key?: string; name?: LocaleText } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthEmployeeRecord {
|
interface AuthEmployeeRecord {
|
||||||
|
|||||||
@@ -15,14 +15,12 @@ import { freightBrand } from "@/theme/freight-brand";
|
|||||||
type Wagon = NonNullable<TrainScheduleDetail["trainSet"]>["wagons"][number];
|
type Wagon = NonNullable<TrainScheduleDetail["trainSet"]>["wagons"][number];
|
||||||
type Locomotive = NonNullable<TrainScheduleDetail["trainSet"]>["locomotive"];
|
type Locomotive = NonNullable<TrainScheduleDetail["trainSet"]>["locomotive"];
|
||||||
|
|
||||||
export interface ContainerMove {
|
export interface WagonLoadMove {
|
||||||
itemId: string;
|
sourceWagonId: string;
|
||||||
targetWagonId: 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 {
|
interface InteractiveTrainConsistProps {
|
||||||
wagons: Wagon[];
|
wagons: Wagon[];
|
||||||
@@ -33,9 +31,9 @@ interface InteractiveTrainConsistProps {
|
|||||||
onSelectWagon: (wagon: Wagon) => void;
|
onSelectWagon: (wagon: Wagon) => void;
|
||||||
/** Booking id to highlight across the train (e.g. selected in the side panel). */
|
/** Booking id to highlight across the train (e.g. selected in the side panel). */
|
||||||
highlightBookingId?: string | null;
|
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;
|
canRearrange?: boolean;
|
||||||
onMoveContainer?: (move: ContainerMove) => void;
|
onMoveLoad?: (move: WagonLoadMove) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const wagonItems = (wagon: Wagon) =>
|
const wagonItems = (wagon: Wagon) =>
|
||||||
@@ -171,7 +169,7 @@ function WagonCar({
|
|||||||
onSelect,
|
onSelect,
|
||||||
drag,
|
drag,
|
||||||
onDragChange,
|
onDragChange,
|
||||||
onMoveContainer,
|
onMoveLoad,
|
||||||
canRearrange,
|
canRearrange,
|
||||||
}: {
|
}: {
|
||||||
wagon: Wagon;
|
wagon: Wagon;
|
||||||
@@ -181,7 +179,7 @@ function WagonCar({
|
|||||||
onSelect: () => void;
|
onSelect: () => void;
|
||||||
drag: DragState;
|
drag: DragState;
|
||||||
onDragChange: (drag: DragState) => void;
|
onDragChange: (drag: DragState) => void;
|
||||||
onMoveContainer?: (move: ContainerMove) => void;
|
onMoveLoad?: (move: WagonLoadMove) => void;
|
||||||
canRearrange: boolean;
|
canRearrange: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [dropHover, setDropHover] = useState(false);
|
const [dropHover, setDropHover] = useState(false);
|
||||||
@@ -203,11 +201,12 @@ function WagonCar({
|
|||||||
const blocks = items.slice(0, 2);
|
const blocks = items.slice(0, 2);
|
||||||
const containerNumbers = items.map((c) => c.containerNumber?.trim() || "—");
|
const containerNumbers = items.map((c) => c.containerNumber?.trim() || "—");
|
||||||
|
|
||||||
// Where a dragged container may land: another wagon, not bulk-loaded, with a
|
// The whole load drags as one unit (a 20ft pair never splits). Any OTHER
|
||||||
// free half (the API re-checks TEU/weight — this only paints the hint).
|
// wagon is a drop target: empty → move (a consist-only wagon repins), loaded
|
||||||
const dropEligible = Boolean(
|
// → the two loads swap. The API validates wagon type + payload weight.
|
||||||
drag && drag.sourceWagonId !== wagon.id && !isBulk && items.length < 2,
|
const draggable = canRearrange && !isEmpty;
|
||||||
);
|
const beingDragged = drag?.sourceWagonId === wagon.id;
|
||||||
|
const dropEligible = Boolean(drag && !beingDragged);
|
||||||
const endDrag = () => {
|
const endDrag = () => {
|
||||||
onDragChange(null);
|
onDragChange(null);
|
||||||
setDropHover(false);
|
setDropHover(false);
|
||||||
@@ -238,7 +237,7 @@ function WagonCar({
|
|||||||
onDrop={(e) => {
|
onDrop={(e) => {
|
||||||
if (dropEligible && drag) {
|
if (dropEligible && drag) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onMoveContainer?.({ itemId: drag.itemId, targetWagonId: wagon.id });
|
onMoveLoad?.({ sourceWagonId: drag.sourceWagonId, targetWagonId: wagon.id });
|
||||||
}
|
}
|
||||||
endDrag();
|
endDrag();
|
||||||
}}
|
}}
|
||||||
@@ -302,8 +301,27 @@ function WagonCar({
|
|||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{/* body */}
|
{/* body — the cargo area is the drag handle for the wagon's whole load */}
|
||||||
<Box style={{ flex: 1, padding: "3px 7px", display: "flex", alignItems: "center" }}>
|
<Box
|
||||||
|
draggable={draggable}
|
||||||
|
onDragStart={(e) => {
|
||||||
|
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 ? (
|
{isEmpty ? (
|
||||||
<Text size="9px" c="dimmed" ta="center" style={{ width: "100%" }}>
|
<Text size="9px" c="dimmed" ta="center" style={{ width: "100%" }}>
|
||||||
Available
|
Available
|
||||||
@@ -333,83 +351,29 @@ function WagonCar({
|
|||||||
</Stack>
|
</Stack>
|
||||||
) : (
|
) : (
|
||||||
<Group gap={3} justify="center" wrap="nowrap" style={{ width: "100%" }}>
|
<Group gap={3} justify="center" wrap="nowrap" style={{ width: "100%" }}>
|
||||||
{blocks.length ? (
|
{(blocks.length ? blocks.map((c) => c.containerNumber?.trim() || "—") : ["—"]).map(
|
||||||
blocks.map((item, i) => {
|
(cn, i) => (
|
||||||
const isDragged = drag?.itemId === item.id;
|
<Box
|
||||||
const swapEligible = Boolean(drag && drag.itemId !== item.id);
|
key={i}
|
||||||
return (
|
style={{
|
||||||
<Box
|
flex: 1,
|
||||||
key={item.id}
|
minWidth: 0,
|
||||||
draggable={canRearrange}
|
height: 26,
|
||||||
onDragStart={(e) => {
|
borderRadius: 4,
|
||||||
e.stopPropagation();
|
background: CONTAINER_GRADIENTS[i % CONTAINER_GRADIENTS.length],
|
||||||
e.dataTransfer.effectAllowed = "move";
|
border: `1px solid ${CONTAINER_BORDERS[i % CONTAINER_BORDERS.length]}`,
|
||||||
// Firefox needs data set for the drag to start.
|
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.3)",
|
||||||
e.dataTransfer.setData("text/plain", item.id);
|
display: "flex",
|
||||||
onDragChange({ itemId: item.id, sourceWagonId: wagon.id });
|
alignItems: "center",
|
||||||
}}
|
justifyContent: "center",
|
||||||
onDragEnd={endDrag}
|
padding: "0 2px",
|
||||||
onDragOver={(e) => {
|
}}
|
||||||
if (swapEligible) {
|
>
|
||||||
e.preventDefault();
|
<Text size="8px" fw={700} c="white" truncate style={{ maxWidth: "100%" }}>
|
||||||
e.stopPropagation();
|
{cn}
|
||||||
e.dataTransfer.dropEffect = "move";
|
</Text>
|
||||||
}
|
</Box>
|
||||||
}}
|
),
|
||||||
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",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text size="8px" fw={700} c="white" truncate style={{ maxWidth: "100%" }}>
|
|
||||||
{item.containerNumber?.trim() || "—"}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<Box
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 0,
|
|
||||||
height: 26,
|
|
||||||
borderRadius: 4,
|
|
||||||
background: CONTAINER_GRADIENTS[0],
|
|
||||||
border: `1px solid ${CONTAINER_BORDERS[0]}`,
|
|
||||||
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.3)",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text size="8px" fw={700} c="white">
|
|
||||||
—
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
@@ -563,7 +527,7 @@ export const InteractiveTrainConsist = ({
|
|||||||
onSelectWagon,
|
onSelectWagon,
|
||||||
highlightBookingId,
|
highlightBookingId,
|
||||||
canRearrange = false,
|
canRearrange = false,
|
||||||
onMoveContainer,
|
onMoveLoad,
|
||||||
}: InteractiveTrainConsistProps) => {
|
}: InteractiveTrainConsistProps) => {
|
||||||
const [drag, setDrag] = useState<DragState>(null);
|
const [drag, setDrag] = useState<DragState>(null);
|
||||||
return (
|
return (
|
||||||
@@ -597,7 +561,7 @@ export const InteractiveTrainConsist = ({
|
|||||||
onSelect={() => onSelectWagon(wagon)}
|
onSelect={() => onSelectWagon(wagon)}
|
||||||
drag={drag}
|
drag={drag}
|
||||||
onDragChange={setDrag}
|
onDragChange={setDrag}
|
||||||
onMoveContainer={onMoveContainer}
|
onMoveLoad={onMoveLoad}
|
||||||
canRearrange={canRearrange}
|
canRearrange={canRearrange}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Hand, MousePointerClick, TrainFront } from "lucide-react";
|
|||||||
import type { TrainScheduleDetail } from "@/types/trainScheduling";
|
import type { TrainScheduleDetail } from "@/types/trainScheduling";
|
||||||
import { TrainStatsBar } from "./TrainStatsBar";
|
import { TrainStatsBar } from "./TrainStatsBar";
|
||||||
import { WagonCard } from "./WagonCard";
|
import { WagonCard } from "./WagonCard";
|
||||||
import { InteractiveTrainConsist, type ContainerMove } from "./InteractiveTrainConsist";
|
import { InteractiveTrainConsist, type WagonLoadMove } from "./InteractiveTrainConsist";
|
||||||
import { RemoveBookingModal } from "./RemoveBookingModal";
|
import { RemoveBookingModal } from "./RemoveBookingModal";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
@@ -57,33 +57,34 @@ export const TrainConsistView = ({
|
|||||||
const removeWagonMutation = useMutation(
|
const removeWagonMutation = useMutation(
|
||||||
api.trainScheduling.removeWagonSlot.mutationOptions(),
|
api.trainScheduling.removeWagonSlot.mutationOptions(),
|
||||||
);
|
);
|
||||||
const moveContainerMutation = useMutation(
|
const moveLoadMutation = useMutation(
|
||||||
api.trainScheduling.moveContainerItem.mutationOptions(),
|
api.trainScheduling.moveWagonLoad.mutationOptions(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const trainSet = scheduleDetail.trainSet;
|
const trainSet = scheduleDetail.trainSet;
|
||||||
const wagons = trainSet?.wagons ?? [];
|
const wagons = trainSet?.wagons ?? [];
|
||||||
const canRearrange = !["DISPATCHED", "ARRIVED"].includes(scheduleDetail.status);
|
const canRearrange = !["DISPATCHED", "ARRIVED"].includes(scheduleDetail.status);
|
||||||
|
|
||||||
const handleMoveContainer = async (move: ContainerMove) => {
|
const handleMoveLoad = async (move: WagonLoadMove) => {
|
||||||
if (moveContainerMutation.isPending) return;
|
if (moveLoadMutation.isPending) return;
|
||||||
|
const targetLoaded =
|
||||||
|
(wagons.find((w) => w.id === move.targetWagonId)?.allocations?.length ?? 0) > 0;
|
||||||
try {
|
try {
|
||||||
await moveContainerMutation.mutateAsync({
|
await moveLoadMutation.mutateAsync({
|
||||||
scheduleId,
|
scheduleId,
|
||||||
itemId: move.itemId,
|
wagonId: move.sourceWagonId,
|
||||||
targetTrainSetWagonId: move.targetWagonId,
|
targetWagonId: move.targetWagonId,
|
||||||
swapWithItemId: move.swapWithItemId,
|
|
||||||
});
|
});
|
||||||
toast({ title: move.swapWithItemId ? "Containers swapped" : "Container moved" });
|
toast({ title: targetLoaded ? "Wagon loads swapped" : "Load moved" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = isAxiosError(error)
|
const message = isAxiosError(error)
|
||||||
? ((error.response?.data as { message?: string | string[] } | undefined)?.message ?? null)
|
? ((error.response?.data as { message?: string | string[] } | undefined)?.message ?? null)
|
||||||
: null;
|
: null;
|
||||||
toast({
|
toast({
|
||||||
title: "Could not move container",
|
title: "Could not move the load",
|
||||||
description: Array.isArray(message)
|
description: Array.isArray(message)
|
||||||
? message.join(", ")
|
? 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",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -187,7 +188,7 @@ export const TrainConsistView = ({
|
|||||||
<Group gap={5} wrap="nowrap">
|
<Group gap={5} wrap="nowrap">
|
||||||
<Hand size={12} color="var(--mantine-color-cyan-7)" />
|
<Hand size={12} color="var(--mantine-color-cyan-7)" />
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
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
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -197,7 +198,7 @@ export const TrainConsistView = ({
|
|||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Box p="md" style={{ opacity: moveContainerMutation.isPending ? 0.6 : 1 }}>
|
<Box p="md" style={{ opacity: moveLoadMutation.isPending ? 0.6 : 1 }}>
|
||||||
<InteractiveTrainConsist
|
<InteractiveTrainConsist
|
||||||
wagons={wagons}
|
wagons={wagons}
|
||||||
locomotive={trainSet?.locomotive}
|
locomotive={trainSet?.locomotive}
|
||||||
@@ -205,8 +206,8 @@ export const TrainConsistView = ({
|
|||||||
selectedWagonId={selectedWagonId}
|
selectedWagonId={selectedWagonId}
|
||||||
onSelectWagon={(w) => setSelectedWagonId((prev) => (prev === w.id ? null : w.id))}
|
onSelectWagon={(w) => setSelectedWagonId((prev) => (prev === w.id ? null : w.id))}
|
||||||
highlightBookingId={highlightBookingId}
|
highlightBookingId={highlightBookingId}
|
||||||
canRearrange={canRearrange && !moveContainerMutation.isPending}
|
canRearrange={canRearrange && !moveLoadMutation.isPending}
|
||||||
onMoveContainer={(move) => void handleMoveContainer(move)}
|
onMoveLoad={(move) => void handleMoveLoad(move)}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
@@ -234,7 +235,7 @@ export const TrainConsistView = ({
|
|||||||
onRemoveBooking={handleRemoveBooking}
|
onRemoveBooking={handleRemoveBooking}
|
||||||
onRemoveWagon={handleRemoveWagon}
|
onRemoveWagon={handleRemoveWagon}
|
||||||
wagons={wagons}
|
wagons={wagons}
|
||||||
onMoveContainer={canRearrange ? (move) => void handleMoveContainer(move) : undefined}
|
onMoveLoad={canRearrange ? (move) => void handleMoveLoad(move) : undefined}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
) : wagons.length ? (
|
) : wagons.length ? (
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
ActionIcon,
|
|
||||||
Badge,
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
@@ -10,7 +9,6 @@ import {
|
|||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
Tooltip,
|
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
ArrowLeftRight,
|
ArrowLeftRight,
|
||||||
@@ -24,7 +22,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { TrainScheduleDetail } from "@/types/trainScheduling";
|
import type { TrainScheduleDetail } from "@/types/trainScheduling";
|
||||||
import { ContainerNumberInput } from "./ContainerNumberInput";
|
import { ContainerNumberInput } from "./ContainerNumberInput";
|
||||||
import type { ContainerMove } from "./InteractiveTrainConsist";
|
import type { WagonLoadMove } from "./InteractiveTrainConsist";
|
||||||
import { freightBrand } from "@/theme/freight-brand";
|
import { freightBrand } from "@/theme/freight-brand";
|
||||||
|
|
||||||
type Wagon = NonNullable<TrainScheduleDetail["trainSet"]>["wagons"][number];
|
type Wagon = NonNullable<TrainScheduleDetail["trainSet"]>["wagons"][number];
|
||||||
@@ -36,13 +34,12 @@ interface WagonCardProps {
|
|||||||
scheduleStatus?: string;
|
scheduleStatus?: string;
|
||||||
onRemoveBooking: (wagon: Wagon) => void;
|
onRemoveBooking: (wagon: Wagon) => void;
|
||||||
onRemoveWagon: (wagonId: string) => 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[];
|
wagons?: Wagon[];
|
||||||
onMoveContainer?: (move: ContainerMove) => void;
|
onMoveLoad?: (move: WagonLoadMove) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const itemCountOf = (w: Wagon) =>
|
const itemAllocCount = (w: Wagon) => w.allocations?.length ?? 0;
|
||||||
(w.allocations ?? []).reduce((sum, a) => sum + (a.containerItems?.length ?? 0), 0);
|
|
||||||
|
|
||||||
const isBulkWagon = (w: Wagon) =>
|
const isBulkWagon = (w: Wagon) =>
|
||||||
(w.allocations ?? []).some((a) => (a.loadType ?? "").toUpperCase().includes("BULK"));
|
(w.allocations ?? []).some((a) => (a.loadType ?? "").toUpperCase().includes("BULK"));
|
||||||
@@ -55,7 +52,7 @@ export const WagonCard = ({
|
|||||||
onRemoveBooking,
|
onRemoveBooking,
|
||||||
onRemoveWagon,
|
onRemoveWagon,
|
||||||
wagons,
|
wagons,
|
||||||
onMoveContainer,
|
onMoveLoad,
|
||||||
}: WagonCardProps) => {
|
}: WagonCardProps) => {
|
||||||
const isDispatched = scheduleStatus === "DISPATCHED";
|
const isDispatched = scheduleStatus === "DISPATCHED";
|
||||||
const allocation = wagon.allocations?.[0];
|
const allocation = wagon.allocations?.[0];
|
||||||
@@ -134,67 +131,20 @@ export const WagonCard = ({
|
|||||||
Containers
|
Containers
|
||||||
</Text>
|
</Text>
|
||||||
<Stack gap={6}>
|
<Stack gap={6}>
|
||||||
{allocation.containerItems.map((item, idx) => {
|
{allocation.containerItems.map((item, idx) => (
|
||||||
const targets = (wagons ?? []).filter(
|
<Group key={item.id} gap={8} wrap="nowrap">
|
||||||
(w) => w.id !== wagon.id && !isBulkWagon(w) && itemCountOf(w) < 2,
|
<ContainerIcon size={13} color="var(--mantine-color-cyan-7)" />
|
||||||
);
|
<Text size="xs" c="dimmed">
|
||||||
return (
|
#{idx + 1}
|
||||||
<Group key={item.id} gap={8} wrap="nowrap">
|
</Text>
|
||||||
<ContainerIcon size={13} color="var(--mantine-color-cyan-7)" />
|
<ContainerNumberInput
|
||||||
<Text size="xs" c="dimmed">
|
value={item.containerNumber ?? null}
|
||||||
#{idx + 1}
|
itemId={item.id}
|
||||||
</Text>
|
scheduleId={scheduleId}
|
||||||
<ContainerNumberInput
|
disabled={isDispatched}
|
||||||
value={item.containerNumber ?? null}
|
/>
|
||||||
itemId={item.id}
|
</Group>
|
||||||
scheduleId={scheduleId}
|
))}
|
||||||
disabled={isDispatched}
|
|
||||||
/>
|
|
||||||
{!isDispatched && onMoveContainer ? (
|
|
||||||
<Menu shadow="md" width={220} position="bottom-end" withinPortal>
|
|
||||||
<Menu.Target>
|
|
||||||
<Tooltip label="Move to another wagon" withArrow>
|
|
||||||
<ActionIcon variant="light" color="cyan" size="sm">
|
|
||||||
<ArrowLeftRight size={13} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
</Menu.Target>
|
|
||||||
<Menu.Dropdown>
|
|
||||||
<Menu.Label>Move to wagon</Menu.Label>
|
|
||||||
{targets.length ? (
|
|
||||||
targets.map((w) => {
|
|
||||||
const count = itemCountOf(w);
|
|
||||||
return (
|
|
||||||
<Menu.Item
|
|
||||||
key={w.id}
|
|
||||||
onClick={() =>
|
|
||||||
onMoveContainer({
|
|
||||||
itemId: item.id,
|
|
||||||
targetWagonId: w.id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Group gap={6} wrap="nowrap" justify="space-between">
|
|
||||||
<Text size="xs" fw={600}>
|
|
||||||
#{w.sequenceNo} ·{" "}
|
|
||||||
{w.physicalWagonNumber ?? w.wagonType?.code ?? "Wagon"}
|
|
||||||
</Text>
|
|
||||||
<Badge size="xs" variant="light" color={count ? "cyan" : "gray"}>
|
|
||||||
{count ? `${count}/2` : "empty"}
|
|
||||||
</Badge>
|
|
||||||
</Group>
|
|
||||||
</Menu.Item>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<Menu.Item disabled>No wagon has free space</Menu.Item>
|
|
||||||
)}
|
|
||||||
</Menu.Dropdown>
|
|
||||||
</Menu>
|
|
||||||
) : null}
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -226,16 +176,62 @@ export const WagonCard = ({
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{!isDispatched ? (
|
{!isDispatched ? (
|
||||||
<Button
|
<Group gap="xs" grow>
|
||||||
variant="light"
|
{onMoveLoad ? (
|
||||||
color="red"
|
<Menu shadow="md" width={240} position="bottom" withinPortal>
|
||||||
size="xs"
|
<Menu.Target>
|
||||||
leftSection={<X size={14} />}
|
<Button
|
||||||
onClick={() => onRemoveBooking(wagon)}
|
variant="light"
|
||||||
fullWidth
|
color="cyan"
|
||||||
>
|
size="xs"
|
||||||
Remove booking
|
leftSection={<ArrowLeftRight size={14} />}
|
||||||
</Button>
|
>
|
||||||
|
Move load
|
||||||
|
</Button>
|
||||||
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown>
|
||||||
|
<Menu.Label>Move this wagon's load to</Menu.Label>
|
||||||
|
{(wagons ?? [])
|
||||||
|
.filter((w) => w.id !== wagon.id)
|
||||||
|
.sort((a, b) => itemAllocCount(a) - itemAllocCount(b))
|
||||||
|
.map((w) => {
|
||||||
|
const loaded = itemAllocCount(w) > 0;
|
||||||
|
return (
|
||||||
|
<Menu.Item
|
||||||
|
key={w.id}
|
||||||
|
onClick={() =>
|
||||||
|
onMoveLoad({ sourceWagonId: wagon.id, targetWagonId: w.id })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Group gap={6} wrap="nowrap" justify="space-between">
|
||||||
|
<Text size="xs" fw={600} truncate>
|
||||||
|
#{w.sequenceNo} ·{" "}
|
||||||
|
{w.physicalWagonNumber ?? w.wagonType?.code ?? "Wagon"}
|
||||||
|
</Text>
|
||||||
|
<Badge
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
color={loaded ? (isBulkWagon(w) ? "orange" : "cyan") : "gray"}
|
||||||
|
>
|
||||||
|
{loaded ? "swap" : "empty"}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
</Menu.Item>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="red"
|
||||||
|
size="xs"
|
||||||
|
leftSection={<X size={14} />}
|
||||||
|
onClick={() => onRemoveBooking(wagon)}
|
||||||
|
>
|
||||||
|
Remove booking
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -399,8 +399,8 @@ export const URL_CONSTANTS = {
|
|||||||
`/train-scheduling/schedules/${scheduleId}/wagons/${wagonId}`,
|
`/train-scheduling/schedules/${scheduleId}/wagons/${wagonId}`,
|
||||||
UPDATE_CONTAINER_ITEM: (scheduleId: string, itemId: string) =>
|
UPDATE_CONTAINER_ITEM: (scheduleId: string, itemId: string) =>
|
||||||
`/train-scheduling/schedules/${scheduleId}/container-items/${itemId}`,
|
`/train-scheduling/schedules/${scheduleId}/container-items/${itemId}`,
|
||||||
MOVE_CONTAINER_ITEM: (scheduleId: string, itemId: string) =>
|
MOVE_WAGON_LOAD: (scheduleId: string, wagonId: string) =>
|
||||||
`/train-scheduling/schedules/${scheduleId}/container-items/${itemId}/move`,
|
`/train-scheduling/schedules/${scheduleId}/wagons/${wagonId}/move-load`,
|
||||||
UNASSIGNED_BOOKINGS: (scheduleId: string) =>
|
UNASSIGNED_BOOKINGS: (scheduleId: string) =>
|
||||||
`/train-scheduling/schedules/${scheduleId}/unassigned-bookings`,
|
`/train-scheduling/schedules/${scheduleId}/unassigned-bookings`,
|
||||||
COMPOSITION_REMOVALS: (scheduleId: string) =>
|
COMPOSITION_REMOVALS: (scheduleId: string) =>
|
||||||
|
|||||||
@@ -353,12 +353,38 @@ export const POSITION_KEYS = {
|
|||||||
djiboutiGl: "djibouti_gl",
|
djiboutiGl: "djibouti_gl",
|
||||||
} as const;
|
} 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<string>();
|
||||||
|
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 {
|
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 {
|
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 {
|
export function isSuperAdmin(user: AuthUser | null | undefined): boolean {
|
||||||
|
|||||||
@@ -815,21 +815,15 @@ export const api = {
|
|||||||
() => [QUERY_KEYS.TRAIN_SCHEDULING.ROOT],
|
() => [QUERY_KEYS.TRAIN_SCHEDULING.ROOT],
|
||||||
),
|
),
|
||||||
|
|
||||||
moveContainerItem: endpoint<
|
moveWagonLoad: endpoint<
|
||||||
{
|
{ scheduleId: string; wagonId: string; targetWagonId: string },
|
||||||
scheduleId: string;
|
|
||||||
itemId: string;
|
|
||||||
targetTrainSetWagonId: string;
|
|
||||||
swapWithItemId?: string;
|
|
||||||
},
|
|
||||||
TrainScheduleDetail
|
TrainScheduleDetail
|
||||||
>(
|
>(
|
||||||
"train-scheduling",
|
"train-scheduling",
|
||||||
"move-container-item",
|
"move-wagon-load",
|
||||||
({ scheduleId, itemId, targetTrainSetWagonId, swapWithItemId }) =>
|
({ scheduleId, wagonId, targetWagonId }) =>
|
||||||
trainSchedulingService.moveContainerItem(scheduleId, itemId, {
|
trainSchedulingService.moveWagonLoad(scheduleId, wagonId, {
|
||||||
targetTrainSetWagonId,
|
targetWagonId,
|
||||||
swapWithItemId,
|
|
||||||
}),
|
}),
|
||||||
undefined,
|
undefined,
|
||||||
() => [QUERY_KEYS.TRAIN_SCHEDULING.ROOT],
|
() => [QUERY_KEYS.TRAIN_SCHEDULING.ROOT],
|
||||||
|
|||||||
@@ -757,13 +757,13 @@ export const trainSchedulingService = {
|
|||||||
return unwrap(response.data);
|
return unwrap(response.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
moveContainerItem: async (
|
moveWagonLoad: async (
|
||||||
scheduleId: string,
|
scheduleId: string,
|
||||||
itemId: string,
|
wagonId: string,
|
||||||
payload: { targetTrainSetWagonId: string; swapWithItemId?: string },
|
payload: { targetWagonId: string },
|
||||||
): Promise<TrainScheduleDetail> => {
|
): Promise<TrainScheduleDetail> => {
|
||||||
const response = await client.post<TrainScheduleDetail>(
|
const response = await client.post<TrainScheduleDetail>(
|
||||||
URL_CONSTANTS.TRAIN_SCHEDULING.MOVE_CONTAINER_ITEM(scheduleId, itemId),
|
URL_CONSTANTS.TRAIN_SCHEDULING.MOVE_WAGON_LOAD(scheduleId, wagonId),
|
||||||
payload,
|
payload,
|
||||||
);
|
);
|
||||||
return unwrap(response.data);
|
return unwrap(response.data);
|
||||||
|
|||||||
Reference in New Issue
Block a user