mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 01:48:12 +00:00
Merge pull request #349 from Tria-plc/freight/feature/last_mile_invoice
Freight/feature/last mile invoice
This commit is contained in:
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
const exists = await queryRunner.hasTable('freight.last_mile_container_allocations');
|
||||||
|
if (exists) {
|
||||||
|
await queryRunner.dropTable('freight.last_mile_container_allocations');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
const exists = await queryRunner.hasTable('freight.booking_container_allocations');
|
||||||
|
if (exists) {
|
||||||
|
await queryRunner.dropTable('freight.booking_container_allocations');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
const exists = await queryRunner.hasTable('freight.first_mile_container_allocations');
|
||||||
|
if (exists) {
|
||||||
|
await queryRunner.dropTable('freight.first_mile_container_allocations');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
|
|||||||
import { BookingContractSignature } from './entities/booking-contract-signature.entity';
|
import { BookingContractSignature } from './entities/booking-contract-signature.entity';
|
||||||
import { BookingReviewNote } from './entities/booking-review-note.entity';
|
import { BookingReviewNote } from './entities/booking-review-note.entity';
|
||||||
import { Booking } from './entities/booking.entity';
|
import { Booking } from './entities/booking.entity';
|
||||||
|
import { BookingContainerAllocation } from './entities/booking-container-allocation.entity';
|
||||||
import { ContractPdfService } from '../../contracts/contract-pdf.service';
|
import { ContractPdfService } from '../../contracts/contract-pdf.service';
|
||||||
import { ContractPricingScheduleBuilder } from '../../contracts/contract-pricing-schedule.builder';
|
import { ContractPricingScheduleBuilder } from '../../contracts/contract-pricing-schedule.builder';
|
||||||
import { ContractRendererService } from '../../contracts/contract-renderer.service';
|
import { ContractRendererService } from '../../contracts/contract-renderer.service';
|
||||||
@@ -50,6 +51,7 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
|
|||||||
BookingRateSnapshot,
|
BookingRateSnapshot,
|
||||||
BookingReviewNote,
|
BookingReviewNote,
|
||||||
BookingContractSignature,
|
BookingContractSignature,
|
||||||
|
BookingContainerAllocation,
|
||||||
]),
|
]),
|
||||||
BillingModule,
|
BillingModule,
|
||||||
forwardRef(() => FirstMileModule),
|
forwardRef(() => FirstMileModule),
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import {
|
|||||||
FreightType,
|
FreightType,
|
||||||
} from './entities/booking.entity';
|
} from './entities/booking.entity';
|
||||||
import { Booking } 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';
|
import { FileRecord } from '../files/entities/file.entity';
|
||||||
|
|
||||||
/** Paginated booking list: flat `total` (backoffice) + `meta` block (portal). */
|
/** Paginated booking list: flat `total` (backoffice) + `meta` block (portal). */
|
||||||
@@ -1337,4 +1338,35 @@ export class BookingsService {
|
|||||||
createdAt: b.createdAt,
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export class ContainerAllocationDto {
|
||||||
|
containerId!: string;
|
||||||
|
vehicleId!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AllocateContainersDto {
|
||||||
|
allocations!: ContainerAllocationDto[];
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import { FileRecord } from '../../files/entities/file.entity';
|
|||||||
import { BookingApprovalStep } from './booking-approval-step.entity';
|
import { BookingApprovalStep } from './booking-approval-step.entity';
|
||||||
import { BookingCargoModifier } from './booking-cargo-modifier.entity';
|
import { BookingCargoModifier } from './booking-cargo-modifier.entity';
|
||||||
import { BookingContainer } from './booking-container.entity';
|
import { BookingContainer } from './booking-container.entity';
|
||||||
|
import { BookingContainerAllocation } from './booking-container-allocation.entity';
|
||||||
import { BookingRateSnapshot } from './booking-rate-snapshot.entity';
|
import { BookingRateSnapshot } from './booking-rate-snapshot.entity';
|
||||||
import { BookingReviewNote } from './booking-review-note.entity';
|
import { BookingReviewNote } from './booking-review-note.entity';
|
||||||
|
|
||||||
@@ -444,6 +445,9 @@ export class Booking extends BaseEntity {
|
|||||||
@OneToMany(() => BookingContainer, (bc) => bc.booking)
|
@OneToMany(() => BookingContainer, (bc) => bc.booking)
|
||||||
bookingContainers?: BookingContainer[];
|
bookingContainers?: BookingContainer[];
|
||||||
|
|
||||||
|
@OneToMany(() => BookingContainerAllocation, (ca) => ca.booking)
|
||||||
|
containerAllocations?: BookingContainerAllocation[];
|
||||||
|
|
||||||
@OneToMany(() => BookingCargoModifier, (m) => m.booking)
|
@OneToMany(() => BookingCargoModifier, (m) => m.booking)
|
||||||
cargoModifiers?: BookingCargoModifier[];
|
cargoModifiers?: BookingCargoModifier[];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export class FirstMileContainerAllocationDto {
|
||||||
|
containerId!: string;
|
||||||
|
vehicleId!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AllocateFirstMileContainersDto {
|
||||||
|
allocations!: FirstMileContainerAllocationDto[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { Column, Entity, Index, JoinColumn, ManyToOne } 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;
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { BaseEntity } from '@edr/api-common';
|
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 { Booking } from '../../bookings/entities/booking.entity';
|
||||||
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
|
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
|
||||||
|
import { FirstMileContainerAllocation } from './first-mile-container-allocation.entity';
|
||||||
|
|
||||||
export const FIRST_MILE_STATUSES = [
|
export const FIRST_MILE_STATUSES = [
|
||||||
'PAYMENT_PENDING',
|
'PAYMENT_PENDING',
|
||||||
@@ -49,4 +50,11 @@ export class FirstMile extends BaseEntity {
|
|||||||
@ManyToOne(() => Vehicle, { nullable: true, eager: false })
|
@ManyToOne(() => Vehicle, { nullable: true, eager: false })
|
||||||
@JoinColumn({ name: 'vehicle_id' })
|
@JoinColumn({ name: 'vehicle_id' })
|
||||||
vehicle?: Vehicle | null;
|
vehicle?: Vehicle | null;
|
||||||
|
|
||||||
|
@OneToMany(
|
||||||
|
() => FirstMileContainerAllocation,
|
||||||
|
(containerAllocation) => containerAllocation.firstMile,
|
||||||
|
{ eager: false },
|
||||||
|
)
|
||||||
|
containerAllocations!: FirstMileContainerAllocation[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Invoice | null> {
|
||||||
|
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<void> {
|
||||||
|
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}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,15 +17,20 @@ import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking
|
|||||||
|
|
||||||
import { CreateFirstMileDto } from './dto/create-first-mile.dto';
|
import { CreateFirstMileDto } from './dto/create-first-mile.dto';
|
||||||
import { UpdateFirstMileDto } from './dto/update-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 { FirstMileStatus } from './entities/first-mile.entity';
|
||||||
import { FirstMileService } from './first-mile.service';
|
import { FirstMileService } from './first-mile.service';
|
||||||
|
import { FirstMileInvoiceService } from './first-mile-invoice.service';
|
||||||
|
|
||||||
@ApiTags('first-mile')
|
@ApiTags('first-mile')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Controller('first-mile')
|
@Controller('first-mile')
|
||||||
@TrainSchedulingView()
|
@TrainSchedulingView()
|
||||||
export class FirstMileController {
|
export class FirstMileController {
|
||||||
constructor(private readonly firstMileService: FirstMileService) {}
|
constructor(
|
||||||
|
private readonly firstMileService: FirstMileService,
|
||||||
|
private readonly firstMileInvoiceService: FirstMileInvoiceService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: 'List first-mile legs' })
|
@ApiOperation({ summary: 'List first-mile legs' })
|
||||||
@@ -72,8 +77,13 @@ export class FirstMileController {
|
|||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingManage()
|
||||||
@ApiOperation({ summary: 'Update a first-mile leg' })
|
@ApiOperation({ summary: 'Update a first-mile leg' })
|
||||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateFirstMileDto) {
|
async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateFirstMileDto) {
|
||||||
return this.firstMileService.update(id, dto);
|
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')
|
@Delete(':id')
|
||||||
@@ -83,4 +93,14 @@ export class FirstMileController {
|
|||||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.firstMileService.remove(id);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,29 @@
|
|||||||
import { Module, forwardRef } from '@nestjs/common';
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
|
||||||
|
import { BillingModule } from '../billing/billing.module';
|
||||||
import { BookingsModule } from '../bookings/bookings.module';
|
import { BookingsModule } from '../bookings/bookings.module';
|
||||||
import { DriversModule } from '../drivers/drivers.module';
|
import { DriversModule } from '../drivers/drivers.module';
|
||||||
import { NotificationsModule } from '../notifications/notifications.module';
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
import { VehiclesModule } from '../vehicles/vehicles.module';
|
import { VehiclesModule } from '../vehicles/vehicles.module';
|
||||||
import { FirstMile } from './entities/first-mile.entity';
|
import { FirstMile } from './entities/first-mile.entity';
|
||||||
|
import { FirstMileContainerAllocation } from './entities/first-mile-container-allocation.entity';
|
||||||
import { FirstMileController } from './first-mile.controller';
|
import { FirstMileController } from './first-mile.controller';
|
||||||
|
import { FirstMileInvoiceService } from './first-mile-invoice.service';
|
||||||
import { FirstMileRepository } from './first-mile.repository';
|
import { FirstMileRepository } from './first-mile.repository';
|
||||||
import { FirstMileService } from './first-mile.service';
|
import { FirstMileService } from './first-mile.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([FirstMile]),
|
TypeOrmModule.forFeature([FirstMile, FirstMileContainerAllocation]),
|
||||||
|
BillingModule,
|
||||||
forwardRef(() => BookingsModule),
|
forwardRef(() => BookingsModule),
|
||||||
VehiclesModule,
|
VehiclesModule,
|
||||||
DriversModule,
|
DriversModule,
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
],
|
],
|
||||||
controllers: [FirstMileController],
|
controllers: [FirstMileController],
|
||||||
providers: [FirstMileRepository, FirstMileService],
|
providers: [FirstMileRepository, FirstMileService, FirstMileInvoiceService],
|
||||||
exports: [FirstMileRepository, FirstMileService],
|
exports: [FirstMileRepository, FirstMileService, FirstMileInvoiceService],
|
||||||
})
|
})
|
||||||
export class FirstMileModule {}
|
export class FirstMileModule {}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
import { FindOptionsWhere } from 'typeorm';
|
import { FindOptionsWhere } from 'typeorm';
|
||||||
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||||
import { DriversService } from '../drivers/drivers.service';
|
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 { CreateFirstMileDto } from './dto/create-first-mile.dto';
|
||||||
import { UpdateFirstMileDto } from './dto/update-first-mile.dto';
|
import { UpdateFirstMileDto } from './dto/update-first-mile.dto';
|
||||||
import { FirstMile, FirstMileStatus } from './entities/first-mile.entity';
|
import { FirstMile, FirstMileStatus } from './entities/first-mile.entity';
|
||||||
|
import { FirstMileContainerAllocation } from './entities/first-mile-container-allocation.entity';
|
||||||
import { FirstMileRepository } from './first-mile.repository';
|
import { FirstMileRepository } from './first-mile.repository';
|
||||||
|
|
||||||
type FirstMileListFilter = {
|
type FirstMileListFilter = {
|
||||||
@@ -32,6 +35,7 @@ export class FirstMileService {
|
|||||||
private readonly logger = new Logger(FirstMileService.name);
|
private readonly logger = new Logger(FirstMileService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
@InjectDataSource() private readonly dataSource: DataSource,
|
||||||
private readonly firstMileRepository: FirstMileRepository,
|
private readonly firstMileRepository: FirstMileRepository,
|
||||||
private readonly bookingsRepository: BookingsRepository,
|
private readonly bookingsRepository: BookingsRepository,
|
||||||
private readonly vehiclesService: VehiclesService,
|
private readonly vehiclesService: VehiclesService,
|
||||||
@@ -273,4 +277,35 @@ export class FirstMileService {
|
|||||||
await this.findById(id);
|
await this.findById(id);
|
||||||
await this.firstMileRepository.softDelete(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,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export class LastMileContainerAllocationDto {
|
||||||
|
containerId!: string;
|
||||||
|
vehicleId!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AllocateLastMileContainersDto {
|
||||||
|
allocations!: LastMileContainerAllocationDto[];
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { BaseEntity } from '@edr/api-common';
|
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 { Booking } from '../../bookings/entities/booking.entity';
|
||||||
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
|
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
|
||||||
|
import { LastMileContainerAllocation } from './last-mile-container-allocation.entity';
|
||||||
|
|
||||||
export const LAST_MILE_STATUSES = [
|
export const LAST_MILE_STATUSES = [
|
||||||
'PAYMENT_PENDING',
|
'PAYMENT_PENDING',
|
||||||
@@ -49,4 +50,7 @@ export class LastMile extends BaseEntity {
|
|||||||
@ManyToOne(() => Vehicle, { nullable: true, eager: false })
|
@ManyToOne(() => Vehicle, { nullable: true, eager: false })
|
||||||
@JoinColumn({ name: 'vehicle_id' })
|
@JoinColumn({ name: 'vehicle_id' })
|
||||||
vehicle?: Vehicle | null;
|
vehicle?: Vehicle | null;
|
||||||
|
|
||||||
|
@OneToMany(() => LastMileContainerAllocation, (ca) => ca.lastMile)
|
||||||
|
containerAllocations?: LastMileContainerAllocation[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Invoice | null> {
|
||||||
|
// 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<void> {
|
||||||
|
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.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,15 +17,20 @@ import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking
|
|||||||
|
|
||||||
import { CreateLastMileDto } from './dto/create-last-mile.dto';
|
import { CreateLastMileDto } from './dto/create-last-mile.dto';
|
||||||
import { UpdateLastMileDto } from './dto/update-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 { LastMileStatus } from './entities/last-mile.entity';
|
||||||
import { LastMileService } from './last-mile.service';
|
import { LastMileService } from './last-mile.service';
|
||||||
|
import { LastMileInvoiceService } from './last-mile-invoice.service';
|
||||||
|
|
||||||
@ApiTags('last-mile')
|
@ApiTags('last-mile')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Controller('last-mile')
|
@Controller('last-mile')
|
||||||
@TrainSchedulingView()
|
@TrainSchedulingView()
|
||||||
export class LastMileController {
|
export class LastMileController {
|
||||||
constructor(private readonly lastMileService: LastMileService) {}
|
constructor(
|
||||||
|
private readonly lastMileService: LastMileService,
|
||||||
|
private readonly lastMileInvoiceService: LastMileInvoiceService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: 'List last-mile legs' })
|
@ApiOperation({ summary: 'List last-mile legs' })
|
||||||
@@ -72,8 +77,13 @@ export class LastMileController {
|
|||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingManage()
|
||||||
@ApiOperation({ summary: 'Update a last-mile leg' })
|
@ApiOperation({ summary: 'Update a last-mile leg' })
|
||||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLastMileDto) {
|
async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLastMileDto) {
|
||||||
return this.lastMileService.update(id, dto);
|
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')
|
@Delete(':id')
|
||||||
@@ -83,4 +93,14 @@ export class LastMileController {
|
|||||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.lastMileService.remove(id);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,29 @@
|
|||||||
import { Module, forwardRef } from '@nestjs/common';
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
|
||||||
|
import { BillingModule } from '../billing/billing.module';
|
||||||
import { BookingsModule } from '../bookings/bookings.module';
|
import { BookingsModule } from '../bookings/bookings.module';
|
||||||
import { DriversModule } from '../drivers/drivers.module';
|
import { DriversModule } from '../drivers/drivers.module';
|
||||||
import { NotificationsModule } from '../notifications/notifications.module';
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
import { VehiclesModule } from '../vehicles/vehicles.module';
|
import { VehiclesModule } from '../vehicles/vehicles.module';
|
||||||
import { LastMile } from './entities/last-mile.entity';
|
import { LastMile } from './entities/last-mile.entity';
|
||||||
|
import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity';
|
||||||
import { LastMileController } from './last-mile.controller';
|
import { LastMileController } from './last-mile.controller';
|
||||||
|
import { LastMileInvoiceService } from './last-mile-invoice.service';
|
||||||
import { LastMileRepository } from './last-mile.repository';
|
import { LastMileRepository } from './last-mile.repository';
|
||||||
import { LastMileService } from './last-mile.service';
|
import { LastMileService } from './last-mile.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([LastMile]),
|
TypeOrmModule.forFeature([LastMile, LastMileContainerAllocation]),
|
||||||
|
BillingModule,
|
||||||
forwardRef(() => BookingsModule),
|
forwardRef(() => BookingsModule),
|
||||||
VehiclesModule,
|
VehiclesModule,
|
||||||
DriversModule,
|
DriversModule,
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
],
|
],
|
||||||
controllers: [LastMileController],
|
controllers: [LastMileController],
|
||||||
providers: [LastMileRepository, LastMileService],
|
providers: [LastMileRepository, LastMileService, LastMileInvoiceService],
|
||||||
exports: [LastMileRepository, LastMileService],
|
exports: [LastMileRepository, LastMileService, LastMileInvoiceService],
|
||||||
})
|
})
|
||||||
export class LastMileModule {}
|
export class LastMileModule {}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
import { FindOptionsWhere } from 'typeorm';
|
import { DataSource, FindOptionsWhere } from 'typeorm';
|
||||||
|
|
||||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||||
import { DriversService } from '../drivers/drivers.service';
|
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 { CreateLastMileDto } from './dto/create-last-mile.dto';
|
||||||
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
|
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
|
||||||
import { LastMile, LastMileStatus } from './entities/last-mile.entity';
|
import { LastMile, LastMileStatus } from './entities/last-mile.entity';
|
||||||
|
import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity';
|
||||||
import { LastMileRepository } from './last-mile.repository';
|
import { LastMileRepository } from './last-mile.repository';
|
||||||
|
|
||||||
type LastMileListFilter = {
|
type LastMileListFilter = {
|
||||||
@@ -37,6 +38,7 @@ export class LastMileService {
|
|||||||
private readonly vehiclesService: VehiclesService,
|
private readonly vehiclesService: VehiclesService,
|
||||||
private readonly driversService: DriversService,
|
private readonly driversService: DriversService,
|
||||||
private readonly smsClient: SmsClientService,
|
private readonly smsClient: SmsClientService,
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async acceptBooking(bookingReference: string): Promise<LastMile | null> {
|
async acceptBooking(bookingReference: string): Promise<LastMile | null> {
|
||||||
@@ -206,4 +208,35 @@ export class LastMileService {
|
|||||||
await this.findById(id);
|
await this.findById(id);
|
||||||
await this.lastMileRepository.softDelete(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,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<Record<string, string | null>>(
|
||||||
|
() => 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 (
|
||||||
|
<Box display="flex" justifyContent="center" p="xl">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
{vehicles.length === 0 && (
|
||||||
|
<Alert icon={<AlertCircle size={16} />} color="yellow">
|
||||||
|
No active vehicles available. Add vehicles before allocating containers.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box style={{ overflowX: "auto" }}>
|
||||||
|
<Table verticalSpacing="md" horizontalSpacing="md" highlightOnHover>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Container ID</Table.Th>
|
||||||
|
<Table.Th>Type</Table.Th>
|
||||||
|
<Table.Th>Qty</Table.Th>
|
||||||
|
<Table.Th>Assigned Vehicle</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{containers.map((container) => (
|
||||||
|
<Table.Tr key={container.id}>
|
||||||
|
<Table.Td>
|
||||||
|
<Text fw={600} size="sm">
|
||||||
|
{container.id}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>{container.type}</Table.Td>
|
||||||
|
<Table.Td>{container.qty}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Select
|
||||||
|
placeholder="Select vehicle"
|
||||||
|
data={vehicleOptions}
|
||||||
|
value={allocations[container.id] ?? null}
|
||||||
|
onChange={(value) =>
|
||||||
|
setAllocations((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[container.id]: value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
searchable
|
||||||
|
clearable
|
||||||
|
disabled={vehicles.length === 0}
|
||||||
|
style={{ minWidth: 200 }}
|
||||||
|
/>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{allocatedCount} of {containers.length} containers allocated
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
loading={saveAllocation.isPending}
|
||||||
|
disabled={allocatedCount === 0 || vehicles.length === 0}
|
||||||
|
onClick={() => saveAllocation.mutate()}
|
||||||
|
>
|
||||||
|
Save Allocations
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<Record<string, string | null>>(
|
||||||
|
() => 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 (
|
||||||
|
<Box display="flex" justifyContent="center" p="xl">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
{vehicles.length === 0 && (
|
||||||
|
<Alert icon={<AlertCircle size={16} />} color="yellow">
|
||||||
|
No active vehicles available. Add vehicles before allocating containers.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box style={{ overflowX: "auto" }}>
|
||||||
|
<Table verticalSpacing="md" horizontalSpacing="md" highlightOnHover>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Container ID</Table.Th>
|
||||||
|
<Table.Th>Type</Table.Th>
|
||||||
|
<Table.Th>Qty</Table.Th>
|
||||||
|
<Table.Th>Assigned Vehicle</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{containers.map((container) => (
|
||||||
|
<Table.Tr key={container.id}>
|
||||||
|
<Table.Td>
|
||||||
|
<Text fw={600} size="sm">
|
||||||
|
{container.id}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>{container.type}</Table.Td>
|
||||||
|
<Table.Td>{container.qty}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Select
|
||||||
|
placeholder="Select vehicle"
|
||||||
|
data={vehicleOptions}
|
||||||
|
value={allocations[container.id] ?? null}
|
||||||
|
onChange={(value) =>
|
||||||
|
setAllocations((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[container.id]: value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
searchable
|
||||||
|
clearable
|
||||||
|
disabled={vehicles.length === 0}
|
||||||
|
style={{ minWidth: 200 }}
|
||||||
|
/>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{allocatedCount} of {containers.length} containers allocated
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
loading={saveAllocation.isPending}
|
||||||
|
disabled={allocatedCount === 0 || vehicles.length === 0}
|
||||||
|
onClick={() => saveAllocation.mutate()}
|
||||||
|
>
|
||||||
|
Save Allocations
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<Record<string, string | null>>(
|
||||||
|
() => 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 (
|
||||||
|
<Box display="flex" justifyContent="center" p="xl">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
{vehicles.length === 0 && (
|
||||||
|
<Alert icon={<AlertCircle size={16} />} color="yellow">
|
||||||
|
No active vehicles available. Add vehicles before allocating containers.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box style={{ overflowX: "auto" }}>
|
||||||
|
<Table verticalSpacing="md" horizontalSpacing="md" highlightOnHover>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Container ID</Table.Th>
|
||||||
|
<Table.Th>Type</Table.Th>
|
||||||
|
<Table.Th>Qty</Table.Th>
|
||||||
|
<Table.Th>Assigned Vehicle</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{containers.map((container) => (
|
||||||
|
<Table.Tr key={container.id}>
|
||||||
|
<Table.Td>
|
||||||
|
<Text fw={600} size="sm">
|
||||||
|
{container.id}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>{container.type}</Table.Td>
|
||||||
|
<Table.Td>{container.qty}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Select
|
||||||
|
placeholder="Select vehicle"
|
||||||
|
data={vehicleOptions}
|
||||||
|
value={allocations[container.id] ?? null}
|
||||||
|
onChange={(value) =>
|
||||||
|
setAllocations((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[container.id]: value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
searchable
|
||||||
|
clearable
|
||||||
|
disabled={vehicles.length === 0}
|
||||||
|
style={{ minWidth: 200 }}
|
||||||
|
/>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{allocatedCount} of {containers.length} containers allocated
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
loading={saveAllocation.isPending}
|
||||||
|
disabled={allocatedCount === 0 || vehicles.length === 0}
|
||||||
|
onClick={() => saveAllocation.mutate()}
|
||||||
|
>
|
||||||
|
Save Allocations
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
* URL that streams an uploaded file through the API by its UUID. Routes the
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Container, Grid, Stack } from "@mantine/core";
|
import { Container, Grid, Stack } from "@mantine/core";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BookingApprovalCard,
|
BookingApprovalCard,
|
||||||
@@ -16,10 +18,26 @@ import {
|
|||||||
type BookingDetailView,
|
type BookingDetailView,
|
||||||
} from "@/components/bookings/detail";
|
} from "@/components/bookings/detail";
|
||||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
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 BookingDetailPage = () => {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
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
|
// Mock data - replace with actual API call
|
||||||
const booking: BookingDetailView = {
|
const booking: BookingDetailView = {
|
||||||
@@ -134,6 +152,17 @@ const BookingDetailPage = () => {
|
|||||||
<BookingContainersCard
|
<BookingContainersCard
|
||||||
containers={booking.bookingContainers ?? []}
|
containers={booking.bookingContainers ?? []}
|
||||||
/>
|
/>
|
||||||
|
<ContainerAllocationTable
|
||||||
|
bookingId={booking.id}
|
||||||
|
containers={(booking.bookingContainers ?? []).map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
type: c.containerType?.label ?? "Unknown",
|
||||||
|
qty: c.quantity,
|
||||||
|
}))}
|
||||||
|
onSave={(allocations) =>
|
||||||
|
allocateMutation.mutateAsync({ allocations })
|
||||||
|
}
|
||||||
|
/>
|
||||||
<BookingApprovalCard
|
<BookingApprovalCard
|
||||||
steps={approvalSteps}
|
steps={approvalSteps}
|
||||||
approvedCount={approvedCount}
|
approvedCount={approvedCount}
|
||||||
|
|||||||
@@ -30,9 +30,11 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
UnstyledButton,
|
UnstyledButton,
|
||||||
|
Alert,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
|
||||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||||
|
import { FirstMileContainerAllocationTable } from "@/components/FirstMileContainerAllocationTable";
|
||||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import {
|
import {
|
||||||
@@ -44,6 +46,7 @@ import {
|
|||||||
import { bookingsService } from "@/services/bookings.service";
|
import { bookingsService } from "@/services/bookings.service";
|
||||||
import { vehiclesService } from "@/services/vehicles.service";
|
import { vehiclesService } from "@/services/vehicles.service";
|
||||||
import { ratesService } from "@/services/rates.service";
|
import { ratesService } from "@/services/rates.service";
|
||||||
|
import { api } from "@/auth/http";
|
||||||
import type { BookingDetail } from "@/types/booking";
|
import type { BookingDetail } from "@/types/booking";
|
||||||
|
|
||||||
const formatPrice = (amount: number) =>
|
const formatPrice = (amount: number) =>
|
||||||
@@ -336,6 +339,9 @@ const FirstMilePage = () => {
|
|||||||
const [invoiceOpen, setInvoiceOpen] = useState(false);
|
const [invoiceOpen, setInvoiceOpen] = useState(false);
|
||||||
const [invoiceRecord, setInvoiceRecord] = useState<FirstMileRecord | null>(null);
|
const [invoiceRecord, setInvoiceRecord] = useState<FirstMileRecord | null>(null);
|
||||||
|
|
||||||
|
const [containerAllocationOpen, setContainerAllocationOpen] = useState(false);
|
||||||
|
const [containerAllocationFirstMileId, setContainerAllocationFirstMileId] = useState<string | null>(null);
|
||||||
|
|
||||||
const { data: listData, isLoading } = useQuery({
|
const { data: listData, isLoading } = useQuery({
|
||||||
queryKey: QUERY_KEYS.FIRST_MILE.list(),
|
queryKey: QUERY_KEYS.FIRST_MILE.list(),
|
||||||
queryFn: async () => {
|
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(
|
const activeRecord = useMemo(
|
||||||
() => records.find((r) => r.id === activeId) ?? null,
|
() => records.find((r) => r.id === activeId) ?? null,
|
||||||
[records, activeId],
|
[records, activeId],
|
||||||
@@ -508,6 +527,16 @@ const FirstMilePage = () => {
|
|||||||
setInvoiceRecord(null);
|
setInvoiceRecord(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openContainerAllocation = (firstMileId: string) => {
|
||||||
|
setContainerAllocationFirstMileId(firstMileId);
|
||||||
|
setContainerAllocationOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeContainerAllocation = () => {
|
||||||
|
setContainerAllocationOpen(false);
|
||||||
|
setContainerAllocationFirstMileId(null);
|
||||||
|
};
|
||||||
|
|
||||||
const handleSaveDistance = () => {
|
const handleSaveDistance = () => {
|
||||||
const distance = parseFloat(distanceValue);
|
const distance = parseFloat(distanceValue);
|
||||||
if (!activeId || isNaN(distance) || distance < 0) {
|
if (!activeId || isNaN(distance) || distance < 0) {
|
||||||
@@ -1272,6 +1301,56 @@ const FirstMilePage = () => {
|
|||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* Container Allocation modal */}
|
||||||
|
<Modal
|
||||||
|
opened={containerAllocationOpen}
|
||||||
|
onClose={closeContainerAllocation}
|
||||||
|
title={<Text fw={600}>Allocate Containers to Vehicles</Text>}
|
||||||
|
size="xl"
|
||||||
|
radius="lg"
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
{activeRecord && (
|
||||||
|
<>
|
||||||
|
{/* Capacity guidance */}
|
||||||
|
{activeRecord.booking?.cargoType?.label === "BULK" ? (
|
||||||
|
<Alert color="blue" title="Bulk Cargo Allocation">
|
||||||
|
<Text size="sm">
|
||||||
|
Select multiple containers per vehicle based on capacity. Each vehicle can carry multiple containers if capacity allows.
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" mt="xs">
|
||||||
|
Capacity: TBD — TODO: add vehicle capacity_tons to vehicle API if missing
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<Alert color="blue">
|
||||||
|
<Text size="sm">
|
||||||
|
One vehicle per container. Each container will be assigned to a single vehicle.
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{/* Container table */}
|
||||||
|
<FirstMileContainerAllocationTable
|
||||||
|
firstMileId={activeRecord.id}
|
||||||
|
containers={[
|
||||||
|
// TODO: Get containers from booking/first-mile data
|
||||||
|
// For now placeholder with TODO comment
|
||||||
|
]}
|
||||||
|
onSave={async (allocations) => {
|
||||||
|
await allocateMutation.mutateAsync(allocations);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button variant="default" onClick={closeContainerAllocation}>Close</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ import {
|
|||||||
} from "@/services/last-mile.service";
|
} from "@/services/last-mile.service";
|
||||||
import { vehiclesService } from "@/services/vehicles.service";
|
import { vehiclesService } from "@/services/vehicles.service";
|
||||||
import { ratesService } from "@/services/rates.service";
|
import { ratesService } from "@/services/rates.service";
|
||||||
|
import { LastMileContainerAllocationTable, type LastMileContainerRow } from "@/components/LastMileContainerAllocationTable";
|
||||||
|
import { api } from "@/auth/http";
|
||||||
|
|
||||||
const formatPrice = (amount: number) =>
|
const formatPrice = (amount: number) =>
|
||||||
`ETB ${amount.toLocaleString("en-US", {
|
`ETB ${amount.toLocaleString("en-US", {
|
||||||
@@ -321,6 +323,9 @@ const LastMilePage = () => {
|
|||||||
const [invoiceOpen, setInvoiceOpen] = useState(false);
|
const [invoiceOpen, setInvoiceOpen] = useState(false);
|
||||||
const [invoiceRecord, setInvoiceRecord] = useState<LastMileRecord | null>(null);
|
const [invoiceRecord, setInvoiceRecord] = useState<LastMileRecord | null>(null);
|
||||||
|
|
||||||
|
const [allocationOpen, setAllocationOpen] = useState(false);
|
||||||
|
const [allocationContainers, setAllocationContainers] = useState<LastMileContainerRow[]>([]);
|
||||||
|
|
||||||
const { data: listData, isLoading } = useQuery({
|
const { data: listData, isLoading } = useQuery({
|
||||||
queryKey: QUERY_KEYS.LAST_MILE.list(),
|
queryKey: QUERY_KEYS.LAST_MILE.list(),
|
||||||
queryFn: async () => {
|
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({
|
const { data: arrivalQueueData, isLoading: arrivalLoading } = useQuery({
|
||||||
queryKey: ["warehouse-inventory", "arrival-queue"],
|
queryKey: ["warehouse-inventory", "arrival-queue"],
|
||||||
queryFn: () => warehouseService.arrivalQueue().then((r) => r.data),
|
queryFn: () => warehouseService.arrivalQueue().then((r) => r.data),
|
||||||
@@ -477,6 +495,18 @@ const LastMilePage = () => {
|
|||||||
setInvoiceRecord(null);
|
setInvoiceRecord(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openAllocation = (id: string, containers?: LastMileContainerRow[]) => {
|
||||||
|
setActiveId(id);
|
||||||
|
setAllocationContainers(containers ?? []);
|
||||||
|
setAllocationOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeAllocation = () => {
|
||||||
|
setAllocationOpen(false);
|
||||||
|
setActiveId(null);
|
||||||
|
setAllocationContainers([]);
|
||||||
|
};
|
||||||
|
|
||||||
const handleSaveDistance = () => {
|
const handleSaveDistance = () => {
|
||||||
const distance = parseFloat(distanceValue);
|
const distance = parseFloat(distanceValue);
|
||||||
if (!activeId || isNaN(distance) || distance < 0) {
|
if (!activeId || isNaN(distance) || distance < 0) {
|
||||||
@@ -1243,6 +1273,76 @@ const LastMilePage = () => {
|
|||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* Container Allocation modal */}
|
||||||
|
<Modal
|
||||||
|
opened={allocationOpen}
|
||||||
|
onClose={closeAllocation}
|
||||||
|
title={<Text fw={600}>Allocate Containers to Vehicles</Text>}
|
||||||
|
size="xl"
|
||||||
|
radius="lg"
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
{activeRecord && (
|
||||||
|
<>
|
||||||
|
<Card withBorder padding="md" radius="md" bg="var(--mantine-color-gray-0)">
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text fw={600} size="sm">{bookingRef(activeRecord)}</Text>
|
||||||
|
<Text size="xs" c="dimmed">{customerName(activeRecord)}</Text>
|
||||||
|
</Stack>
|
||||||
|
<Stack gap={0} align="flex-end">
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase">Cargo Type</Text>
|
||||||
|
<Text size="sm" fw={600}>{activeRecord.booking?.cargoType?.label ?? activeRecord.booking?.cargoType?.name ?? "—"}</Text>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Capacity logic based on cargo type */}
|
||||||
|
{activeRecord.booking?.cargoType?.name === "BULK" ? (
|
||||||
|
<Card withBorder padding="md" radius="md" bg="var(--mantine-color-blue-0)" style={{ borderColor: "var(--mantine-color-blue-3)" }}>
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Group gap="xs">
|
||||||
|
<Text fw={600} size="sm">Smart Capacity Allocation</Text>
|
||||||
|
</Group>
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Text size="sm">Capacity: TBD</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
TODO: add vehicle capacity_tons to vehicle API if missing
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
TODO: add container weight to booking if missing
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Text size="sm" fw={500} mt="xs">
|
||||||
|
Select multiple containers per vehicle based on capacity
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card withBorder padding="md" radius="md" bg="var(--mantine-color-gray-0)">
|
||||||
|
<Text size="sm" fw={500}>One vehicle per container</Text>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<LastMileContainerAllocationTable
|
||||||
|
lastMileId={activeId ?? ""}
|
||||||
|
containers={allocationContainers}
|
||||||
|
onSave={async (mappings) => {
|
||||||
|
await allocateMutation.mutateAsync(mappings);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button variant="default" onClick={closeAllocation}>Close</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user