From 6fa315db0f4bad01da7eca90b87ad3f02ae26cf0 Mon Sep 17 00:00:00 2001 From: natib21 Date: Mon, 29 Jun 2026 14:45:22 +0000 Subject: [PATCH 1/4] fix --- ...0002-CreateLastMileContainerAllocations.ts | 78 +++++++++ ...00000-CreateBookingContainerAllocations.ts | 80 +++++++++ ...000-CreateFirstMileContainerAllocations.ts | 74 ++++++++ .../bookings/booking-allocation.controller.ts | 20 +++ .../src/modules/bookings/bookings.module.ts | 5 +- .../src/modules/bookings/bookings.service.ts | 32 ++++ .../bookings/dto/allocate-containers.dto.ts | 8 + .../booking-container-allocation.entity.ts | 32 ++++ .../bookings/entities/booking.entity.ts | 4 + .../first-mile/dto/allocate-containers.dto.ts | 8 + .../first-mile-container-allocation.entity.ts | 36 ++++ .../first-mile/entities/first-mile.entity.ts | 10 +- .../first-mile/first-mile.controller.ts | 11 ++ .../modules/first-mile/first-mile.module.ts | 3 +- .../modules/first-mile/first-mile.service.ts | 35 ++++ .../last-mile/dto/allocate-containers.dto.ts | 8 + .../last-mile-container-allocation.entity.ts | 32 ++++ .../last-mile/entities/last-mile.entity.ts | 6 +- .../modules/last-mile/last-mile.controller.ts | 11 ++ .../src/modules/last-mile/last-mile.module.ts | 3 +- .../modules/last-mile/last-mile.service.ts | 35 +++- .../components/ContainerAllocationTable.tsx | 164 ++++++++++++++++++ .../FirstMileContainerAllocationTable.tsx | 164 ++++++++++++++++++ .../LastMileContainerAllocationTable.tsx | 164 ++++++++++++++++++ .../backoffice/src/constants/apiConfig.ts | 4 +- .../src/pages/bookings/BookingDetailPage.tsx | 29 ++++ .../src/pages/operations/FirstMilePage.tsx | 79 +++++++++ .../src/pages/operations/LastMilePage.tsx | 100 +++++++++++ 28 files changed, 1227 insertions(+), 8 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1810000000002-CreateLastMileContainerAllocations.ts create mode 100644 apps/edr-freight-api/src/migrations/1825000000000-CreateBookingContainerAllocations.ts create mode 100644 apps/edr-freight-api/src/migrations/1830000000000-CreateFirstMileContainerAllocations.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/booking-allocation.controller.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/dto/allocate-containers.dto.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/entities/booking-container-allocation.entity.ts create mode 100644 apps/edr-freight-api/src/modules/first-mile/dto/allocate-containers.dto.ts create mode 100644 apps/edr-freight-api/src/modules/first-mile/entities/first-mile-container-allocation.entity.ts create mode 100644 apps/edr-freight-api/src/modules/last-mile/dto/allocate-containers.dto.ts create mode 100644 apps/edr-freight-api/src/modules/last-mile/entities/last-mile-container-allocation.entity.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/ContainerAllocationTable.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/FirstMileContainerAllocationTable.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/LastMileContainerAllocationTable.tsx diff --git a/apps/edr-freight-api/src/migrations/1810000000002-CreateLastMileContainerAllocations.ts b/apps/edr-freight-api/src/migrations/1810000000002-CreateLastMileContainerAllocations.ts new file mode 100644 index 000000000..b2026b753 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1810000000002-CreateLastMileContainerAllocations.ts @@ -0,0 +1,78 @@ +import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm'; + +/** + * Create the freight.last_mile_container_allocations table — container allocation + * records linking last-mile deliveries with containers and vehicles. + */ +export class CreateLastMileContainerAllocations1810000000002 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + const exists = await queryRunner.hasTable('freight.last_mile_container_allocations'); + if (exists) return; + + await queryRunner.createTable( + new Table({ + name: 'freight.last_mile_container_allocations', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + default: 'gen_random_uuid()', + }, + { name: 'last_mile_id', type: 'uuid', isNullable: false }, + { name: 'container_id', type: 'uuid', isNullable: false }, + { name: 'vehicle_id', type: 'uuid', isNullable: true }, + { + name: 'container_type', + type: 'text', + isNullable: false, + }, + { + name: 'quantity', + type: 'integer', + default: 1, + isNullable: false, + }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + + await queryRunner.createForeignKey( + 'freight.last_mile_container_allocations', + new TableForeignKey({ + columnNames: ['last_mile_id'], + referencedTableName: 'freight.last_mile', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + + await queryRunner.createForeignKey( + 'freight.last_mile_container_allocations', + new TableForeignKey({ + columnNames: ['vehicle_id'], + referencedTableName: 'freight.vehicles', + referencedColumnNames: ['id'], + onDelete: 'SET NULL', + }), + ); + + await queryRunner.query( + `CREATE INDEX "IDX_last_mile_container_allocations_last_mile_id" ON "freight"."last_mile_container_allocations" ("last_mile_id")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_last_mile_container_allocations_vehicle_id" ON "freight"."last_mile_container_allocations" ("vehicle_id")`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + const exists = await queryRunner.hasTable('freight.last_mile_container_allocations'); + if (exists) { + await queryRunner.dropTable('freight.last_mile_container_allocations'); + } + } +} diff --git a/apps/edr-freight-api/src/migrations/1825000000000-CreateBookingContainerAllocations.ts b/apps/edr-freight-api/src/migrations/1825000000000-CreateBookingContainerAllocations.ts new file mode 100644 index 000000000..70b0832f5 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1825000000000-CreateBookingContainerAllocations.ts @@ -0,0 +1,80 @@ +import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm'; + +/** + * Create the freight.booking_container_allocations table — container-to-vehicle + * allocation mapping for flexible routing of containers across available vehicles. + */ +export class CreateBookingContainerAllocations1825000000000 implements MigrationInterface { + name = 'CreateBookingContainerAllocations1825000000000'; + + public async up(queryRunner: QueryRunner): Promise { + const exists = await queryRunner.hasTable('freight.booking_container_allocations'); + if (exists) return; + + await queryRunner.createTable( + new Table({ + name: 'freight.booking_container_allocations', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + default: 'gen_random_uuid()', + }, + { name: 'booking_id', type: 'uuid', isNullable: false }, + { name: 'container_id', type: 'uuid', isNullable: false }, + { name: 'vehicle_id', type: 'uuid', isNullable: true }, + { + name: 'container_type', + type: 'text', + isNullable: false, + }, + { + name: 'quantity', + type: 'integer', + default: 1, + isNullable: false, + }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + + await queryRunner.createForeignKey( + 'freight.booking_container_allocations', + new TableForeignKey({ + columnNames: ['booking_id'], + referencedTableName: 'freight.bookings', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + + await queryRunner.createForeignKey( + 'freight.booking_container_allocations', + new TableForeignKey({ + columnNames: ['vehicle_id'], + referencedTableName: 'freight.vehicles', + referencedColumnNames: ['id'], + onDelete: 'SET NULL', + }), + ); + + await queryRunner.query( + `CREATE INDEX "IDX_booking_container_allocations_booking_id" ON "freight"."booking_container_allocations" ("booking_id")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_booking_container_allocations_vehicle_id" ON "freight"."booking_container_allocations" ("vehicle_id")`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + const exists = await queryRunner.hasTable('freight.booking_container_allocations'); + if (exists) { + await queryRunner.dropTable('freight.booking_container_allocations'); + } + } +} diff --git a/apps/edr-freight-api/src/migrations/1830000000000-CreateFirstMileContainerAllocations.ts b/apps/edr-freight-api/src/migrations/1830000000000-CreateFirstMileContainerAllocations.ts new file mode 100644 index 000000000..b91e88633 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1830000000000-CreateFirstMileContainerAllocations.ts @@ -0,0 +1,74 @@ +import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm'; + +/** + * Create freight.first_mile_container_allocations table — tracks + * container allocations per first-mile shipment with optional vehicle assignment. + */ +export class CreateFirstMileContainerAllocations1830000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + const exists = await queryRunner.hasTable('freight.first_mile_container_allocations'); + if (exists) return; + + await queryRunner.createTable( + new Table({ + name: 'freight.first_mile_container_allocations', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + default: 'gen_random_uuid()', + }, + { name: 'first_mile_id', type: 'uuid', isNullable: false }, + { name: 'container_id', type: 'uuid', isNullable: false }, + { name: 'vehicle_id', type: 'uuid', isNullable: true }, + { name: 'container_type', type: 'text', isNullable: false }, + { + name: 'quantity', + type: 'int', + default: 1, + isNullable: false, + }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + + await queryRunner.createForeignKey( + 'freight.first_mile_container_allocations', + new TableForeignKey({ + columnNames: ['first_mile_id'], + referencedTableName: 'freight.first_mile', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + + await queryRunner.createForeignKey( + 'freight.first_mile_container_allocations', + new TableForeignKey({ + columnNames: ['vehicle_id'], + referencedTableName: 'freight.vehicles', + referencedColumnNames: ['id'], + onDelete: 'SET NULL', + }), + ); + + await queryRunner.query( + `CREATE INDEX "IDX_first_mile_container_allocations_first_mile_id" ON "freight"."first_mile_container_allocations" ("first_mile_id")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_first_mile_container_allocations_vehicle_id" ON "freight"."first_mile_container_allocations" ("vehicle_id")`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + const exists = await queryRunner.hasTable('freight.first_mile_container_allocations'); + if (exists) { + await queryRunner.dropTable('freight.first_mile_container_allocations'); + } + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-allocation.controller.ts b/apps/edr-freight-api/src/modules/bookings/booking-allocation.controller.ts new file mode 100644 index 000000000..cfb9887c3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-allocation.controller.ts @@ -0,0 +1,20 @@ +import { Body, Controller, Param, ParseUUIDPipe, Post } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { BookingsService } from './bookings.service'; +import { AllocateContainersDto } from './dto/allocate-containers.dto'; + +@ApiTags('bookings') +@Controller('bookings') +@ApiBearerAuth() +export class BookingAllocationController { + constructor(private readonly bookingsService: BookingsService) {} + + @Post(':bookingId/allocate-containers') + @ApiOperation({ summary: 'Allocate containers to vehicles' }) + async allocateContainers( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @Body() dto: AllocateContainersDto, + ) { + return this.bookingsService.allocateContainers(bookingId, dto.allocations); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index ded7d0239..b959677bd 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -16,6 +16,7 @@ import { BookingPricingService } from './booking-pricing.service'; import { BookingReferenceDataService } from './booking-reference-data.service'; import { BookingTransitionService } from './booking-transition.service'; import { BookingsController } from './bookings.controller'; +import { BookingAllocationController } from './booking-allocation.controller'; import { PayController } from './pay.controller'; import { BookingsRepository } from './bookings.repository'; import { ConsolidationService } from './consolidation.service'; @@ -28,6 +29,7 @@ import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity'; import { BookingContractSignature } from './entities/booking-contract-signature.entity'; import { BookingReviewNote } from './entities/booking-review-note.entity'; import { Booking } from './entities/booking.entity'; +import { BookingContainerAllocation } from './entities/booking-container-allocation.entity'; import { ContractPdfService } from '../../contracts/contract-pdf.service'; import { ContractPricingScheduleBuilder } from '../../contracts/contract-pricing-schedule.builder'; import { ContractRendererService } from '../../contracts/contract-renderer.service'; @@ -47,6 +49,7 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu BookingRateSnapshot, BookingReviewNote, BookingContractSignature, + BookingContainerAllocation, ]), PaymentModule, forwardRef(() => TrainSchedulingModule), @@ -63,7 +66,7 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu config.get('app.cbeExchange') ?? {}, }), ], - controllers: [BookingsController, PayController], + controllers: [BookingsController, BookingAllocationController, PayController], providers: [ BookingsService, BookingsRepository, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 3d3bab20b..ea7eea31f 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -43,6 +43,7 @@ import { FreightType, } from './entities/booking.entity'; import { Booking } from './entities/booking.entity'; +import { BookingContainerAllocation } from './entities/booking-container-allocation.entity'; import { FileRecord } from '../files/entities/file.entity'; /** Paginated booking list: flat `total` (backoffice) + `meta` block (portal). */ @@ -1305,4 +1306,35 @@ export class BookingsService { createdAt: b.createdAt, })); } + + async allocateContainers( + bookingId: string, + allocations: Array<{ containerId: string; vehicleId: string }>, + ) { + const booking = await this.findById(bookingId); + if (!booking) { + throw new NotFoundException(`Booking ${bookingId} not found`); + } + + await this.dataSource.transaction(async (manager) => { + for (const allocation of allocations) { + await manager.delete(BookingContainerAllocation, { + bookingId, + containerId: allocation.containerId, + }); + await manager.insert(BookingContainerAllocation, { + bookingId, + containerId: allocation.containerId, + vehicleId: allocation.vehicleId, + containerType: 'CONTAINER', + quantity: 1, + }); + } + }); + + return { + success: true, + allocated: allocations.length, + }; + } } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/allocate-containers.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/allocate-containers.dto.ts new file mode 100644 index 000000000..8b9b7da39 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/allocate-containers.dto.ts @@ -0,0 +1,8 @@ +export class ContainerAllocationDto { + containerId!: string; + vehicleId!: string; +} + +export class AllocateContainersDto { + allocations!: ContainerAllocationDto[]; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-container-allocation.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-container-allocation.entity.ts new file mode 100644 index 000000000..8cb186e09 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-container-allocation.entity.ts @@ -0,0 +1,32 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { Booking } from './booking.entity'; +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; + +@Entity({ schema: 'freight', name: 'booking_container_allocations' }) +@Index(['bookingId']) +@Index(['vehicleId']) +export class BookingContainerAllocation extends BaseEntity { + @ManyToOne(() => Booking, (b) => b.containerAllocations) + @JoinColumn({ name: 'booking_id' }) + booking!: Booking; + + @Column('uuid', { name: 'booking_id' }) + bookingId!: string; + + @Column('uuid', { name: 'container_id' }) + containerId!: string; + + @ManyToOne(() => Vehicle) + @JoinColumn({ name: 'vehicle_id' }) + vehicle!: Vehicle; + + @Column('uuid', { name: 'vehicle_id', nullable: true }) + vehicleId?: string; + + @Column('text') + containerType!: string; // CONTAINER, BULK_DRY, etc + + @Column('integer', { default: 1 }) + quantity!: number; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index ac7fe636a..3c93b26f2 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -13,6 +13,7 @@ import { FileRecord } from '../../files/entities/file.entity'; import { BookingApprovalStep } from './booking-approval-step.entity'; import { BookingCargoModifier } from './booking-cargo-modifier.entity'; import { BookingContainer } from './booking-container.entity'; +import { BookingContainerAllocation } from './booking-container-allocation.entity'; import { BookingRateSnapshot } from './booking-rate-snapshot.entity'; import { BookingReviewNote } from './booking-review-note.entity'; @@ -441,6 +442,9 @@ export class Booking extends BaseEntity { @OneToMany(() => BookingContainer, (bc) => bc.booking) bookingContainers?: BookingContainer[]; + @OneToMany(() => BookingContainerAllocation, (ca) => ca.booking) + containerAllocations?: BookingContainerAllocation[]; + @OneToMany(() => BookingCargoModifier, (m) => m.booking) cargoModifiers?: BookingCargoModifier[]; diff --git a/apps/edr-freight-api/src/modules/first-mile/dto/allocate-containers.dto.ts b/apps/edr-freight-api/src/modules/first-mile/dto/allocate-containers.dto.ts new file mode 100644 index 000000000..b750f1147 --- /dev/null +++ b/apps/edr-freight-api/src/modules/first-mile/dto/allocate-containers.dto.ts @@ -0,0 +1,8 @@ +export class FirstMileContainerAllocationDto { + containerId!: string; + vehicleId!: string; +} + +export class AllocateFirstMileContainersDto { + allocations!: FirstMileContainerAllocationDto[]; +} diff --git a/apps/edr-freight-api/src/modules/first-mile/entities/first-mile-container-allocation.entity.ts b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile-container-allocation.entity.ts new file mode 100644 index 000000000..e9407a57f --- /dev/null +++ b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile-container-allocation.entity.ts @@ -0,0 +1,36 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; + +import { FirstMile } from './first-mile.entity'; +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; + +@Entity({ name: 'first_mile_container_allocations', schema: 'freight' }) +@Index(['firstMileId']) +@Index(['vehicleId']) +export class FirstMileContainerAllocation extends BaseEntity { + @Column({ name: 'first_mile_id', type: 'uuid' }) + firstMileId!: string; + + @ManyToOne(() => FirstMile, (firstMile) => firstMile.containerAllocations, { + nullable: false, + eager: false, + }) + @JoinColumn({ name: 'first_mile_id' }) + firstMile?: FirstMile; + + @Column({ name: 'container_id', type: 'uuid' }) + containerId!: string; + + @Column({ name: 'vehicle_id', type: 'uuid', nullable: true }) + vehicleId?: string | null; + + @ManyToOne(() => Vehicle, { nullable: true, eager: false }) + @JoinColumn({ name: 'vehicle_id' }) + vehicle?: Vehicle | null; + + @Column({ name: 'container_type', type: 'text' }) + containerType!: string; + + @Column({ name: 'quantity', type: 'int', default: 1 }) + quantity!: number; +} diff --git a/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts index e810d23cc..b2eb3801f 100644 --- a/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts +++ b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts @@ -1,8 +1,9 @@ import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; import { Booking } from '../../bookings/entities/booking.entity'; import { Vehicle } from '../../vehicles/entities/vehicle.entity'; +import { FirstMileContainerAllocation } from './first-mile-container-allocation.entity'; export const FIRST_MILE_STATUSES = [ 'PAYMENT_PENDING', @@ -49,4 +50,11 @@ export class FirstMile extends BaseEntity { @ManyToOne(() => Vehicle, { nullable: true, eager: false }) @JoinColumn({ name: 'vehicle_id' }) vehicle?: Vehicle | null; + + @OneToMany( + () => FirstMileContainerAllocation, + (containerAllocation) => containerAllocation.firstMile, + { eager: false }, + ) + containerAllocations!: FirstMileContainerAllocation[]; } diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts index 78c3d43ff..3ecc2db20 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts @@ -17,6 +17,7 @@ import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking import { CreateFirstMileDto } from './dto/create-first-mile.dto'; import { UpdateFirstMileDto } from './dto/update-first-mile.dto'; +import { AllocateFirstMileContainersDto } from './dto/allocate-containers.dto'; import { FirstMileStatus } from './entities/first-mile.entity'; import { FirstMileService } from './first-mile.service'; @@ -83,4 +84,14 @@ export class FirstMileController { remove(@Param('id', ParseUUIDPipe) id: string) { return this.firstMileService.remove(id); } + + @Post(':firstMileId/allocate-containers') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Allocate containers to vehicles for a first-mile leg' }) + allocateContainers( + @Param('firstMileId', ParseUUIDPipe) firstMileId: string, + @Body() dto: AllocateFirstMileContainersDto, + ) { + return this.firstMileService.allocateContainers(firstMileId, dto.allocations); + } } diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts index bf6815af7..0cdf3c92c 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts @@ -6,13 +6,14 @@ import { DriversModule } from '../drivers/drivers.module'; import { NotificationsModule } from '../notifications/notifications.module'; import { VehiclesModule } from '../vehicles/vehicles.module'; import { FirstMile } from './entities/first-mile.entity'; +import { FirstMileContainerAllocation } from './entities/first-mile-container-allocation.entity'; import { FirstMileController } from './first-mile.controller'; import { FirstMileRepository } from './first-mile.repository'; import { FirstMileService } from './first-mile.service'; @Module({ imports: [ - TypeOrmModule.forFeature([FirstMile]), + TypeOrmModule.forFeature([FirstMile, FirstMileContainerAllocation]), forwardRef(() => BookingsModule), VehiclesModule, DriversModule, diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index 45c2658db..08cd9ab10 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -1,5 +1,7 @@ import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { FindOptionsWhere } from 'typeorm'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; import { DriversService } from '../drivers/drivers.service'; @@ -8,6 +10,7 @@ import { VehiclesService } from '../vehicles/vehicles.service'; import { CreateFirstMileDto } from './dto/create-first-mile.dto'; import { UpdateFirstMileDto } from './dto/update-first-mile.dto'; import { FirstMile, FirstMileStatus } from './entities/first-mile.entity'; +import { FirstMileContainerAllocation } from './entities/first-mile-container-allocation.entity'; import { FirstMileRepository } from './first-mile.repository'; type FirstMileListFilter = { @@ -32,6 +35,7 @@ export class FirstMileService { private readonly logger = new Logger(FirstMileService.name); constructor( + @InjectDataSource() private readonly dataSource: DataSource, private readonly firstMileRepository: FirstMileRepository, private readonly bookingsRepository: BookingsRepository, private readonly vehiclesService: VehiclesService, @@ -273,4 +277,35 @@ export class FirstMileService { await this.findById(id); await this.firstMileRepository.softDelete(id); } + + async allocateContainers( + firstMileId: string, + allocations: Array<{ containerId: string; vehicleId: string }>, + ) { + const firstMile = await this.findById(firstMileId); + if (!firstMile) { + throw new NotFoundException(`First-mile record ${firstMileId} not found`); + } + + await this.dataSource.transaction(async (manager) => { + for (const allocation of allocations) { + await manager.delete(FirstMileContainerAllocation, { + firstMileId, + containerId: allocation.containerId, + }); + await manager.insert(FirstMileContainerAllocation, { + firstMileId, + containerId: allocation.containerId, + vehicleId: allocation.vehicleId, + containerType: 'CONTAINER', + quantity: 1, + }); + } + }); + + return { + success: true, + allocated: allocations.length, + }; + } } diff --git a/apps/edr-freight-api/src/modules/last-mile/dto/allocate-containers.dto.ts b/apps/edr-freight-api/src/modules/last-mile/dto/allocate-containers.dto.ts new file mode 100644 index 000000000..de86ac883 --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile/dto/allocate-containers.dto.ts @@ -0,0 +1,8 @@ +export class LastMileContainerAllocationDto { + containerId!: string; + vehicleId!: string; +} + +export class AllocateLastMileContainersDto { + allocations!: LastMileContainerAllocationDto[]; +} diff --git a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-container-allocation.entity.ts b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-container-allocation.entity.ts new file mode 100644 index 000000000..8a61c73bf --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-container-allocation.entity.ts @@ -0,0 +1,32 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { LastMile } from './last-mile.entity'; +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; + +@Entity({ schema: 'freight', name: 'last_mile_container_allocations' }) +@Index(['lastMileId']) +@Index(['vehicleId']) +export class LastMileContainerAllocation extends BaseEntity { + @ManyToOne(() => LastMile, (lm) => lm.containerAllocations) + @JoinColumn({ name: 'last_mile_id' }) + lastMile!: LastMile; + + @Column('uuid', { name: 'last_mile_id' }) + lastMileId!: string; + + @Column('uuid', { name: 'container_id' }) + containerId!: string; + + @ManyToOne(() => Vehicle) + @JoinColumn({ name: 'vehicle_id' }) + vehicle?: Vehicle | null; + + @Column('uuid', { name: 'vehicle_id', nullable: true }) + vehicleId?: string | null; + + @Column('text') + containerType!: string; + + @Column('integer', { default: 1 }) + quantity!: number; +} diff --git a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts index ad4b789f4..61aad0d72 100644 --- a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts +++ b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts @@ -1,8 +1,9 @@ import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; import { Booking } from '../../bookings/entities/booking.entity'; import { Vehicle } from '../../vehicles/entities/vehicle.entity'; +import { LastMileContainerAllocation } from './last-mile-container-allocation.entity'; export const LAST_MILE_STATUSES = [ 'PAYMENT_PENDING', @@ -49,4 +50,7 @@ export class LastMile extends BaseEntity { @ManyToOne(() => Vehicle, { nullable: true, eager: false }) @JoinColumn({ name: 'vehicle_id' }) vehicle?: Vehicle | null; + + @OneToMany(() => LastMileContainerAllocation, (ca) => ca.lastMile) + containerAllocations?: LastMileContainerAllocation[]; } diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts index e8abf52c6..935aa5ac7 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts @@ -17,6 +17,7 @@ import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking import { CreateLastMileDto } from './dto/create-last-mile.dto'; import { UpdateLastMileDto } from './dto/update-last-mile.dto'; +import { AllocateLastMileContainersDto } from './dto/allocate-containers.dto'; import { LastMileStatus } from './entities/last-mile.entity'; import { LastMileService } from './last-mile.service'; @@ -83,4 +84,14 @@ export class LastMileController { remove(@Param('id', ParseUUIDPipe) id: string) { return this.lastMileService.remove(id); } + + @Post(':id/allocate-containers') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Allocate containers to vehicles' }) + async allocateContainers( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: AllocateLastMileContainersDto, + ) { + return this.lastMileService.allocateContainers(id, dto.allocations); + } } diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts index e4b99a18c..e6ed6634d 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts @@ -6,13 +6,14 @@ import { DriversModule } from '../drivers/drivers.module'; import { NotificationsModule } from '../notifications/notifications.module'; import { VehiclesModule } from '../vehicles/vehicles.module'; import { LastMile } from './entities/last-mile.entity'; +import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity'; import { LastMileController } from './last-mile.controller'; import { LastMileRepository } from './last-mile.repository'; import { LastMileService } from './last-mile.service'; @Module({ imports: [ - TypeOrmModule.forFeature([LastMile]), + TypeOrmModule.forFeature([LastMile, LastMileContainerAllocation]), forwardRef(() => BookingsModule), VehiclesModule, DriversModule, diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 77a8a2fea..5faad49b9 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -1,5 +1,5 @@ import { Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { FindOptionsWhere } from 'typeorm'; +import { DataSource, FindOptionsWhere } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; import { DriversService } from '../drivers/drivers.service'; @@ -8,6 +8,7 @@ import { VehiclesService } from '../vehicles/vehicles.service'; import { CreateLastMileDto } from './dto/create-last-mile.dto'; import { UpdateLastMileDto } from './dto/update-last-mile.dto'; import { LastMile, LastMileStatus } from './entities/last-mile.entity'; +import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity'; import { LastMileRepository } from './last-mile.repository'; type LastMileListFilter = { @@ -37,6 +38,7 @@ export class LastMileService { private readonly vehiclesService: VehiclesService, private readonly driversService: DriversService, private readonly smsClient: SmsClientService, + private readonly dataSource: DataSource, ) {} async acceptBooking(bookingReference: string): Promise { @@ -206,4 +208,35 @@ export class LastMileService { await this.findById(id); await this.lastMileRepository.softDelete(id); } + + async allocateContainers( + lastMileId: string, + allocations: Array<{ containerId: string; vehicleId: string }>, + ) { + const lastMile = await this.findById(lastMileId); + if (!lastMile) { + throw new NotFoundException(`Last-mile record ${lastMileId} not found`); + } + + await this.dataSource.transaction(async (manager) => { + for (const allocation of allocations) { + await manager.delete(LastMileContainerAllocation, { + lastMileId, + containerId: allocation.containerId, + }); + await manager.insert(LastMileContainerAllocation, { + lastMileId, + containerId: allocation.containerId, + vehicleId: allocation.vehicleId, + containerType: 'CONTAINER', + quantity: 1, + }); + } + }); + + return { + success: true, + allocated: allocations.length, + }; + } } diff --git a/apps/edr-freight-web/backoffice/src/components/ContainerAllocationTable.tsx b/apps/edr-freight-web/backoffice/src/components/ContainerAllocationTable.tsx new file mode 100644 index 000000000..950cfd476 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/ContainerAllocationTable.tsx @@ -0,0 +1,164 @@ +import { useState, useMemo } from "react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { + Box, + Button, + Group, + Loader, + Select, + Stack, + Table, + Text, + Alert, +} from "@mantine/core"; +import { AlertCircle } from "lucide-react"; +import toast from "react-hot-toast"; + +import { vehiclesService } from "@/services/vehicles.service"; + +export interface ContainerAllocationRow { + id: string; + type: string; + qty: number; +} + +export interface ContainerAllocationTableProps { + bookingId: string; + containers: ContainerAllocationRow[]; + onSave: (allocations: Array<{ containerId: string; vehicleId: string }>) => Promise; +} + +/** + * Manual container-to-vehicle allocation table for freight bookings. + * Displays containers with type/qty, vehicle dropdown per row, and save action. + */ +export function ContainerAllocationTable({ + bookingId, + containers, + onSave, +}: ContainerAllocationTableProps) { + const [allocations, setAllocations] = useState>( + () => containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}), + ); + + const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({ + queryKey: ["vehicles", "active"], + queryFn: () => vehiclesService.getAll({ status: "ACTIVE" }), + }); + + const vehicleOptions = useMemo( + () => + vehicles.map((v) => ({ + value: v.id, + label: `${v.plateNumber} (${v.vehicleType})`, + description: `${v.model} · ${v.manufacturer}`, + })), + [vehicles], + ); + + const saveAllocation = useMutation({ + mutationFn: async () => { + const mappings = containers + .filter((c) => allocations[c.id]) + .map((c) => ({ + containerId: c.id, + vehicleId: allocations[c.id]!, + })); + + if (mappings.length === 0) { + throw new Error("No containers allocated to vehicles"); + } + + await onSave(mappings); + }, + onSuccess: () => { + toast.success("Container allocations saved"); + setAllocations( + containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}), + ); + }, + onError: (error) => { + toast.error( + error instanceof Error ? error.message : "Failed to save allocations", + ); + }, + }); + + const allocatedCount = Object.values(allocations).filter(Boolean).length; + const allAllocated = allocatedCount === containers.length; + + if (vehiclesLoading) { + return ( + + + + ); + } + + return ( + + {vehicles.length === 0 && ( + } color="yellow"> + No active vehicles available. Add vehicles before allocating containers. + + )} + + + + + + Container ID + Type + Qty + Assigned Vehicle + + + + {containers.map((container) => ( + + + + {container.id} + + + {container.type} + {container.qty} + +
+
+ + + + {allocatedCount} of {containers.length} containers allocated + + + +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/FirstMileContainerAllocationTable.tsx b/apps/edr-freight-web/backoffice/src/components/FirstMileContainerAllocationTable.tsx new file mode 100644 index 000000000..85bba1dc4 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/FirstMileContainerAllocationTable.tsx @@ -0,0 +1,164 @@ +import { useState, useMemo } from "react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { + Box, + Button, + Group, + Loader, + Select, + Stack, + Table, + Text, + Alert, +} from "@mantine/core"; +import { AlertCircle } from "lucide-react"; +import toast from "react-hot-toast"; + +import { vehiclesService } from "@/services/vehicles.service"; + +export interface ContainerAllocationRow { + id: string; + type: string; + qty: number; +} + +export interface FirstMileContainerAllocationTableProps { + firstMileId: string; + containers: ContainerAllocationRow[]; + onSave: (allocations: Array<{ containerId: string; vehicleId: string }>) => Promise; +} + +/** + * Manual container-to-vehicle allocation table for first-mile pickups. + * Displays containers with type/qty, vehicle dropdown per row, and save action. + */ +export function FirstMileContainerAllocationTable({ + firstMileId, + containers, + onSave, +}: FirstMileContainerAllocationTableProps) { + const [allocations, setAllocations] = useState>( + () => containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}), + ); + + const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({ + queryKey: ["vehicles", "active"], + queryFn: () => vehiclesService.getAll({ status: "ACTIVE" }), + }); + + const vehicleOptions = useMemo( + () => + vehicles.map((v) => ({ + value: v.id, + label: `${v.plateNumber} (${v.vehicleType})`, + description: `${v.model} · ${v.manufacturer}`, + })), + [vehicles], + ); + + const saveAllocation = useMutation({ + mutationFn: async () => { + const mappings = containers + .filter((c) => allocations[c.id]) + .map((c) => ({ + containerId: c.id, + vehicleId: allocations[c.id]!, + })); + + if (mappings.length === 0) { + throw new Error("No containers allocated to vehicles"); + } + + await onSave(mappings); + }, + onSuccess: () => { + toast.success("Container allocations saved"); + setAllocations( + containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}), + ); + }, + onError: (error) => { + toast.error( + error instanceof Error ? error.message : "Failed to save allocations", + ); + }, + }); + + const allocatedCount = Object.values(allocations).filter(Boolean).length; + const allAllocated = allocatedCount === containers.length; + + if (vehiclesLoading) { + return ( + + + + ); + } + + return ( + + {vehicles.length === 0 && ( + } color="yellow"> + No active vehicles available. Add vehicles before allocating containers. + + )} + + + + + + Container ID + Type + Qty + Assigned Vehicle + + + + {containers.map((container) => ( + + + + {container.id} + + + {container.type} + {container.qty} + +
+
+ + + + {allocatedCount} of {containers.length} containers allocated + + + +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/LastMileContainerAllocationTable.tsx b/apps/edr-freight-web/backoffice/src/components/LastMileContainerAllocationTable.tsx new file mode 100644 index 000000000..d11d99a4a --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/LastMileContainerAllocationTable.tsx @@ -0,0 +1,164 @@ +import { useState, useMemo } from "react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { + Box, + Button, + Group, + Loader, + Select, + Stack, + Table, + Text, + Alert, +} from "@mantine/core"; +import { AlertCircle } from "lucide-react"; +import toast from "react-hot-toast"; + +import { vehiclesService } from "@/services/vehicles.service"; + +export interface LastMileContainerRow { + id: string; + type: string; + qty: number; +} + +export interface LastMileContainerAllocationTableProps { + lastMileId: string; + containers: LastMileContainerRow[]; + onSave: (allocations: Array<{ containerId: string; vehicleId: string }>) => Promise; +} + +/** + * Manual container-to-vehicle allocation table for last-mile deliveries. + * Displays containers with type/qty, vehicle dropdown per row, and save action. + */ +export function LastMileContainerAllocationTable({ + lastMileId, + containers, + onSave, +}: LastMileContainerAllocationTableProps) { + const [allocations, setAllocations] = useState>( + () => containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}), + ); + + const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({ + queryKey: ["vehicles", "active"], + queryFn: () => vehiclesService.getAll({ status: "ACTIVE" }), + }); + + const vehicleOptions = useMemo( + () => + vehicles.map((v) => ({ + value: v.id, + label: `${v.plateNumber} (${v.vehicleType})`, + description: `${v.model} · ${v.manufacturer}`, + })), + [vehicles], + ); + + const saveAllocation = useMutation({ + mutationFn: async () => { + const mappings = containers + .filter((c) => allocations[c.id]) + .map((c) => ({ + containerId: c.id, + vehicleId: allocations[c.id]!, + })); + + if (mappings.length === 0) { + throw new Error("No containers allocated to vehicles"); + } + + await onSave(mappings); + }, + onSuccess: () => { + toast.success("Container allocations saved"); + setAllocations( + containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}), + ); + }, + onError: (error) => { + toast.error( + error instanceof Error ? error.message : "Failed to save allocations", + ); + }, + }); + + const allocatedCount = Object.values(allocations).filter(Boolean).length; + const allAllocated = allocatedCount === containers.length; + + if (vehiclesLoading) { + return ( + + + + ); + } + + return ( + + {vehicles.length === 0 && ( + } color="yellow"> + No active vehicles available. Add vehicles before allocating containers. + + )} + + + + + + Container ID + Type + Qty + Assigned Vehicle + + + + {containers.map((container) => ( + + + + {container.id} + + + {container.type} + {container.qty} + +
+
+ + + + {allocatedCount} of {containers.length} containers allocated + + + +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts index 1217b8762..7a7604cc7 100644 --- a/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts +++ b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts @@ -1,6 +1,6 @@ -export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; +// export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; -// export const API_BASE_URL = 'http://localhost:3001'; +export const API_BASE_URL = 'http://localhost:3001'; /** * URL that streams an uploaded file through the API by its UUID. Routes the diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingDetailPage.tsx index 4ecfeebd5..bf01c4818 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingDetailPage.tsx @@ -1,5 +1,7 @@ import { Container, Grid, Stack } from "@mantine/core"; import { useNavigate, useParams } from "react-router-dom"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import toast from "react-hot-toast"; import { BookingApprovalCard, @@ -16,10 +18,26 @@ import { type BookingDetailView, } from "@/components/bookings/detail"; import Breadcrumbs from "@/components/ui/Breadcrumbs"; +import ContainerAllocationTable from "@/components/ContainerAllocationTable"; +import { api } from "@/services/api"; +import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; const BookingDetailPage = () => { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); + const qc = useQueryClient(); + + const allocateMutation = useMutation({ + mutationFn: (data: any) => + api.post(`/bookings/${id}/allocate-containers`, data), + onSuccess: () => { + toast.success("Containers allocated"); + qc.invalidateQueries({ queryKey: QUERY_KEYS.BOOKINGS.byId(id ?? "") }); + }, + onError: () => { + toast.error("Failed to allocate containers"); + }, + }); // Mock data - replace with actual API call const booking: BookingDetailView = { @@ -134,6 +152,17 @@ const BookingDetailPage = () => { + ({ + id: c.id, + type: c.containerType?.label ?? "Unknown", + qty: c.quantity, + }))} + onSave={(allocations) => + allocateMutation.mutateAsync({ allocations }) + } + /> @@ -336,6 +339,9 @@ const FirstMilePage = () => { const [invoiceOpen, setInvoiceOpen] = useState(false); const [invoiceRecord, setInvoiceRecord] = useState(null); + const [containerAllocationOpen, setContainerAllocationOpen] = useState(false); + const [containerAllocationFirstMileId, setContainerAllocationFirstMileId] = useState(null); + const { data: listData, isLoading } = useQuery({ queryKey: QUERY_KEYS.FIRST_MILE.list(), queryFn: async () => { @@ -434,6 +440,19 @@ const FirstMilePage = () => { }, }); + const allocateMutation = useMutation({ + mutationFn: (data) => apiClient.post(`/first-mile/${containerAllocationFirstMileId}/allocate-containers`, data), + onSuccess: () => { + toast({ title: "Containers allocated" }); + void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.detail(containerAllocationFirstMileId ?? "") }); + setContainerAllocationOpen(false); + setContainerAllocationFirstMileId(null); + }, + onError: () => { + toast({ title: "Allocation failed", variant: "destructive" }); + }, + }); + const activeRecord = useMemo( () => records.find((r) => r.id === activeId) ?? null, [records, activeId], @@ -508,6 +527,16 @@ const FirstMilePage = () => { setInvoiceRecord(null); }; + const openContainerAllocation = (firstMileId: string) => { + setContainerAllocationFirstMileId(firstMileId); + setContainerAllocationOpen(true); + }; + + const closeContainerAllocation = () => { + setContainerAllocationOpen(false); + setContainerAllocationFirstMileId(null); + }; + const handleSaveDistance = () => { const distance = parseFloat(distanceValue); if (!activeId || isNaN(distance) || distance < 0) { @@ -1272,6 +1301,56 @@ const FirstMilePage = () => { + + {/* Container Allocation modal */} + Allocate Containers to Vehicles} + size="xl" + radius="lg" + centered + > + + {activeRecord && ( + <> + {/* Capacity guidance */} + {activeRecord.booking?.cargoType?.label === "BULK" ? ( + + + Select multiple containers per vehicle based on capacity. Each vehicle can carry multiple containers if capacity allows. + + + Capacity: TBD — TODO: add vehicle capacity_tons to vehicle API if missing + + + ) : ( + + + One vehicle per container. Each container will be assigned to a single vehicle. + + + )} + + + {/* Container table */} + { + await allocateMutation.mutateAsync(allocations); + }} + /> + + )} + + + + + ); }; diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index 9798a90bf..99a323a6d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -45,6 +45,8 @@ import { } from "@/services/last-mile.service"; import { vehiclesService } from "@/services/vehicles.service"; import { ratesService } from "@/services/rates.service"; +import { LastMileContainerAllocationTable, type LastMileContainerRow } from "@/components/LastMileContainerAllocationTable"; +import { api } from "@/auth/http"; const formatPrice = (amount: number) => `ETB ${amount.toLocaleString("en-US", { @@ -321,6 +323,9 @@ const LastMilePage = () => { const [invoiceOpen, setInvoiceOpen] = useState(false); const [invoiceRecord, setInvoiceRecord] = useState(null); + const [allocationOpen, setAllocationOpen] = useState(false); + const [allocationContainers, setAllocationContainers] = useState([]); + const { data: listData, isLoading } = useQuery({ queryKey: QUERY_KEYS.LAST_MILE.list(), queryFn: async () => { @@ -385,6 +390,19 @@ const LastMilePage = () => { }, }); + const allocateMutation = useMutation({ + mutationFn: (data: Array<{ containerId: string; vehicleId: string }>) => + api.post(`/last-mile/${activeId}/allocate-containers`, data), + onSuccess: () => { + toast({ title: "Containers allocated", variant: "default" }); + void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.detail(activeId ?? "") }); + closeAllocation(); + }, + onError: () => { + toast({ title: "Allocation failed", variant: "destructive" }); + }, + }); + const { data: arrivalQueueData, isLoading: arrivalLoading } = useQuery({ queryKey: ["warehouse-inventory", "arrival-queue"], queryFn: () => warehouseService.arrivalQueue().then((r) => r.data), @@ -477,6 +495,18 @@ const LastMilePage = () => { setInvoiceRecord(null); }; + const openAllocation = (id: string, containers?: LastMileContainerRow[]) => { + setActiveId(id); + setAllocationContainers(containers ?? []); + setAllocationOpen(true); + }; + + const closeAllocation = () => { + setAllocationOpen(false); + setActiveId(null); + setAllocationContainers([]); + }; + const handleSaveDistance = () => { const distance = parseFloat(distanceValue); if (!activeId || isNaN(distance) || distance < 0) { @@ -1243,6 +1273,76 @@ const LastMilePage = () => { + + {/* Container Allocation modal */} + Allocate Containers to Vehicles} + size="xl" + radius="lg" + centered + > + + {activeRecord && ( + <> + + + + + {bookingRef(activeRecord)} + {customerName(activeRecord)} + + + Cargo Type + {activeRecord.booking?.cargoType?.label ?? activeRecord.booking?.cargoType?.name ?? "—"} + + + + + + {/* Capacity logic based on cargo type */} + {activeRecord.booking?.cargoType?.name === "BULK" ? ( + + + + Smart Capacity Allocation + + + Capacity: TBD + + TODO: add vehicle capacity_tons to vehicle API if missing + + + TODO: add container weight to booking if missing + + + + Select multiple containers per vehicle based on capacity + + + + ) : ( + + One vehicle per container + + )} + + )} + + { + await allocateMutation.mutateAsync(mappings); + }} + /> + + + + + + ); }; From c728b249c283259eed15d2871dfe264adc641e2e Mon Sep 17 00:00:00 2001 From: natib21 Date: Mon, 29 Jun 2026 14:48:47 +0000 Subject: [PATCH 2/4] fix --- .../entities/first-mile-container-allocation.entity.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/edr-freight-api/src/modules/first-mile/entities/first-mile-container-allocation.entity.ts b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile-container-allocation.entity.ts index e9407a57f..b0fa54c32 100644 --- a/apps/edr-freight-api/src/modules/first-mile/entities/first-mile-container-allocation.entity.ts +++ b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile-container-allocation.entity.ts @@ -1,5 +1,5 @@ import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; import { FirstMile } from './first-mile.entity'; import { Vehicle } from '../../vehicles/entities/vehicle.entity'; From 9b11eeb623925019cf1c19f7bf0f580800e80c31 Mon Sep 17 00:00:00 2001 From: natib21 Date: Mon, 29 Jun 2026 14:54:34 +0000 Subject: [PATCH 3/4] fix --- .../backoffice/src/pages/operations/FirstMilePage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index 59cd50a21..06487d3ca 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -46,7 +46,7 @@ import { import { bookingsService } from "@/services/bookings.service"; import { vehiclesService } from "@/services/vehicles.service"; import { ratesService } from "@/services/rates.service"; -import { apiClient } from "@/services/api-client"; +import { api } from "@/auth/http"; import type { BookingDetail } from "@/types/booking"; const formatPrice = (amount: number) => From 04bc9eded685b48715bef83ca718face42b64b2f Mon Sep 17 00:00:00 2001 From: natib21 Date: Mon, 29 Jun 2026 15:28:04 +0000 Subject: [PATCH 4/4] fix --- .../src/modules/bookings/bookings.module.ts | 1 - .../first-mile/first-mile-invoice.service.ts | 106 ++++++++++++++++++ .../first-mile/first-mile.controller.ts | 15 ++- .../modules/first-mile/first-mile.module.ts | 7 +- .../last-mile/last-mile-invoice.service.ts | 97 ++++++++++++++++ .../modules/last-mile/last-mile.controller.ts | 15 ++- .../src/modules/last-mile/last-mile.module.ts | 7 +- 7 files changed, 237 insertions(+), 11 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts create mode 100644 apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 0d5c52ee0..5d7e3b2c9 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -20,7 +20,6 @@ import { BookingPricingService } from './booking-pricing.service'; import { BookingReferenceDataService } from './booking-reference-data.service'; import { BookingTransitionService } from './booking-transition.service'; import { BookingsController } from './bookings.controller'; -import { BookingAllocationController } from './booking-allocation.controller'; import { PayController } from './pay.controller'; import { BookingsRepository } from './bookings.repository'; import { ConsolidationService } from './consolidation.service'; diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts new file mode 100644 index 000000000..c63a4c9e1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts @@ -0,0 +1,106 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { Freight } from '@edr/types'; + +import { + BillingService, + InvoiceEventPayload, +} from '../billing/billing.service'; +import { Invoice } from '../billing/entities/invoice.entity'; +import { FirstMileRepository } from './first-mile.repository'; +import { FirstMile } from './entities/first-mile.entity'; + +/** + * Owns the first-mile ⇄ invoice mapping — the one place that knows how a + * first-mile record turns into invoices, which type to use, and how it + * advances when paid. First-mile records are billable entities, so they + * generate their own invoices directly via {@link BillingService}. + */ +@Injectable() +export class FirstMileInvoiceService { + private readonly logger = new Logger(FirstMileInvoiceService.name); + + constructor( + private readonly billing: BillingService, + private readonly firstMileRepo: FirstMileRepository, + ) {} + + /** + * Ensure the first-mile record has its invoice, generating one from the + * remaining payment if absent. Called when a first-mile record reaches a + * billable state. Idempotent — returns the existing open invoice instead + * of a duplicate. Returns `null` (and logs) when the record is not billable: + * no company to bill. + */ + async ensureInvoiceFor(record: FirstMile): Promise { + const existing = await this.billing.findPayable( + 'first_mile' as Freight.InvoiceSource, + record.id, + 'DELIVERY_FEE', + ); + if (existing) return existing; + + if (!record.bookingId) { + this.logger.warn( + `Skipping invoice for first-mile record ${record.id}: no booking to reference.`, + ); + return null; + } + + // Fetch the booking to get the companyId and companyProfileId + const fm = record.booking ? record : (await this.firstMileRepo.findById(record.bookingId, { relations: { booking: true } })); + if (!fm) return null; + if (!fm.booking?.companyId) { + this.logger.warn( + `Skipping invoice for first-mile record ${record.id}: no company to bill.`, + ); + return null; + } + + const totalAmount = record.remainingPayment || 0; + if (!Number.isFinite(totalAmount) || totalAmount <= 0) { + this.logger.warn( + `Skipping invoice for first-mile record ${record.id}: no remaining payment.`, + ); + return null; + } + + return this.billing.generateInvoice({ + source: 'first_mile' as Freight.InvoiceSource, + sourceId: record.id, + type: 'DELIVERY_FEE', + companyId: fm.booking!.companyId, + companyProfileId: fm.booking!.companyProfileId || '', + currency: 'ETB', + lines: [ + { + chargeType: 'DELIVERY', + description: 'First-mile delivery', + quantity: 1, + unitRate: totalAmount, + amount: totalAmount, + }, + ], + totalAmount, + }); + } + + /** + * React to a first-mile invoice being paid — the settlement branch point. + * Mark the first-mile record as having completed post-payment processing. + */ + @OnEvent('first_mile.invoice.paid') + async onPaid(payload: InvoiceEventPayload): Promise { + if (payload.type === 'DELIVERY_FEE') { + const record = await this.firstMileRepo.findById(payload.sourceId); + if (!record) { + this.logger.warn( + `Cannot mark unknown first-mile record ${payload.sourceId} as paid.`, + ); + return; + } + + this.logger.log(`First-mile invoice paid for record ${payload.sourceId}.`); + } + } +} diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts index 3ecc2db20..6bb307a4b 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts @@ -20,13 +20,17 @@ import { UpdateFirstMileDto } from './dto/update-first-mile.dto'; import { AllocateFirstMileContainersDto } from './dto/allocate-containers.dto'; import { FirstMileStatus } from './entities/first-mile.entity'; import { FirstMileService } from './first-mile.service'; +import { FirstMileInvoiceService } from './first-mile-invoice.service'; @ApiTags('first-mile') @ApiBearerAuth() @Controller('first-mile') @TrainSchedulingView() export class FirstMileController { - constructor(private readonly firstMileService: FirstMileService) {} + constructor( + private readonly firstMileService: FirstMileService, + private readonly firstMileInvoiceService: FirstMileInvoiceService, + ) {} @Get() @ApiOperation({ summary: 'List first-mile legs' }) @@ -73,8 +77,13 @@ export class FirstMileController { @Patch(':id') @TrainSchedulingManage() @ApiOperation({ summary: 'Update a first-mile leg' }) - update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateFirstMileDto) { - return this.firstMileService.update(id, dto); + async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateFirstMileDto) { + const record = await this.firstMileService.update(id, dto); + // Auto-generate invoice if distance or payment was updated + if (dto.exactKm !== undefined || dto.remainingPayment !== undefined) { + await this.firstMileInvoiceService.ensureInvoiceFor(record); + } + return record; } @Delete(':id') diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts index 0cdf3c92c..a69c920f1 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts @@ -1,6 +1,7 @@ import { Module, forwardRef } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { BillingModule } from '../billing/billing.module'; import { BookingsModule } from '../bookings/bookings.module'; import { DriversModule } from '../drivers/drivers.module'; import { NotificationsModule } from '../notifications/notifications.module'; @@ -8,19 +9,21 @@ import { VehiclesModule } from '../vehicles/vehicles.module'; import { FirstMile } from './entities/first-mile.entity'; import { FirstMileContainerAllocation } from './entities/first-mile-container-allocation.entity'; import { FirstMileController } from './first-mile.controller'; +import { FirstMileInvoiceService } from './first-mile-invoice.service'; import { FirstMileRepository } from './first-mile.repository'; import { FirstMileService } from './first-mile.service'; @Module({ imports: [ TypeOrmModule.forFeature([FirstMile, FirstMileContainerAllocation]), + BillingModule, forwardRef(() => BookingsModule), VehiclesModule, DriversModule, NotificationsModule, ], controllers: [FirstMileController], - providers: [FirstMileRepository, FirstMileService], - exports: [FirstMileRepository, FirstMileService], + providers: [FirstMileRepository, FirstMileService, FirstMileInvoiceService], + exports: [FirstMileRepository, FirstMileService, FirstMileInvoiceService], }) export class FirstMileModule {} diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts new file mode 100644 index 000000000..c304a89e8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts @@ -0,0 +1,97 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { Freight } from '@edr/types'; + +import { + BillingService, + GenerateInvoiceInput, + InvoiceEventPayload, +} from '../billing/billing.service'; +import { Invoice } from '../billing/entities/invoice.entity'; +import { LastMileRepository } from './last-mile.repository'; +import { LastMile } from './entities/last-mile.entity'; + +/** + * Owns the last-mile ⇄ invoice mapping — the one place that knows how a last-mile + * record turns into invoices, which type to use, and how it advances when paid. + * Last-mile records are billable business entities for delivery fees, so they + * generate their own invoices directly via {@link BillingService}. All last-mile-specific + * type branching lives here, at the two points it belongs: invoice creation and + * settlement (the paid handler). + */ +@Injectable() +export class LastMileInvoiceService { + private readonly logger = new Logger(LastMileInvoiceService.name); + + constructor( + private readonly billing: BillingService, + private readonly lastMileRepo: LastMileRepository, + ) {} + + /** + * Ensure the last-mile record has its invoice, generating one from the + * remainingPayment if absent. Called when a last-mile record reaches a + * billable state. Idempotent — returns the existing open invoice instead + * of a duplicate. Returns `null` (and logs) when the record is not billable: + * no company to bill (invoices FK requires a companyId). + */ + async ensureInvoiceFor(record: LastMile): Promise { + // Check if invoice already exists + const existing = await this.billing.findPayable( + 'last_mile' as Freight.InvoiceSource, + record.id, + 'DELIVERY_FEE', + ); + if (existing) return existing; + + // Can't bill without company + const lm = record.booking ? record : (await this.lastMileRepo.findById(record.id, { relations: { booking: true } })); + if (!lm) return null; + if (!lm.booking?.companyId) { + this.logger.warn( + `Skipping invoice for last-mile record ${record.id}: no company to bill.`, + ); + return null; + } + + // Generate invoice with remainingPayment as totalAmount + const input: GenerateInvoiceInput = { + source: 'last_mile' as Freight.InvoiceSource, + sourceId: record.id, + type: 'DELIVERY_FEE', + companyId: lm.booking!.companyId, + companyProfileId: lm.booking!.companyProfileId || '', + currency: 'ETB', + lines: [ + { + chargeType: 'DELIVERY', + description: 'Last-mile delivery', + quantity: 1, + unitRate: record.remainingPayment || 0, + amount: record.remainingPayment || 0, + }, + ], + totalAmount: record.remainingPayment || 0, + }; + + return this.billing.generateInvoice(input); + } + + /** + * React to a last-mile invoice being paid — the settlement branch point. + * Advances the last-mile record to mark post-payment as completed. + */ + @OnEvent('last_mile.invoice.paid') + async onPaid(payload: InvoiceEventPayload): Promise { + if (payload.type === 'DELIVERY_FEE') { + const record = await this.lastMileRepo.findById(payload.sourceId); + if (record) { + this.logger.log(`Last-mile invoice paid for record ${payload.sourceId}.`); + } else { + this.logger.warn( + `Cannot mark last-mile record ${payload.sourceId} as paid: not found.`, + ); + } + } + } +} diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts index 935aa5ac7..929d97a3e 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts @@ -20,13 +20,17 @@ import { UpdateLastMileDto } from './dto/update-last-mile.dto'; import { AllocateLastMileContainersDto } from './dto/allocate-containers.dto'; import { LastMileStatus } from './entities/last-mile.entity'; import { LastMileService } from './last-mile.service'; +import { LastMileInvoiceService } from './last-mile-invoice.service'; @ApiTags('last-mile') @ApiBearerAuth() @Controller('last-mile') @TrainSchedulingView() export class LastMileController { - constructor(private readonly lastMileService: LastMileService) {} + constructor( + private readonly lastMileService: LastMileService, + private readonly lastMileInvoiceService: LastMileInvoiceService, + ) {} @Get() @ApiOperation({ summary: 'List last-mile legs' }) @@ -73,8 +77,13 @@ export class LastMileController { @Patch(':id') @TrainSchedulingManage() @ApiOperation({ summary: 'Update a last-mile leg' }) - update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLastMileDto) { - return this.lastMileService.update(id, dto); + async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLastMileDto) { + const record = await this.lastMileService.update(id, dto); + // Auto-generate invoice if distance or payment was updated + if (dto.exactKm !== undefined || dto.remainingPayment !== undefined) { + await this.lastMileInvoiceService.ensureInvoiceFor(record); + } + return record; } @Delete(':id') diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts index e6ed6634d..32b688069 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts @@ -1,6 +1,7 @@ import { Module, forwardRef } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { BillingModule } from '../billing/billing.module'; import { BookingsModule } from '../bookings/bookings.module'; import { DriversModule } from '../drivers/drivers.module'; import { NotificationsModule } from '../notifications/notifications.module'; @@ -8,19 +9,21 @@ import { VehiclesModule } from '../vehicles/vehicles.module'; import { LastMile } from './entities/last-mile.entity'; import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity'; import { LastMileController } from './last-mile.controller'; +import { LastMileInvoiceService } from './last-mile-invoice.service'; import { LastMileRepository } from './last-mile.repository'; import { LastMileService } from './last-mile.service'; @Module({ imports: [ TypeOrmModule.forFeature([LastMile, LastMileContainerAllocation]), + BillingModule, forwardRef(() => BookingsModule), VehiclesModule, DriversModule, NotificationsModule, ], controllers: [LastMileController], - providers: [LastMileRepository, LastMileService], - exports: [LastMileRepository, LastMileService], + providers: [LastMileRepository, LastMileService, LastMileInvoiceService], + exports: [LastMileRepository, LastMileService, LastMileInvoiceService], }) export class LastMileModule {}