mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat(bookings): Integrate auto-pricing into DRAFT booking UI, and refactor customer-company relationships across services.
This commit is contained in:
@@ -36,6 +36,7 @@ import {
|
||||
import { EdrOrgSeeder } from "./seed/edr-org.seeder";
|
||||
import { DemoUsersSeeder } from "./seed/demo-users.seeder";
|
||||
import { DemoBookingsSeeder } from "./seed/demo-bookings.seeder";
|
||||
import { PricingDataSeeder } from "./seed/pricing-data.seeder";
|
||||
import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
|
||||
|
||||
@Module({
|
||||
@@ -83,7 +84,7 @@ import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
|
||||
BackofficeModule,
|
||||
DemoPermissionsModule,
|
||||
],
|
||||
providers: [EdrOrgSeeder, DemoUsersSeeder, DemoBookingsSeeder, FileUploadSettingsSeeder],
|
||||
providers: [EdrOrgSeeder, DemoUsersSeeder, DemoBookingsSeeder, PricingDataSeeder, FileUploadSettingsSeeder],
|
||||
})
|
||||
export class AppModule implements OnApplicationBootstrap {
|
||||
constructor(
|
||||
@@ -91,6 +92,7 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
private readonly edrOrgSeeder: EdrOrgSeeder,
|
||||
private readonly demoUsersSeeder: DemoUsersSeeder,
|
||||
private readonly demoBookingsSeeder: DemoBookingsSeeder,
|
||||
private readonly pricingDataSeeder: PricingDataSeeder,
|
||||
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
|
||||
) { }
|
||||
|
||||
@@ -99,6 +101,7 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
await this.edrOrgSeeder.run();
|
||||
await this.demoUsersSeeder.run();
|
||||
await this.demoBookingsSeeder.run();
|
||||
await this.pricingDataSeeder.run();
|
||||
await this.fileUploadSettingsSeeder.run();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,25 +3,28 @@ import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource, EntityManager, In } from 'typeorm';
|
||||
} from "@nestjs/common";
|
||||
import { InjectDataSource } from "@nestjs/typeorm";
|
||||
import { DataSource, EntityManager, In } from "typeorm";
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { Locomotive, type LocomotiveStatus } from '../locomotives/entities/locomotive.entity';
|
||||
import { LocomotivesRepository } from '../locomotives/locomotives.repository';
|
||||
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
||||
import { TrainSet } from '../train-sets/entities/train-set.entity';
|
||||
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
|
||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
import { WagonTypesRepository } from '../wagon-types/wagon-types.repository';
|
||||
import { CreateContainerTrainScheduleDto } from './dto/create-container-train-schedule.dto';
|
||||
import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto';
|
||||
import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto';
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
import {
|
||||
Locomotive,
|
||||
type LocomotiveStatus,
|
||||
} from "../locomotives/entities/locomotive.entity";
|
||||
import { LocomotivesRepository } from "../locomotives/locomotives.repository";
|
||||
import { TrainSetWagon } from "../train-sets/entities/train-set-wagon.entity";
|
||||
import { TrainSet } from "../train-sets/entities/train-set.entity";
|
||||
import { TrainScheduleBooking } from "../train-schedules/entities/train-schedule-booking.entity";
|
||||
import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity";
|
||||
import { WagonBookingAllocation } from "../train-schedules/entities/wagon-booking-allocation.entity";
|
||||
import { WagonType } from "../wagon-types/entities/wagon-type.entity";
|
||||
import { WagonTypesRepository } from "../wagon-types/wagon-types.repository";
|
||||
import { CreateContainerTrainScheduleDto } from "./dto/create-container-train-schedule.dto";
|
||||
import { GetEligibleContainerBookingsDto } from "./dto/get-eligible-container-bookings.dto";
|
||||
import { PreviewContainerTrainScheduleDto } from "./dto/preview-container-train-schedule.dto";
|
||||
|
||||
const DEFAULT_WAGON_TYPE_CODE = 'NW5';
|
||||
const DEFAULT_WAGON_TYPE_CODE = "NW5";
|
||||
const MAX_TRAIN_WEIGHT_TONS = 3500;
|
||||
const MAX_TRAIN_LENGTH_METERS = 760;
|
||||
|
||||
@@ -74,31 +77,38 @@ export class TrainSchedulingService {
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly locomotivesRepository: LocomotivesRepository,
|
||||
private readonly wagonTypesRepository: WagonTypesRepository,
|
||||
) {}
|
||||
) { }
|
||||
|
||||
async getEligibleContainerBookings(query: GetEligibleContainerBookingsDto) {
|
||||
const bookingRepository = this.dataSource.getRepository(Booking);
|
||||
const queryBuilder = bookingRepository
|
||||
.createQueryBuilder('booking')
|
||||
.leftJoinAndSelect('booking.customer', 'customer')
|
||||
.leftJoinAndSelect('booking.originYard', 'originYard')
|
||||
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
|
||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
|
||||
.leftJoin(TrainScheduleBooking, 'scheduleBooking', 'scheduleBooking.booking_id = booking.id')
|
||||
.where('booking.freightType = :freightType', { freightType: 'CONTAINER' })
|
||||
.andWhere('scheduleBooking.id IS NULL');
|
||||
.createQueryBuilder("booking")
|
||||
.leftJoinAndSelect("booking.customer", "customer")
|
||||
.leftJoinAndSelect("booking.originYard", "originYard")
|
||||
.leftJoinAndSelect("booking.destinationYard", "destinationYard")
|
||||
.leftJoinAndSelect("booking.bookingContainers", "bookingContainer")
|
||||
.leftJoinAndSelect("bookingContainer.containerType", "containerType")
|
||||
.leftJoin(
|
||||
TrainScheduleBooking,
|
||||
"scheduleBooking",
|
||||
"scheduleBooking.booking_id = booking.id",
|
||||
)
|
||||
.where("booking.freightType = :freightType", { freightType: "CONTAINER" })
|
||||
.andWhere("scheduleBooking.id IS NULL");
|
||||
|
||||
if (query.originStationId) {
|
||||
queryBuilder.andWhere('booking.originYardId = :originStationId', {
|
||||
queryBuilder.andWhere("booking.originYardId = :originStationId", {
|
||||
originStationId: query.originStationId,
|
||||
});
|
||||
}
|
||||
|
||||
if (query.destinationStationId) {
|
||||
queryBuilder.andWhere('booking.destinationYardId = :destinationStationId', {
|
||||
destinationStationId: query.destinationStationId,
|
||||
});
|
||||
queryBuilder.andWhere(
|
||||
"booking.destinationYardId = :destinationStationId",
|
||||
{
|
||||
destinationStationId: query.destinationStationId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (query.scheduleDate) {
|
||||
@@ -109,25 +119,44 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
if (query.status) {
|
||||
queryBuilder.andWhere('booking.status = :status', { status: query.status });
|
||||
queryBuilder.andWhere("booking.status = :status", {
|
||||
status: query.status,
|
||||
});
|
||||
}
|
||||
|
||||
const bookings = await queryBuilder
|
||||
.orderBy('booking.scheduled_date', 'ASC')
|
||||
.addOrderBy('booking.created_at', 'ASC')
|
||||
.orderBy("booking.scheduled_date", "ASC")
|
||||
.addOrderBy("booking.created_at", "ASC")
|
||||
.getMany();
|
||||
|
||||
const items: EligibleBookingItem[] = bookings.map((booking) => ({
|
||||
id: booking.id,
|
||||
reference: booking.reference,
|
||||
customer: booking.customer?.companyName ?? booking.customer?.email ?? 'Unknown customer',
|
||||
containerType: booking.bookingContainers
|
||||
?.map((container) => container.containerType?.label ?? container.containerType?.code ?? 'Container')
|
||||
.join(', ') ?? 'Container',
|
||||
quantity: booking.bookingContainers?.reduce((sum, container) => sum + Number(container.quantity ?? 0), 0) ?? 0,
|
||||
customer:
|
||||
booking.company?.name ?? booking.company?.email ?? "Unknown customer",
|
||||
containerType:
|
||||
booking.bookingContainers
|
||||
?.map(
|
||||
(container) =>
|
||||
container.containerType?.label ??
|
||||
container.containerType?.code ??
|
||||
"Container",
|
||||
)
|
||||
.join(", ") ?? "Container",
|
||||
quantity:
|
||||
booking.bookingContainers?.reduce(
|
||||
(sum, container) => sum + Number(container.quantity ?? 0),
|
||||
0,
|
||||
) ?? 0,
|
||||
weightTons: this.roundTons(booking.cargoTotalWeightVgm),
|
||||
origin: booking.originYard?.label ?? booking.originYard?.code ?? 'Unknown origin',
|
||||
destination: booking.destinationYard?.label ?? booking.destinationYard?.code ?? 'Unknown destination',
|
||||
origin:
|
||||
booking.originYard?.label ??
|
||||
booking.originYard?.code ??
|
||||
"Unknown origin",
|
||||
destination:
|
||||
booking.destinationYard?.label ??
|
||||
booking.destinationYard?.code ??
|
||||
"Unknown destination",
|
||||
preferredDepartureDate: booking.scheduledDate.toISOString(),
|
||||
status: booking.status,
|
||||
}));
|
||||
@@ -155,7 +184,7 @@ export class TrainSchedulingService {
|
||||
|
||||
if (!validation.valid) {
|
||||
throw new BadRequestException({
|
||||
message: 'train_schedule_invalid',
|
||||
message: "train_schedule_invalid",
|
||||
violations: validation.violations,
|
||||
});
|
||||
}
|
||||
@@ -165,92 +194,115 @@ export class TrainSchedulingService {
|
||||
validation.summary.totalWeightTons,
|
||||
);
|
||||
|
||||
const createdSchedule = await this.dataSource.transaction(async (manager) => {
|
||||
const locomotiveRepository = manager.getRepository(Locomotive);
|
||||
const lockedLocomotive = await locomotiveRepository.findOne({
|
||||
where: { id: locomotive.id },
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
});
|
||||
const createdSchedule = await this.dataSource.transaction(
|
||||
async (manager) => {
|
||||
const locomotiveRepository = manager.getRepository(Locomotive);
|
||||
const lockedLocomotive = await locomotiveRepository.findOne({
|
||||
where: { id: locomotive.id },
|
||||
lock: { mode: "pessimistic_write" },
|
||||
});
|
||||
|
||||
if (!lockedLocomotive) {
|
||||
throw new NotFoundException(`Locomotive ${locomotive.id} not found`);
|
||||
}
|
||||
|
||||
if (lockedLocomotive.status !== 'AVAILABLE') {
|
||||
throw new ConflictException(`Locomotive ${lockedLocomotive.code} is not available`);
|
||||
}
|
||||
|
||||
if (Number(lockedLocomotive.maxPullWeightTons) < validation.summary.totalWeightTons) {
|
||||
throw new BadRequestException(
|
||||
`Locomotive ${lockedLocomotive.code} cannot pull ${validation.summary.totalWeightTons}T`,
|
||||
);
|
||||
}
|
||||
|
||||
const existingScheduleCount = await manager.getRepository(TrainScheduleBooking).count({
|
||||
where: { bookingId: In(validation.bookings.map((booking) => booking.id)) },
|
||||
});
|
||||
|
||||
if (existingScheduleCount > 0) {
|
||||
throw new BadRequestException('One or more bookings are already scheduled');
|
||||
}
|
||||
|
||||
const trainSet = await this.buildTrainSet(
|
||||
manager,
|
||||
lockedLocomotive,
|
||||
validation.wagonType,
|
||||
validation.summary.totalWeightTons,
|
||||
validation.summary.totalLengthMeters,
|
||||
validation.wagonPlan,
|
||||
);
|
||||
|
||||
const schedule = manager.getRepository(TrainSchedule).create({
|
||||
trainSetId: trainSet.id,
|
||||
originStationId: dto.originStationId,
|
||||
destinationStationId: dto.destinationStationId,
|
||||
scheduledDepartureDate: new Date(dto.scheduleDate),
|
||||
status: 'SCHEDULED',
|
||||
});
|
||||
|
||||
const savedSchedule = await manager.getRepository(TrainSchedule).save(schedule);
|
||||
|
||||
const scheduleBookings = validation.bookings.map((booking) =>
|
||||
manager.getRepository(TrainScheduleBooking).create({
|
||||
trainScheduleId: savedSchedule.id,
|
||||
bookingId: booking.id,
|
||||
}),
|
||||
);
|
||||
await manager.getRepository(TrainScheduleBooking).save(scheduleBookings);
|
||||
|
||||
const savedWagons = await manager.getRepository(TrainSetWagon).find({
|
||||
where: { trainSetId: trainSet.id },
|
||||
order: { sequenceNo: 'ASC' },
|
||||
});
|
||||
|
||||
const wagonBySequence = new Map(savedWagons.map((wagon) => [wagon.sequenceNo, wagon]));
|
||||
const allocationRows = validation.wagonPlan.flatMap((wagonPlan) => {
|
||||
const wagon = wagonBySequence.get(wagonPlan.sequenceNo);
|
||||
|
||||
if (!wagon) {
|
||||
throw new BadRequestException(`Missing wagon sequence ${wagonPlan.sequenceNo}`);
|
||||
if (!lockedLocomotive) {
|
||||
throw new NotFoundException(`Locomotive ${locomotive.id} not found`);
|
||||
}
|
||||
|
||||
return wagonPlan.allocations.map((allocation) =>
|
||||
manager.getRepository(WagonBookingAllocation).create({
|
||||
trainSetWagonId: wagon.id,
|
||||
bookingId: allocation.bookingId,
|
||||
allocatedWeightTons: allocation.allocatedWeightTons,
|
||||
if (lockedLocomotive.status !== "AVAILABLE") {
|
||||
throw new ConflictException(
|
||||
`Locomotive ${lockedLocomotive.code} is not available`,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
Number(lockedLocomotive.maxPullWeightTons) <
|
||||
validation.summary.totalWeightTons
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
`Locomotive ${lockedLocomotive.code} cannot pull ${validation.summary.totalWeightTons}T`,
|
||||
);
|
||||
}
|
||||
|
||||
const existingScheduleCount = await manager
|
||||
.getRepository(TrainScheduleBooking)
|
||||
.count({
|
||||
where: {
|
||||
bookingId: In(validation.bookings.map((booking) => booking.id)),
|
||||
},
|
||||
});
|
||||
|
||||
if (existingScheduleCount > 0) {
|
||||
throw new BadRequestException(
|
||||
"One or more bookings are already scheduled",
|
||||
);
|
||||
}
|
||||
|
||||
const trainSet = await this.buildTrainSet(
|
||||
manager,
|
||||
lockedLocomotive,
|
||||
validation.wagonType,
|
||||
validation.summary.totalWeightTons,
|
||||
validation.summary.totalLengthMeters,
|
||||
validation.wagonPlan,
|
||||
);
|
||||
|
||||
const schedule = manager.getRepository(TrainSchedule).create({
|
||||
trainSetId: trainSet.id,
|
||||
originStationId: dto.originStationId,
|
||||
destinationStationId: dto.destinationStationId,
|
||||
scheduledDepartureDate: new Date(dto.scheduleDate),
|
||||
status: "SCHEDULED",
|
||||
});
|
||||
|
||||
const savedSchedule = await manager
|
||||
.getRepository(TrainSchedule)
|
||||
.save(schedule);
|
||||
|
||||
const scheduleBookings = validation.bookings.map((booking) =>
|
||||
manager.getRepository(TrainScheduleBooking).create({
|
||||
trainScheduleId: savedSchedule.id,
|
||||
bookingId: booking.id,
|
||||
}),
|
||||
);
|
||||
});
|
||||
await manager
|
||||
.getRepository(TrainScheduleBooking)
|
||||
.save(scheduleBookings);
|
||||
|
||||
await manager.getRepository(WagonBookingAllocation).save(allocationRows);
|
||||
const savedWagons = await manager.getRepository(TrainSetWagon).find({
|
||||
where: { trainSetId: trainSet.id },
|
||||
order: { sequenceNo: "ASC" },
|
||||
});
|
||||
|
||||
await locomotiveRepository.update(lockedLocomotive.id, {
|
||||
status: 'ASSIGNED',
|
||||
});
|
||||
const wagonBySequence = new Map(
|
||||
savedWagons.map((wagon) => [wagon.sequenceNo, wagon]),
|
||||
);
|
||||
const allocationRows = validation.wagonPlan.flatMap((wagonPlan) => {
|
||||
const wagon = wagonBySequence.get(wagonPlan.sequenceNo);
|
||||
|
||||
return savedSchedule.id;
|
||||
});
|
||||
if (!wagon) {
|
||||
throw new BadRequestException(
|
||||
`Missing wagon sequence ${wagonPlan.sequenceNo}`,
|
||||
);
|
||||
}
|
||||
|
||||
return wagonPlan.allocations.map((allocation) =>
|
||||
manager.getRepository(WagonBookingAllocation).create({
|
||||
trainSetWagonId: wagon.id,
|
||||
bookingId: allocation.bookingId,
|
||||
allocatedWeightTons: allocation.allocatedWeightTons,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await manager
|
||||
.getRepository(WagonBookingAllocation)
|
||||
.save(allocationRows);
|
||||
|
||||
await locomotiveRepository.update(lockedLocomotive.id, {
|
||||
status: "ASSIGNED",
|
||||
});
|
||||
|
||||
return savedSchedule.id;
|
||||
},
|
||||
);
|
||||
|
||||
return this.getContainerTrainScheduleById(createdSchedule);
|
||||
}
|
||||
@@ -261,7 +313,7 @@ export class TrainSchedulingService {
|
||||
const bookingIds = [...new Set(dto.bookingIds)];
|
||||
|
||||
if (!bookingIds.length) {
|
||||
throw new BadRequestException('At least one booking is required');
|
||||
throw new BadRequestException("At least one booking is required");
|
||||
}
|
||||
|
||||
const [wagonType] = await this.wagonTypesRepository.findAll({
|
||||
@@ -269,7 +321,9 @@ export class TrainSchedulingService {
|
||||
});
|
||||
|
||||
if (!wagonType) {
|
||||
throw new NotFoundException(`Wagon type ${DEFAULT_WAGON_TYPE_CODE} not found`);
|
||||
throw new NotFoundException(
|
||||
`Wagon type ${DEFAULT_WAGON_TYPE_CODE} not found`,
|
||||
);
|
||||
}
|
||||
|
||||
const bookings = await this.loadBookingsForScheduling(bookingIds);
|
||||
@@ -278,21 +332,29 @@ export class TrainSchedulingService {
|
||||
if (bookings.length !== bookingIds.length) {
|
||||
const foundIds = new Set(bookings.map((booking) => booking.id));
|
||||
const missing = bookingIds.filter((id) => !foundIds.has(id));
|
||||
violations.push(`Bookings not found: ${missing.join(', ')}`);
|
||||
violations.push(`Bookings not found: ${missing.join(", ")}`);
|
||||
}
|
||||
|
||||
const scheduledLinks = await this.dataSource.getRepository(TrainScheduleBooking).find({
|
||||
where: { bookingId: In(bookingIds) },
|
||||
select: { bookingId: true },
|
||||
});
|
||||
const scheduledLinks = await this.dataSource
|
||||
.getRepository(TrainScheduleBooking)
|
||||
.find({
|
||||
where: { bookingId: In(bookingIds) },
|
||||
select: { bookingId: true },
|
||||
});
|
||||
|
||||
if (scheduledLinks.length > 0) {
|
||||
violations.push('One or more selected bookings are already assigned to a train schedule');
|
||||
violations.push(
|
||||
"One or more selected bookings are already assigned to a train schedule",
|
||||
);
|
||||
}
|
||||
|
||||
const nonContainerBookings = bookings.filter((booking) => booking.freightType !== 'CONTAINER');
|
||||
const nonContainerBookings = bookings.filter(
|
||||
(booking) => booking.freightType !== "CONTAINER",
|
||||
);
|
||||
if (nonContainerBookings.length > 0) {
|
||||
violations.push('Only CONTAINER bookings are supported for train scheduling');
|
||||
violations.push(
|
||||
"Only CONTAINER bookings are supported for train scheduling",
|
||||
);
|
||||
}
|
||||
|
||||
const scheduleDateKey = this.toUtcDateKey(dto.scheduleDate);
|
||||
@@ -302,44 +364,62 @@ export class TrainSchedulingService {
|
||||
booking.destinationYardId !== dto.destinationStationId,
|
||||
);
|
||||
if (routeMismatch) {
|
||||
violations.push('Selected bookings must share the same origin and destination as the schedule');
|
||||
violations.push(
|
||||
"Selected bookings must share the same origin and destination as the schedule",
|
||||
);
|
||||
}
|
||||
|
||||
const dateMismatch = bookings.some(
|
||||
(booking) => this.toUtcDateKey(booking.scheduledDate) !== scheduleDateKey,
|
||||
);
|
||||
if (dateMismatch) {
|
||||
violations.push('Selected bookings must share the same schedule date');
|
||||
violations.push("Selected bookings must share the same schedule date");
|
||||
}
|
||||
|
||||
const uniqueOriginCount = new Set(bookings.map((booking) => booking.originYardId)).size;
|
||||
const uniqueOriginCount = new Set(
|
||||
bookings.map((booking) => booking.originYardId),
|
||||
).size;
|
||||
if (uniqueOriginCount > 1) {
|
||||
violations.push('Selected bookings must share the same origin station');
|
||||
violations.push("Selected bookings must share the same origin station");
|
||||
}
|
||||
|
||||
const uniqueDestinationCount = new Set(bookings.map((booking) => booking.destinationYardId)).size;
|
||||
const uniqueDestinationCount = new Set(
|
||||
bookings.map((booking) => booking.destinationYardId),
|
||||
).size;
|
||||
if (uniqueDestinationCount > 1) {
|
||||
violations.push('Selected bookings must share the same destination station');
|
||||
violations.push(
|
||||
"Selected bookings must share the same destination station",
|
||||
);
|
||||
}
|
||||
|
||||
const uniqueDateCount = new Set(
|
||||
bookings.map((booking) => this.toUtcDateKey(booking.scheduledDate)),
|
||||
).size;
|
||||
if (uniqueDateCount > 1) {
|
||||
violations.push('Selected bookings must share the same preferred departure date');
|
||||
violations.push(
|
||||
"Selected bookings must share the same preferred departure date",
|
||||
);
|
||||
}
|
||||
|
||||
const totalWeightTons = this.roundTons(
|
||||
bookings.reduce((sum, booking) => sum + Number(booking.cargoTotalWeightVgm ?? 0), 0),
|
||||
bookings.reduce(
|
||||
(sum, booking) => sum + Number(booking.cargoTotalWeightVgm ?? 0),
|
||||
0,
|
||||
),
|
||||
);
|
||||
|
||||
const wagonPlan = this.allocateBookingsToWagons(bookings, this.calculateNW5WagonPlan(totalWeightTons, wagonType));
|
||||
const wagonPlan = this.allocateBookingsToWagons(
|
||||
bookings,
|
||||
this.calculateNW5WagonPlan(totalWeightTons, wagonType),
|
||||
);
|
||||
const totalLengthMeters = this.roundTons(
|
||||
wagonPlan.reduce((sum, wagon) => sum + wagon.lengthMeters, 0),
|
||||
);
|
||||
|
||||
if (totalWeightTons > MAX_TRAIN_WEIGHT_TONS) {
|
||||
violations.push(`Total booking weight ${totalWeightTons}T exceeds max train weight ${MAX_TRAIN_WEIGHT_TONS}T`);
|
||||
violations.push(
|
||||
`Total booking weight ${totalWeightTons}T exceeds max train weight ${MAX_TRAIN_WEIGHT_TONS}T`,
|
||||
);
|
||||
}
|
||||
|
||||
if (totalLengthMeters > MAX_TRAIN_LENGTH_METERS) {
|
||||
@@ -357,21 +437,25 @@ export class TrainSchedulingService {
|
||||
);
|
||||
}
|
||||
|
||||
const availableLocomotiveCount = await this.dataSource.getRepository(Locomotive).count({
|
||||
where: { status: 'AVAILABLE' as LocomotiveStatus },
|
||||
});
|
||||
const availableLocomotiveCount = await this.dataSource
|
||||
.getRepository(Locomotive)
|
||||
.count({
|
||||
where: { status: "AVAILABLE" as LocomotiveStatus },
|
||||
});
|
||||
|
||||
if (availableLocomotiveCount === 0) {
|
||||
violations.push('No available locomotive exists for scheduling');
|
||||
violations.push("No available locomotive exists for scheduling");
|
||||
} else {
|
||||
const capableLocomotives = await this.dataSource.getRepository(Locomotive).find({
|
||||
where: { status: 'AVAILABLE' },
|
||||
});
|
||||
const capableLocomotives = await this.dataSource
|
||||
.getRepository(Locomotive)
|
||||
.find({
|
||||
where: { status: "AVAILABLE" },
|
||||
});
|
||||
const canPull = capableLocomotives.some(
|
||||
(locomotive) => Number(locomotive.maxPullWeightTons) >= totalWeightTons,
|
||||
);
|
||||
if (!canPull) {
|
||||
violations.push('No available locomotive can pull the total weight');
|
||||
violations.push("No available locomotive can pull the total weight");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -391,14 +475,21 @@ export class TrainSchedulingService {
|
||||
};
|
||||
}
|
||||
|
||||
calculateNW5WagonPlan(totalBookingWeightTons: number, wagonType: WagonType): WagonPlanRecord[] {
|
||||
calculateNW5WagonPlan(
|
||||
totalBookingWeightTons: number,
|
||||
wagonType: WagonType,
|
||||
): WagonPlanRecord[] {
|
||||
const wagonCapacityTons = Number(wagonType.capacityTons);
|
||||
const wagonsNeeded = Math.ceil(totalBookingWeightTons / wagonCapacityTons);
|
||||
let remainingWeight = this.roundTons(totalBookingWeightTons);
|
||||
|
||||
return Array.from({ length: wagonsNeeded }, (_, index) => {
|
||||
const assignedWeightTons = this.roundTons(Math.min(wagonCapacityTons, remainingWeight));
|
||||
remainingWeight = this.roundTons(Math.max(0, remainingWeight - assignedWeightTons));
|
||||
const assignedWeightTons = this.roundTons(
|
||||
Math.min(wagonCapacityTons, remainingWeight),
|
||||
);
|
||||
remainingWeight = this.roundTons(
|
||||
Math.max(0, remainingWeight - assignedWeightTons),
|
||||
);
|
||||
|
||||
return {
|
||||
sequenceNo: index + 1,
|
||||
@@ -410,15 +501,20 @@ export class TrainSchedulingService {
|
||||
});
|
||||
}
|
||||
|
||||
async selectOrValidateLocomotive(locomotiveId: string, totalWeightTons: number) {
|
||||
async selectOrValidateLocomotive(
|
||||
locomotiveId: string,
|
||||
totalWeightTons: number,
|
||||
) {
|
||||
const locomotive = await this.locomotivesRepository.findById(locomotiveId);
|
||||
|
||||
if (!locomotive) {
|
||||
throw new NotFoundException(`Locomotive ${locomotiveId} not found`);
|
||||
}
|
||||
|
||||
if (locomotive.status !== 'AVAILABLE') {
|
||||
throw new BadRequestException(`Locomotive ${locomotive.code} is not available`);
|
||||
if (locomotive.status !== "AVAILABLE") {
|
||||
throw new BadRequestException(
|
||||
`Locomotive ${locomotive.code} is not available`,
|
||||
);
|
||||
}
|
||||
|
||||
if (Number(locomotive.maxPullWeightTons) < totalWeightTons) {
|
||||
@@ -443,7 +539,7 @@ export class TrainSchedulingService {
|
||||
totalWeightTons,
|
||||
totalLengthMeters,
|
||||
wagonCount: wagonPlan.length,
|
||||
status: 'ASSIGNED',
|
||||
status: "ASSIGNED",
|
||||
});
|
||||
const savedTrainSet = await manager.getRepository(TrainSet).save(trainSet);
|
||||
|
||||
@@ -463,11 +559,16 @@ export class TrainSchedulingService {
|
||||
return savedTrainSet;
|
||||
}
|
||||
|
||||
allocateBookingsToWagons(bookings: Booking[], baseWagonPlan: WagonPlanRecord[]): WagonPlanRecord[] {
|
||||
allocateBookingsToWagons(
|
||||
bookings: Booking[],
|
||||
baseWagonPlan: WagonPlanRecord[],
|
||||
): WagonPlanRecord[] {
|
||||
const remaining = bookings.map((booking) => ({
|
||||
bookingId: booking.id,
|
||||
bookingReference: booking.reference,
|
||||
remainingWeightTons: this.roundTons(Number(booking.cargoTotalWeightVgm ?? 0)),
|
||||
remainingWeightTons: this.roundTons(
|
||||
Number(booking.cargoTotalWeightVgm ?? 0),
|
||||
),
|
||||
}));
|
||||
let bookingIndex = 0;
|
||||
|
||||
@@ -496,7 +597,9 @@ export class TrainSchedulingService {
|
||||
booking.remainingWeightTons - allocatedWeightTons,
|
||||
);
|
||||
wagonRemaining = this.roundTons(wagonRemaining - allocatedWeightTons);
|
||||
assignedWeightTons = this.roundTons(assignedWeightTons + allocatedWeightTons);
|
||||
assignedWeightTons = this.roundTons(
|
||||
assignedWeightTons + allocatedWeightTons,
|
||||
);
|
||||
|
||||
if (booking.remainingWeightTons <= 0) {
|
||||
bookingIndex += 1;
|
||||
@@ -519,40 +622,54 @@ export class TrainSchedulingService {
|
||||
destinationStation: true,
|
||||
scheduleBookings: true,
|
||||
},
|
||||
order: { scheduledDepartureDate: 'DESC', createdAt: 'DESC' },
|
||||
order: { scheduledDepartureDate: "DESC", createdAt: "DESC" },
|
||||
});
|
||||
|
||||
return schedules.map((schedule) => ({
|
||||
id: schedule.id,
|
||||
scheduleDate: schedule.scheduledDepartureDate,
|
||||
origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
|
||||
origin:
|
||||
schedule.originStation?.label ?? schedule.originStation?.code ?? null,
|
||||
destination:
|
||||
schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null,
|
||||
schedule.destinationStation?.label ??
|
||||
schedule.destinationStation?.code ??
|
||||
null,
|
||||
locomotive: schedule.trainSet?.locomotive
|
||||
? {
|
||||
id: schedule.trainSet.locomotive.id,
|
||||
code: schedule.trainSet.locomotive.code,
|
||||
name: schedule.trainSet.locomotive.name ?? null,
|
||||
}
|
||||
id: schedule.trainSet.locomotive.id,
|
||||
code: schedule.trainSet.locomotive.code,
|
||||
name: schedule.trainSet.locomotive.name ?? null,
|
||||
}
|
||||
: null,
|
||||
wagonCount: schedule.trainSet?.wagonCount ?? 0,
|
||||
totalWeightTons: this.roundTons(Number(schedule.trainSet?.totalWeightTons ?? 0)),
|
||||
totalLengthMeters: this.roundTons(Number(schedule.trainSet?.totalLengthMeters ?? 0)),
|
||||
totalWeightTons: this.roundTons(
|
||||
Number(schedule.trainSet?.totalWeightTons ?? 0),
|
||||
),
|
||||
totalLengthMeters: this.roundTons(
|
||||
Number(schedule.trainSet?.totalLengthMeters ?? 0),
|
||||
),
|
||||
bookingsCount: schedule.scheduleBookings?.length ?? 0,
|
||||
status: schedule.status,
|
||||
}));
|
||||
}
|
||||
|
||||
async getContainerTrainScheduleById(id: string) {
|
||||
const schedule = await this.dataSource.getRepository(TrainSchedule).findOne({
|
||||
where: { id },
|
||||
relations: {
|
||||
trainSet: { locomotive: true, wagons: { wagonType: true, allocations: { booking: true } } },
|
||||
originStation: true,
|
||||
destinationStation: true,
|
||||
scheduleBookings: { booking: { customer: true, originYard: true, destinationYard: true } },
|
||||
},
|
||||
});
|
||||
const schedule = await this.dataSource
|
||||
.getRepository(TrainSchedule)
|
||||
.findOne({
|
||||
where: { id },
|
||||
relations: {
|
||||
trainSet: {
|
||||
locomotive: true,
|
||||
wagons: { wagonType: true, allocations: { booking: true } },
|
||||
},
|
||||
originStation: true,
|
||||
destinationStation: true,
|
||||
scheduleBookings: {
|
||||
booking: { company: true, originYard: true, destinationYard: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!schedule) {
|
||||
throw new NotFoundException(`Train schedule ${id} not found`);
|
||||
@@ -567,67 +684,78 @@ export class TrainSchedulingService {
|
||||
destinationStation: schedule.destinationStation,
|
||||
trainSet: schedule.trainSet
|
||||
? {
|
||||
id: schedule.trainSet.id,
|
||||
status: schedule.trainSet.status,
|
||||
wagonCount: schedule.trainSet.wagonCount,
|
||||
totalWeightTons: this.roundTons(Number(schedule.trainSet.totalWeightTons)),
|
||||
totalLengthMeters: this.roundTons(Number(schedule.trainSet.totalLengthMeters)),
|
||||
locomotive: schedule.trainSet.locomotive
|
||||
? {
|
||||
id: schedule.trainSet.locomotive.id,
|
||||
code: schedule.trainSet.locomotive.code,
|
||||
name: schedule.trainSet.locomotive.name,
|
||||
status: schedule.trainSet.locomotive.status,
|
||||
maxPullWeightTons: this.roundTons(
|
||||
Number(schedule.trainSet.locomotive.maxPullWeightTons),
|
||||
),
|
||||
id: schedule.trainSet.id,
|
||||
status: schedule.trainSet.status,
|
||||
wagonCount: schedule.trainSet.wagonCount,
|
||||
totalWeightTons: this.roundTons(
|
||||
Number(schedule.trainSet.totalWeightTons),
|
||||
),
|
||||
totalLengthMeters: this.roundTons(
|
||||
Number(schedule.trainSet.totalLengthMeters),
|
||||
),
|
||||
locomotive: schedule.trainSet.locomotive
|
||||
? {
|
||||
id: schedule.trainSet.locomotive.id,
|
||||
code: schedule.trainSet.locomotive.code,
|
||||
name: schedule.trainSet.locomotive.name,
|
||||
status: schedule.trainSet.locomotive.status,
|
||||
maxPullWeightTons: this.roundTons(
|
||||
Number(schedule.trainSet.locomotive.maxPullWeightTons),
|
||||
),
|
||||
}
|
||||
: null,
|
||||
wagons: [...(schedule.trainSet.wagons ?? [])]
|
||||
.sort((left, right) => left.sequenceNo - right.sequenceNo)
|
||||
.map((wagon) => ({
|
||||
id: wagon.id,
|
||||
sequenceNo: wagon.sequenceNo,
|
||||
capacityTons: this.roundTons(Number(wagon.capacityTons)),
|
||||
lengthMeters: this.roundTons(Number(wagon.lengthMeters)),
|
||||
assignedWeightTons: this.roundTons(
|
||||
Number(wagon.assignedWeightTons),
|
||||
),
|
||||
wagonType: wagon.wagonType
|
||||
? {
|
||||
id: wagon.wagonType.id,
|
||||
code: wagon.wagonType.code,
|
||||
name: wagon.wagonType.name,
|
||||
}
|
||||
: null,
|
||||
wagons:
|
||||
[...(schedule.trainSet.wagons ?? [])]
|
||||
.sort((left, right) => left.sequenceNo - right.sequenceNo)
|
||||
.map((wagon) => ({
|
||||
id: wagon.id,
|
||||
sequenceNo: wagon.sequenceNo,
|
||||
capacityTons: this.roundTons(Number(wagon.capacityTons)),
|
||||
lengthMeters: this.roundTons(Number(wagon.lengthMeters)),
|
||||
assignedWeightTons: this.roundTons(Number(wagon.assignedWeightTons)),
|
||||
wagonType: wagon.wagonType
|
||||
? {
|
||||
id: wagon.wagonType.id,
|
||||
code: wagon.wagonType.code,
|
||||
name: wagon.wagonType.name,
|
||||
}
|
||||
: null,
|
||||
allocations:
|
||||
wagon.allocations?.map((allocation) => ({
|
||||
id: allocation.id,
|
||||
bookingId: allocation.bookingId,
|
||||
bookingReference: allocation.booking?.reference ?? null,
|
||||
allocatedWeightTons: this.roundTons(Number(allocation.allocatedWeightTons)),
|
||||
})) ?? [],
|
||||
})),
|
||||
}
|
||||
: null,
|
||||
allocations:
|
||||
wagon.allocations?.map((allocation) => ({
|
||||
id: allocation.id,
|
||||
bookingId: allocation.bookingId,
|
||||
bookingReference: allocation.booking?.reference ?? null,
|
||||
allocatedWeightTons: this.roundTons(
|
||||
Number(allocation.allocatedWeightTons),
|
||||
),
|
||||
})) ?? [],
|
||||
})),
|
||||
}
|
||||
: null,
|
||||
bookings:
|
||||
schedule.scheduleBookings?.map((scheduleBooking) => ({
|
||||
id: scheduleBooking.booking?.id ?? scheduleBooking.bookingId,
|
||||
reference: scheduleBooking.booking?.reference ?? null,
|
||||
customer:
|
||||
scheduleBooking.booking?.customer?.companyName ??
|
||||
scheduleBooking.booking?.customer?.email ??
|
||||
scheduleBooking.booking?.company?.name ??
|
||||
scheduleBooking.booking?.company?.email ??
|
||||
null,
|
||||
weightTons: this.roundTons(Number(scheduleBooking.booking?.cargoTotalWeightVgm ?? 0)),
|
||||
weightTons: this.roundTons(
|
||||
Number(scheduleBooking.booking?.cargoTotalWeightVgm ?? 0),
|
||||
),
|
||||
status: scheduleBooking.booking?.status ?? null,
|
||||
})) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
async cancelTrainSchedule(id: string) {
|
||||
const schedule = await this.dataSource.getRepository(TrainSchedule).findOne({
|
||||
where: { id },
|
||||
relations: { trainSet: { locomotive: true } },
|
||||
});
|
||||
const schedule = await this.dataSource
|
||||
.getRepository(TrainSchedule)
|
||||
.findOne({
|
||||
where: { id },
|
||||
relations: { trainSet: { locomotive: true } },
|
||||
});
|
||||
|
||||
if (!schedule) {
|
||||
throw new NotFoundException(`Train schedule ${id} not found`);
|
||||
@@ -635,19 +763,21 @@ export class TrainSchedulingService {
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(TrainSchedule).update(schedule.id, {
|
||||
status: 'CANCELLED',
|
||||
status: "CANCELLED",
|
||||
});
|
||||
|
||||
if (schedule.trainSetId) {
|
||||
await manager.getRepository(TrainSet).update(schedule.trainSetId, {
|
||||
status: 'CANCELLED',
|
||||
status: "CANCELLED",
|
||||
});
|
||||
}
|
||||
|
||||
if (schedule.trainSet?.locomotiveId) {
|
||||
await manager.getRepository(Locomotive).update(schedule.trainSet.locomotiveId, {
|
||||
status: 'AVAILABLE',
|
||||
});
|
||||
await manager
|
||||
.getRepository(Locomotive)
|
||||
.update(schedule.trainSet.locomotiveId, {
|
||||
status: "AVAILABLE",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -658,12 +788,12 @@ export class TrainSchedulingService {
|
||||
return this.dataSource.getRepository(Booking).find({
|
||||
where: { id: In(bookingIds) },
|
||||
relations: {
|
||||
customer: true,
|
||||
company: true,
|
||||
originYard: true,
|
||||
destinationYard: true,
|
||||
bookingContainers: { containerType: true },
|
||||
},
|
||||
order: { createdAt: 'ASC' },
|
||||
order: { createdAt: "ASC" },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -673,7 +803,7 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
private roundTons(value: number | string | null | undefined) {
|
||||
const numericValue = typeof value === 'number' ? value : Number(value ?? 0);
|
||||
const numericValue = typeof value === "number" ? value : Number(value ?? 0);
|
||||
|
||||
if (!Number.isFinite(numericValue)) {
|
||||
return 0;
|
||||
|
||||
@@ -1,95 +1,105 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { randomUUID } from "crypto";
|
||||
import { DataSource } from "typeorm";
|
||||
|
||||
import { BookingContainer } from '../modules/bookings/entities/booking-container.entity';
|
||||
import { Booking } from '../modules/bookings/entities/booking.entity';
|
||||
import { Customer } from '../modules/customers/entities/customer.entity';
|
||||
import { Locomotive } from '../modules/locomotives/entities/locomotive.entity';
|
||||
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
|
||||
import { Yard } from '../modules/rule-engine/entities/yard.entity';
|
||||
import { WagonType } from '../modules/wagon-types/entities/wagon-type.entity';
|
||||
import { ContainerType } from '../modules/rule-engine/entities/container-type.entity';
|
||||
import { BookingContainer } from "../modules/bookings/entities/booking-container.entity";
|
||||
import { Booking } from "../modules/bookings/entities/booking.entity";
|
||||
import { Customer } from "../modules/customers/entities/customer.entity";
|
||||
import { Locomotive } from "../modules/locomotives/entities/locomotive.entity";
|
||||
import { ServiceType } from "../modules/rule-engine/entities/service-type.entity";
|
||||
import { Yard } from "../modules/rule-engine/entities/yard.entity";
|
||||
import { WagonType } from "../modules/wagon-types/entities/wagon-type.entity";
|
||||
import { ContainerType } from "../modules/rule-engine/entities/container-type.entity";
|
||||
|
||||
const SEED_FLAG = 'SEED_DEMO_BOOKINGS';
|
||||
const SEED_FLAG = "SEED_DEMO_BOOKINGS";
|
||||
|
||||
const SERVICE_TYPE_CODE = 'RAIL_CONTAINER';
|
||||
const CUSTOMER_EMAIL = 'train-scheduling-demo@edr.local';
|
||||
const SERVICE_TYPE_CODE = "RAIL_CONTAINER";
|
||||
const CUSTOMER_EMAIL = "train-scheduling-demo@edr.local";
|
||||
|
||||
const YARDS = [
|
||||
{ code: 'DJIBOUTI', label: 'Djibouti', country: 'Djibouti', displayOrder: 1 },
|
||||
{ code: 'ADDIS_ABABA', label: 'Addis Ababa', country: 'Ethiopia', displayOrder: 2 },
|
||||
{ code: 'DIRE_DAWA', label: 'Dire Dawa', country: 'Ethiopia', displayOrder: 3 },
|
||||
{ code: "DJIBOUTI", label: "Djibouti", country: "Djibouti", displayOrder: 1 },
|
||||
{
|
||||
code: "ADDIS_ABABA",
|
||||
label: "Addis Ababa",
|
||||
country: "Ethiopia",
|
||||
displayOrder: 2,
|
||||
},
|
||||
{
|
||||
code: "DIRE_DAWA",
|
||||
label: "Dire Dawa",
|
||||
country: "Ethiopia",
|
||||
displayOrder: 3,
|
||||
},
|
||||
];
|
||||
|
||||
const CONTAINER_TYPES = [
|
||||
{ code: '20FT', label: '20FT', sizeFt: 20 },
|
||||
{ code: '40FT', label: '40FT', sizeFt: 40 },
|
||||
{ code: "20FT", label: "20FT", sizeFt: 20 },
|
||||
{ code: "40FT", label: "40FT", sizeFt: 40 },
|
||||
];
|
||||
|
||||
const DEMO_BOOKINGS = [
|
||||
{
|
||||
reference: 'BKG-CONT-001',
|
||||
containerCode: '40FT',
|
||||
reference: "BKG-CONT-001",
|
||||
containerCode: "40FT",
|
||||
quantity: 20,
|
||||
totalWeightTons: 500,
|
||||
originCode: 'DJIBOUTI',
|
||||
destinationCode: 'ADDIS_ABABA',
|
||||
scheduledDate: '2026-06-20T08:00:00.000Z',
|
||||
originCode: "DJIBOUTI",
|
||||
destinationCode: "ADDIS_ABABA",
|
||||
scheduledDate: "2026-06-20T08:00:00.000Z",
|
||||
},
|
||||
{
|
||||
reference: 'BKG-CONT-002',
|
||||
containerCode: '20FT',
|
||||
reference: "BKG-CONT-002",
|
||||
containerCode: "20FT",
|
||||
quantity: 10,
|
||||
totalWeightTons: 300,
|
||||
originCode: 'DJIBOUTI',
|
||||
destinationCode: 'ADDIS_ABABA',
|
||||
scheduledDate: '2026-06-20T08:00:00.000Z',
|
||||
originCode: "DJIBOUTI",
|
||||
destinationCode: "ADDIS_ABABA",
|
||||
scheduledDate: "2026-06-20T08:00:00.000Z",
|
||||
},
|
||||
{
|
||||
reference: 'BKG-CONT-003',
|
||||
containerCode: '40FT',
|
||||
reference: "BKG-CONT-003",
|
||||
containerCode: "40FT",
|
||||
quantity: 15,
|
||||
totalWeightTons: 450,
|
||||
originCode: 'DJIBOUTI',
|
||||
destinationCode: 'ADDIS_ABABA',
|
||||
scheduledDate: '2026-06-20T08:00:00.000Z',
|
||||
originCode: "DJIBOUTI",
|
||||
destinationCode: "ADDIS_ABABA",
|
||||
scheduledDate: "2026-06-20T08:00:00.000Z",
|
||||
},
|
||||
{
|
||||
reference: 'BKG-CONT-007',
|
||||
containerCode: '20FT',
|
||||
reference: "BKG-CONT-007",
|
||||
containerCode: "20FT",
|
||||
quantity: 6,
|
||||
totalWeightTons: 180,
|
||||
originCode: 'DJIBOUTI',
|
||||
destinationCode: 'ADDIS_ABABA',
|
||||
scheduledDate: '2026-06-20T08:00:00.000Z',
|
||||
originCode: "DJIBOUTI",
|
||||
destinationCode: "ADDIS_ABABA",
|
||||
scheduledDate: "2026-06-20T08:00:00.000Z",
|
||||
},
|
||||
{
|
||||
reference: 'BKG-CONT-004',
|
||||
containerCode: '40FT',
|
||||
reference: "BKG-CONT-004",
|
||||
containerCode: "40FT",
|
||||
quantity: 12,
|
||||
totalWeightTons: 360,
|
||||
originCode: 'ADDIS_ABABA',
|
||||
destinationCode: 'DIRE_DAWA',
|
||||
scheduledDate: '2026-06-20T08:00:00.000Z',
|
||||
originCode: "ADDIS_ABABA",
|
||||
destinationCode: "DIRE_DAWA",
|
||||
scheduledDate: "2026-06-20T08:00:00.000Z",
|
||||
},
|
||||
{
|
||||
reference: 'BKG-CONT-005',
|
||||
containerCode: '20FT',
|
||||
reference: "BKG-CONT-005",
|
||||
containerCode: "20FT",
|
||||
quantity: 8,
|
||||
totalWeightTons: 160,
|
||||
originCode: 'DJIBOUTI',
|
||||
destinationCode: 'ADDIS_ABABA',
|
||||
scheduledDate: '2026-06-21T08:00:00.000Z',
|
||||
originCode: "DJIBOUTI",
|
||||
destinationCode: "ADDIS_ABABA",
|
||||
scheduledDate: "2026-06-21T08:00:00.000Z",
|
||||
},
|
||||
{
|
||||
reference: 'BKG-CONT-006',
|
||||
containerCode: '40FT',
|
||||
reference: "BKG-CONT-006",
|
||||
containerCode: "40FT",
|
||||
quantity: 80,
|
||||
totalWeightTons: 3600,
|
||||
originCode: 'DJIBOUTI',
|
||||
destinationCode: 'ADDIS_ABABA',
|
||||
scheduledDate: '2026-06-20T08:00:00.000Z',
|
||||
originCode: "DJIBOUTI",
|
||||
destinationCode: "ADDIS_ABABA",
|
||||
scheduledDate: "2026-06-20T08:00:00.000Z",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -97,24 +107,26 @@ const DEMO_BOOKINGS = [
|
||||
export class DemoBookingsSeeder {
|
||||
private readonly logger = new Logger(DemoBookingsSeeder.name);
|
||||
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
constructor(private readonly dataSource: DataSource) { }
|
||||
|
||||
async run() {
|
||||
const shouldSeed = process.env[SEED_FLAG]?.trim().toLowerCase() === 'true';
|
||||
const shouldSeed = process.env[SEED_FLAG]?.trim().toLowerCase() === "true";
|
||||
if (!shouldSeed) {
|
||||
this.logger.log(`Skipping demo booking seed because ${SEED_FLAG} is not enabled`);
|
||||
this.logger.log(
|
||||
`Skipping demo booking seed because ${SEED_FLAG} is not enabled`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(WagonType).upsert(
|
||||
{
|
||||
code: 'NW5',
|
||||
name: 'Flat Wagon',
|
||||
code: "NW5",
|
||||
name: "Flat Wagon",
|
||||
capacityTons: 70,
|
||||
lengthMeters: 14,
|
||||
maxWagonsPerTrain: 53,
|
||||
supportedLoadTypes: ['CONTAINER'],
|
||||
supportedLoadTypes: ["CONTAINER"],
|
||||
isActive: true,
|
||||
},
|
||||
{ conflictPaths: { code: true } },
|
||||
@@ -123,16 +135,16 @@ export class DemoBookingsSeeder {
|
||||
await manager.getRepository(Locomotive).upsert(
|
||||
[
|
||||
{
|
||||
code: 'LOC-001',
|
||||
name: 'Demo Locomotive 1',
|
||||
code: "LOC-001",
|
||||
name: "Demo Locomotive 1",
|
||||
maxPullWeightTons: 3500,
|
||||
status: 'AVAILABLE',
|
||||
status: "AVAILABLE",
|
||||
},
|
||||
{
|
||||
code: 'LOC-002',
|
||||
name: 'Demo Locomotive 2',
|
||||
code: "LOC-002",
|
||||
name: "Demo Locomotive 2",
|
||||
maxPullWeightTons: 2500,
|
||||
status: 'AVAILABLE',
|
||||
status: "AVAILABLE",
|
||||
},
|
||||
],
|
||||
{ conflictPaths: { code: true } },
|
||||
@@ -146,8 +158,8 @@ export class DemoBookingsSeeder {
|
||||
await manager.getRepository(ServiceType).upsert(
|
||||
{
|
||||
code: SERVICE_TYPE_CODE,
|
||||
serviceName: 'Rail Container Service',
|
||||
description: 'Temporary service type for train scheduling demos',
|
||||
serviceName: "Rail Container Service",
|
||||
description: "Temporary service type for train scheduling demos",
|
||||
canBeBookedAlone: true,
|
||||
includesFirstMile: false,
|
||||
includesLastMile: false,
|
||||
@@ -173,74 +185,86 @@ export class DemoBookingsSeeder {
|
||||
|
||||
await manager.getRepository(Customer).upsert(
|
||||
{
|
||||
userId: '00000000-0000-0000-0000-000000000111',
|
||||
firstName: 'Train',
|
||||
lastName: 'Scheduling',
|
||||
userId: "00000000-0000-0000-0000-000000000111",
|
||||
firstName: "Train",
|
||||
lastName: "Scheduling",
|
||||
email: CUSTOMER_EMAIL,
|
||||
phone: '251900000001',
|
||||
companyName: 'Train Scheduling Demo Customer',
|
||||
phone: "251900000001",
|
||||
companyName: "Train Scheduling Demo Customer",
|
||||
companyEmail: CUSTOMER_EMAIL,
|
||||
companyPhone: '251900000001',
|
||||
companyLocation: 'Addis Ababa',
|
||||
companyAddress: 'Demo Address',
|
||||
customerType: 'DEMO',
|
||||
status: 'ACTIVE',
|
||||
contactPersonName: 'Train Scheduling',
|
||||
contactPersonPhone: '251900000001',
|
||||
tinNumber: '1234567890',
|
||||
vatNumber: '1234567890',
|
||||
fanNumber: '1234567890123456',
|
||||
generalManagerName: 'Demo Manager',
|
||||
companyPhone: "251900000001",
|
||||
companyLocation: "Addis Ababa",
|
||||
companyAddress: "Demo Address",
|
||||
customerType: "DEMO",
|
||||
status: "ACTIVE",
|
||||
contactPersonName: "Train Scheduling",
|
||||
contactPersonPhone: "251900000001",
|
||||
tinNumber: "1234567890",
|
||||
vatNumber: "1234567890",
|
||||
fanNumber: "1234567890123456",
|
||||
generalManagerName: "Demo Manager",
|
||||
generalManagerEmail: CUSTOMER_EMAIL,
|
||||
generalManagerPhone: '251900000001',
|
||||
generalManagerPhone: "251900000001",
|
||||
},
|
||||
{ conflictPaths: { email: true } },
|
||||
);
|
||||
|
||||
const [serviceType, customer, yards, containerTypes] = await Promise.all([
|
||||
manager.getRepository(ServiceType).findOneByOrFail({ code: SERVICE_TYPE_CODE }),
|
||||
manager.getRepository(Customer).findOneByOrFail({ email: CUSTOMER_EMAIL }),
|
||||
manager
|
||||
.getRepository(ServiceType)
|
||||
.findOneByOrFail({ code: SERVICE_TYPE_CODE }),
|
||||
manager
|
||||
.getRepository(Customer)
|
||||
.findOneByOrFail({ email: CUSTOMER_EMAIL }),
|
||||
manager.getRepository(Yard).find(),
|
||||
manager.getRepository(ContainerType).find(),
|
||||
]);
|
||||
|
||||
const yardByCode = new Map(yards.map((yard) => [yard.code, yard]));
|
||||
const containerTypeByCode = new Map(
|
||||
containerTypes.map((containerType) => [containerType.code, containerType]),
|
||||
containerTypes.map((containerType) => [
|
||||
containerType.code,
|
||||
containerType,
|
||||
]),
|
||||
);
|
||||
|
||||
for (const demoBooking of DEMO_BOOKINGS) {
|
||||
const origin = yardByCode.get(demoBooking.originCode);
|
||||
const destination = yardByCode.get(demoBooking.destinationCode);
|
||||
const containerType = containerTypeByCode.get(demoBooking.containerCode);
|
||||
const containerType = containerTypeByCode.get(
|
||||
demoBooking.containerCode,
|
||||
);
|
||||
|
||||
if (!origin || !destination || !containerType) {
|
||||
throw new Error(`demo_booking_seed_dependency_missing:${demoBooking.reference}`);
|
||||
throw new Error(
|
||||
`demo_booking_seed_dependency_missing:${demoBooking.reference}`,
|
||||
);
|
||||
}
|
||||
|
||||
const vgmPerUnitTons = demoBooking.totalWeightTons / demoBooking.quantity;
|
||||
const vgmPerUnitTons =
|
||||
demoBooking.totalWeightTons / demoBooking.quantity;
|
||||
|
||||
await manager.getRepository(Booking).upsert(
|
||||
{
|
||||
reference: demoBooking.reference,
|
||||
customerId: customer.id,
|
||||
status: 'APPROVED',
|
||||
companyId: customer.id,
|
||||
status: "APPROVED",
|
||||
scheduledDate: new Date(demoBooking.scheduledDate),
|
||||
totalAmount: 0,
|
||||
paymentStatus: 'PENDING',
|
||||
contractType: 'NEW',
|
||||
paymentStatus: "PENDING",
|
||||
contractType: "NEW",
|
||||
serviceTypeId: serviceType.id,
|
||||
equipmentReturn: 'WITHOUT_RETURN',
|
||||
equipmentReturn: "WITHOUT_RETURN",
|
||||
originYardId: origin.id,
|
||||
destinationYardId: destination.id,
|
||||
tradeDirection: 'IMPORT',
|
||||
freightType: 'CONTAINER',
|
||||
tradeDirection: "IMPORT",
|
||||
freightType: "CONTAINER",
|
||||
cargoTypeId: null,
|
||||
cargoFreeText: null,
|
||||
shippingLineId: null,
|
||||
cargoTotalWeightVgm: demoBooking.totalWeightTons,
|
||||
isHazardous: false,
|
||||
paymentCurrency: 'USD',
|
||||
paymentCurrency: "USD",
|
||||
allowConsolidation: false,
|
||||
priorityScore: 0,
|
||||
versionNumber: 1,
|
||||
@@ -252,7 +276,9 @@ export class DemoBookingsSeeder {
|
||||
reference: demoBooking.reference,
|
||||
});
|
||||
|
||||
await manager.getRepository(BookingContainer).delete({ bookingId: booking.id });
|
||||
await manager
|
||||
.getRepository(BookingContainer)
|
||||
.delete({ bookingId: booking.id });
|
||||
await manager.getRepository(BookingContainer).insert({
|
||||
id: randomUUID(),
|
||||
bookingId: booking.id,
|
||||
@@ -264,11 +290,13 @@ export class DemoBookingsSeeder {
|
||||
weightLimitRuleId: null,
|
||||
isOverweight: demoBooking.totalWeightTons > 70,
|
||||
overweightExcessTons:
|
||||
demoBooking.totalWeightTons > 70 ? demoBooking.totalWeightTons - 70 : null,
|
||||
demoBooking.totalWeightTons > 70
|
||||
? demoBooking.totalWeightTons - 70
|
||||
: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.logger.log('Seeded demo train scheduling data');
|
||||
this.logger.log("Seeded demo train scheduling data");
|
||||
}
|
||||
}
|
||||
|
||||
800
apps/edr-freight-api/src/seed/pricing-data.seeder.ts
Normal file
800
apps/edr-freight-api/src/seed/pricing-data.seeder.ts
Normal file
@@ -0,0 +1,800 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { DataSource } from "typeorm";
|
||||
|
||||
import { BookingCargoModifier } from "../modules/bookings/entities/booking-cargo-modifier.entity";
|
||||
import { CargoType } from "../modules/rule-engine/entities/cargo-type.entity";
|
||||
import { ContainerType } from "../modules/rule-engine/entities/container-type.entity";
|
||||
import { PriorityRule } from "../modules/rule-engine/entities/priority-rule.entity";
|
||||
import { Rate } from "../modules/rule-engine/entities/rate.entity";
|
||||
import { ServiceType } from "../modules/rule-engine/entities/service-type.entity";
|
||||
import { ShippingLine } from "../modules/rule-engine/entities/shipping-line.entity";
|
||||
import { SurchargeType } from "../modules/rule-engine/entities/surcharge-type.entity";
|
||||
import { WeightLimitRule } from "../modules/rule-engine/entities/weight-limit-rule.entity";
|
||||
import { Yard } from "../modules/rule-engine/entities/yard.entity";
|
||||
|
||||
const STAFF_USER_ID = "00000000-0000-0000-0000-000000000001";
|
||||
const CEO_USER_ID = "00000000-0000-0000-0000-000000000002";
|
||||
|
||||
@Injectable()
|
||||
export class PricingDataSeeder {
|
||||
private readonly logger = new Logger(PricingDataSeeder.name);
|
||||
|
||||
constructor(private readonly dataSource: DataSource) { }
|
||||
|
||||
async run(): Promise<void> {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const ctRepo = manager.getRepository(ContainerType);
|
||||
const stRepo = manager.getRepository(ServiceType);
|
||||
const yRepo = manager.getRepository(Yard);
|
||||
const slRepo = manager.getRepository(ShippingLine);
|
||||
const wlRepo = manager.getRepository(WeightLimitRule);
|
||||
const prRepo = manager.getRepository(PriorityRule);
|
||||
const rRepo = manager.getRepository(Rate);
|
||||
|
||||
await this.upsertReferenceData(manager, ctRepo, stRepo, yRepo, slRepo);
|
||||
await this.seedWeightLimits(wlRepo, ctRepo);
|
||||
await this.seedPriorityRules(prRepo);
|
||||
const containerTypes = await ctRepo.find();
|
||||
const ctByCode = new Map(containerTypes.map((ct) => [ct.code, ct]));
|
||||
|
||||
const rates = await this.seedRates(rRepo, ctByCode);
|
||||
const ratesByType = new Map<string, Rate[]>();
|
||||
for (const r of rates) {
|
||||
const key = `${r.rateType}|${r.currency}|${r.containerTypeId ?? ""}`;
|
||||
if (!ratesByType.has(key)) ratesByType.set(key, []);
|
||||
ratesByType.get(key)!.push(r);
|
||||
}
|
||||
|
||||
await this.seedSurchargeTypes(manager, ratesByType);
|
||||
|
||||
const yards = await yRepo.find();
|
||||
const yardByCode = new Map(yards.map((y) => [y.code, y]));
|
||||
const serviceTypes = await stRepo.find();
|
||||
const stByCode = new Map(serviceTypes.map((st) => [st.code, st]));
|
||||
const shippingLines = await slRepo.find();
|
||||
const slByCode = new Map(shippingLines.map((sl) => [sl.code, sl]));
|
||||
const cargoTypes = await manager.getRepository(CargoType).find();
|
||||
const cargoByCode = new Map(cargoTypes.map((c) => [c.code, c]));
|
||||
|
||||
await this.seedDraftBookings(
|
||||
ctByCode,
|
||||
yardByCode,
|
||||
stByCode,
|
||||
slByCode,
|
||||
cargoByCode,
|
||||
);
|
||||
});
|
||||
|
||||
this.logger.log("Seeded pricing data");
|
||||
}
|
||||
|
||||
private async upsertReferenceData(
|
||||
manager: any,
|
||||
ctRepo: any,
|
||||
stRepo: any,
|
||||
yRepo: any,
|
||||
slRepo: any,
|
||||
): Promise<void> {
|
||||
await yRepo.upsert(
|
||||
[
|
||||
{
|
||||
code: "DJIBOUTI",
|
||||
label: "Djibouti",
|
||||
country: "Djibouti",
|
||||
displayOrder: 1,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
code: "ADDIS_ABABA",
|
||||
label: "Addis Ababa",
|
||||
country: "Ethiopia",
|
||||
displayOrder: 2,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
code: "DIRE_DAWA",
|
||||
label: "Dire Dawa",
|
||||
country: "Ethiopia",
|
||||
displayOrder: 3,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
code: "MODJO",
|
||||
label: "Modjo",
|
||||
country: "Ethiopia",
|
||||
displayOrder: 4,
|
||||
isActive: true,
|
||||
},
|
||||
],
|
||||
{ conflictPaths: { code: true } },
|
||||
);
|
||||
|
||||
await ctRepo.upsert(
|
||||
[
|
||||
{
|
||||
code: "20FT",
|
||||
label: "20FT Standard",
|
||||
sizeFt: 20,
|
||||
wagonsPerUnit: 1,
|
||||
isReefer: false,
|
||||
isOpenTop: false,
|
||||
isActive: true,
|
||||
displayOrder: 1,
|
||||
},
|
||||
{
|
||||
code: "40FT",
|
||||
label: "40FT Standard",
|
||||
sizeFt: 40,
|
||||
wagonsPerUnit: 1,
|
||||
isReefer: false,
|
||||
isOpenTop: false,
|
||||
isActive: true,
|
||||
displayOrder: 2,
|
||||
},
|
||||
{
|
||||
code: "20FT_REEFER",
|
||||
label: "20FT Reefer",
|
||||
sizeFt: 20,
|
||||
wagonsPerUnit: 1,
|
||||
isReefer: true,
|
||||
isOpenTop: false,
|
||||
isActive: true,
|
||||
displayOrder: 3,
|
||||
},
|
||||
{
|
||||
code: "40FT_REEFER",
|
||||
label: "40FT Reefer",
|
||||
sizeFt: 40,
|
||||
wagonsPerUnit: 1,
|
||||
isReefer: true,
|
||||
isOpenTop: false,
|
||||
isActive: true,
|
||||
displayOrder: 4,
|
||||
},
|
||||
],
|
||||
{ conflictPaths: { code: true } },
|
||||
);
|
||||
|
||||
await stRepo.upsert(
|
||||
[
|
||||
{
|
||||
code: "RAIL_CONTAINER",
|
||||
serviceName: "Rail Container Service",
|
||||
description: "Standard rail container transport",
|
||||
canBeBookedAlone: true,
|
||||
includesFirstMile: false,
|
||||
includesLastMile: false,
|
||||
includesCustoms: false,
|
||||
priorityBonusPoints: 0,
|
||||
isActive: true,
|
||||
displayOrder: 1,
|
||||
},
|
||||
{
|
||||
code: "RAIL_FORWARDING",
|
||||
serviceName: "Rail Forwarding Service",
|
||||
description: "Rail transport with first/last mile and customs",
|
||||
canBeBookedAlone: true,
|
||||
includesFirstMile: true,
|
||||
includesLastMile: true,
|
||||
includesCustoms: true,
|
||||
priorityBonusPoints: 100,
|
||||
isActive: true,
|
||||
displayOrder: 2,
|
||||
},
|
||||
{
|
||||
code: "RAIL_BULK",
|
||||
serviceName: "Rail Bulk Transport",
|
||||
description: "Bulk commodity rail transport",
|
||||
canBeBookedAlone: true,
|
||||
includesFirstMile: false,
|
||||
includesLastMile: false,
|
||||
includesCustoms: false,
|
||||
priorityBonusPoints: 50,
|
||||
isActive: true,
|
||||
displayOrder: 3,
|
||||
},
|
||||
],
|
||||
{ conflictPaths: { code: true } },
|
||||
);
|
||||
|
||||
await slRepo.upsert(
|
||||
[
|
||||
{
|
||||
code: "MAERSK",
|
||||
label: "Maersk Line",
|
||||
mappedToCode: "MAERSK",
|
||||
showExtraFeeNotice: true,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
code: "MSC",
|
||||
label: "MSC",
|
||||
mappedToCode: "MSC",
|
||||
showExtraFeeNotice: true,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
code: "CMA_CGM",
|
||||
label: "CMA CGM",
|
||||
mappedToCode: "CMA_CGM",
|
||||
showExtraFeeNotice: true,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
code: "COSCO",
|
||||
label: "COSCO Shipping",
|
||||
mappedToCode: "COSCO",
|
||||
showExtraFeeNotice: true,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
code: "OTHER",
|
||||
label: "Other Line",
|
||||
mappedToCode: null,
|
||||
showExtraFeeNotice: false,
|
||||
isActive: true,
|
||||
},
|
||||
],
|
||||
{ conflictPaths: { code: true } },
|
||||
);
|
||||
|
||||
await manager.getRepository(CargoType).upsert(
|
||||
[
|
||||
{
|
||||
code: "GRAIN",
|
||||
cargoTypeName: "Grain / Cereals",
|
||||
requiresDirectorApproval: false,
|
||||
isActive: true,
|
||||
displayOrder: 1,
|
||||
},
|
||||
{
|
||||
code: "FERTILIZER",
|
||||
cargoTypeName: "Fertilizer",
|
||||
requiresDirectorApproval: false,
|
||||
isActive: true,
|
||||
displayOrder: 2,
|
||||
},
|
||||
{
|
||||
code: "CEMENT",
|
||||
cargoTypeName: "Cement / Clinker",
|
||||
requiresDirectorApproval: false,
|
||||
isActive: true,
|
||||
displayOrder: 3,
|
||||
},
|
||||
{
|
||||
code: "STEEL",
|
||||
cargoTypeName: "Steel / Rebar",
|
||||
requiresDirectorApproval: true,
|
||||
isActive: true,
|
||||
displayOrder: 4,
|
||||
},
|
||||
{
|
||||
code: "MACHINERY",
|
||||
cargoTypeName: "Heavy Machinery",
|
||||
requiresDirectorApproval: true,
|
||||
isActive: true,
|
||||
displayOrder: 5,
|
||||
},
|
||||
{
|
||||
code: "OTHER_BULK",
|
||||
cargoTypeName: "Other Bulk Cargo",
|
||||
requiresDirectorApproval: false,
|
||||
isActive: true,
|
||||
displayOrder: 6,
|
||||
},
|
||||
],
|
||||
{ conflictPaths: { code: true } },
|
||||
);
|
||||
}
|
||||
|
||||
private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
|
||||
await wlRepo.createQueryBuilder().delete().execute();
|
||||
const twenty = await ctRepo.findOneByOrFail({ code: "20FT" });
|
||||
const forty = await ctRepo.findOneByOrFail({ code: "40FT" });
|
||||
const base = new Date("2026-01-01");
|
||||
await wlRepo.insert([
|
||||
{
|
||||
containerTypeId: twenty.id,
|
||||
tradeDirection: "IMPORT",
|
||||
maxVgmTons: 26,
|
||||
effectiveFrom: base,
|
||||
},
|
||||
{
|
||||
containerTypeId: twenty.id,
|
||||
tradeDirection: "EXPORT",
|
||||
maxVgmTons: 26,
|
||||
effectiveFrom: base,
|
||||
},
|
||||
{
|
||||
containerTypeId: forty.id,
|
||||
tradeDirection: "IMPORT",
|
||||
maxVgmTons: 28,
|
||||
effectiveFrom: base,
|
||||
},
|
||||
{
|
||||
containerTypeId: forty.id,
|
||||
tradeDirection: "EXPORT",
|
||||
maxVgmTons: 28,
|
||||
effectiveFrom: base,
|
||||
},
|
||||
]);
|
||||
this.logger.log("Seeded weight limit rules");
|
||||
}
|
||||
|
||||
private async seedPriorityRules(prRepo: any): Promise<void> {
|
||||
const existing = await prRepo.find({
|
||||
where: [{ code: "USD_PRIORITY" }, { code: "STANDARD_PRIORITY" }],
|
||||
});
|
||||
for (const r of existing) {
|
||||
await prRepo.remove(r);
|
||||
}
|
||||
await prRepo.save([
|
||||
prRepo.create({
|
||||
code: "USD_PRIORITY",
|
||||
label: "USD Payment Priority",
|
||||
score: 200,
|
||||
conditionCurrency: "USD",
|
||||
isActive: true,
|
||||
}),
|
||||
prRepo.create({
|
||||
code: "STANDARD_PRIORITY",
|
||||
label: "Standard Priority",
|
||||
score: 50,
|
||||
conditionCurrency: null,
|
||||
isActive: true,
|
||||
}),
|
||||
]);
|
||||
this.logger.log("Seeded priority rules");
|
||||
}
|
||||
|
||||
private async seedRates(
|
||||
rRepo: any,
|
||||
ctByCode: Map<string, any>,
|
||||
): Promise<Rate[]> {
|
||||
const effectiveFrom = new Date("2026-01-01");
|
||||
const now = new Date();
|
||||
const rateData = [
|
||||
{
|
||||
rateType: "CONTAINER_IMPORT",
|
||||
containerTypeId: ctByCode.get("20FT")!.id,
|
||||
currency: "USD",
|
||||
rateValue: 800,
|
||||
rateUnit: "PER_CONTAINER",
|
||||
},
|
||||
{
|
||||
rateType: "CONTAINER_IMPORT",
|
||||
containerTypeId: ctByCode.get("40FT")!.id,
|
||||
currency: "USD",
|
||||
rateValue: 1200,
|
||||
rateUnit: "PER_CONTAINER",
|
||||
},
|
||||
{
|
||||
rateType: "CONTAINER_IMPORT",
|
||||
containerTypeId: ctByCode.get("20FT")!.id,
|
||||
currency: "ETB",
|
||||
rateValue: 45000,
|
||||
rateUnit: "PER_CONTAINER",
|
||||
},
|
||||
{
|
||||
rateType: "CONTAINER_IMPORT",
|
||||
containerTypeId: ctByCode.get("40FT")!.id,
|
||||
currency: "ETB",
|
||||
rateValue: 67000,
|
||||
rateUnit: "PER_CONTAINER",
|
||||
},
|
||||
{
|
||||
rateType: "CONTAINER_EXPORT",
|
||||
containerTypeId: ctByCode.get("20FT")!.id,
|
||||
currency: "USD",
|
||||
rateValue: 600,
|
||||
rateUnit: "PER_CONTAINER",
|
||||
},
|
||||
{
|
||||
rateType: "CONTAINER_EXPORT",
|
||||
containerTypeId: ctByCode.get("40FT")!.id,
|
||||
currency: "USD",
|
||||
rateValue: 900,
|
||||
rateUnit: "PER_CONTAINER",
|
||||
},
|
||||
{
|
||||
rateType: "CONTAINER_EXPORT",
|
||||
containerTypeId: ctByCode.get("20FT")!.id,
|
||||
currency: "ETB",
|
||||
rateValue: 34000,
|
||||
rateUnit: "PER_CONTAINER",
|
||||
},
|
||||
{
|
||||
rateType: "CONTAINER_EXPORT",
|
||||
containerTypeId: ctByCode.get("40FT")!.id,
|
||||
currency: "ETB",
|
||||
rateValue: 50000,
|
||||
rateUnit: "PER_CONTAINER",
|
||||
},
|
||||
{
|
||||
rateType: "INTERCITY_CONTAINER",
|
||||
containerTypeId: ctByCode.get("20FT")!.id,
|
||||
currency: "ETB",
|
||||
rateValue: 20000,
|
||||
rateUnit: "PER_CONTAINER",
|
||||
},
|
||||
{
|
||||
rateType: "INTERCITY_CONTAINER",
|
||||
containerTypeId: ctByCode.get("40FT")!.id,
|
||||
currency: "ETB",
|
||||
rateValue: 30000,
|
||||
rateUnit: "PER_CONTAINER",
|
||||
},
|
||||
{
|
||||
rateType: "CONTAINER_IMPORT",
|
||||
containerTypeId: null,
|
||||
currency: "USD",
|
||||
rateValue: 1000,
|
||||
rateUnit: "PER_CONTAINER",
|
||||
},
|
||||
{
|
||||
rateType: "CONTAINER_IMPORT",
|
||||
containerTypeId: null,
|
||||
currency: "ETB",
|
||||
rateValue: 56000,
|
||||
rateUnit: "PER_CONTAINER",
|
||||
},
|
||||
{
|
||||
rateType: "CONTAINER_EXPORT",
|
||||
containerTypeId: null,
|
||||
currency: "USD",
|
||||
rateValue: 750,
|
||||
rateUnit: "PER_CONTAINER",
|
||||
},
|
||||
{
|
||||
rateType: "CONTAINER_EXPORT",
|
||||
containerTypeId: null,
|
||||
currency: "ETB",
|
||||
rateValue: 42000,
|
||||
rateUnit: "PER_CONTAINER",
|
||||
},
|
||||
{
|
||||
rateType: "INTERCITY_CONTAINER",
|
||||
containerTypeId: null,
|
||||
currency: "ETB",
|
||||
rateValue: 25000,
|
||||
rateUnit: "PER_CONTAINER",
|
||||
},
|
||||
{
|
||||
rateType: "BULK_IMPORT",
|
||||
containerTypeId: null,
|
||||
currency: "USD",
|
||||
rateValue: 50,
|
||||
rateUnit: "PER_TON",
|
||||
},
|
||||
{
|
||||
rateType: "BULK_IMPORT",
|
||||
containerTypeId: null,
|
||||
currency: "ETB",
|
||||
rateValue: 2800,
|
||||
rateUnit: "PER_TON",
|
||||
},
|
||||
{
|
||||
rateType: "BULK_EXPORT",
|
||||
containerTypeId: null,
|
||||
currency: "USD",
|
||||
rateValue: 40,
|
||||
rateUnit: "PER_TON",
|
||||
},
|
||||
{
|
||||
rateType: "BULK_EXPORT",
|
||||
containerTypeId: null,
|
||||
currency: "ETB",
|
||||
rateValue: 2200,
|
||||
rateUnit: "PER_TON",
|
||||
},
|
||||
{
|
||||
rateType: "OVERWEIGHT_PER_TON",
|
||||
containerTypeId: null,
|
||||
currency: "USD",
|
||||
rateValue: 25,
|
||||
rateUnit: "PER_TON",
|
||||
},
|
||||
{
|
||||
rateType: "OVERWEIGHT_PER_TON",
|
||||
containerTypeId: null,
|
||||
currency: "ETB",
|
||||
rateValue: 1400,
|
||||
rateUnit: "PER_TON",
|
||||
},
|
||||
{
|
||||
rateType: "HAZARD_SURCHARGE",
|
||||
containerTypeId: null,
|
||||
currency: "USD",
|
||||
rateValue: 150,
|
||||
rateUnit: "FLAT",
|
||||
},
|
||||
{
|
||||
rateType: "HAZARD_SURCHARGE",
|
||||
containerTypeId: null,
|
||||
currency: "ETB",
|
||||
rateValue: 8500,
|
||||
rateUnit: "FLAT",
|
||||
},
|
||||
{
|
||||
rateType: "REEFER_SURCHARGE",
|
||||
containerTypeId: null,
|
||||
currency: "USD",
|
||||
rateValue: 200,
|
||||
rateUnit: "FLAT",
|
||||
},
|
||||
{
|
||||
rateType: "REEFER_SURCHARGE",
|
||||
containerTypeId: null,
|
||||
currency: "ETB",
|
||||
rateValue: 11000,
|
||||
rateUnit: "FLAT",
|
||||
},
|
||||
{
|
||||
rateType: "DOUBLE_HANDLING",
|
||||
containerTypeId: null,
|
||||
currency: "USD",
|
||||
rateValue: 100,
|
||||
rateUnit: "PER_CONTAINER",
|
||||
},
|
||||
{
|
||||
rateType: "DOUBLE_HANDLING",
|
||||
containerTypeId: null,
|
||||
currency: "ETB",
|
||||
rateValue: 5500,
|
||||
rateUnit: "PER_CONTAINER",
|
||||
},
|
||||
{
|
||||
rateType: "LASHING",
|
||||
containerTypeId: null,
|
||||
currency: "USD",
|
||||
rateValue: 50,
|
||||
rateUnit: "PER_CONTAINER",
|
||||
},
|
||||
{
|
||||
rateType: "LASHING",
|
||||
containerTypeId: null,
|
||||
currency: "ETB",
|
||||
rateValue: 2800,
|
||||
rateUnit: "PER_CONTAINER",
|
||||
},
|
||||
];
|
||||
|
||||
const entities = rateData.map((d) =>
|
||||
rRepo.create({
|
||||
...d,
|
||||
status: "LIVE",
|
||||
proposedByStaffId: STAFF_USER_ID,
|
||||
approvedByCeoId: CEO_USER_ID,
|
||||
approvedAt: now,
|
||||
effectiveFrom,
|
||||
}),
|
||||
);
|
||||
return rRepo.save(entities);
|
||||
}
|
||||
|
||||
private async seedSurchargeTypes(
|
||||
manager: any,
|
||||
ratesByType: Map<string, Rate[]>,
|
||||
): Promise<void> {
|
||||
const surRepo = manager.getRepository(SurchargeType);
|
||||
const bcmRepo = manager.getRepository(BookingCargoModifier);
|
||||
await bcmRepo.createQueryBuilder().delete().execute();
|
||||
const findRate = (rateType: string, currency: string) => {
|
||||
const key = `${rateType}|${currency}|`;
|
||||
const rates = ratesByType.get(key);
|
||||
return rates?.[0];
|
||||
};
|
||||
|
||||
const hazardRateUsd = findRate("HAZARD_SURCHARGE", "USD");
|
||||
const hazardRateEtb = findRate("HAZARD_SURCHARGE", "ETB");
|
||||
const reeferRateUsd = findRate("REEFER_SURCHARGE", "USD");
|
||||
const reeferRateEtb = findRate("REEFER_SURCHARGE", "ETB");
|
||||
const overweightRateUsd = findRate("OVERWEIGHT_PER_TON", "USD");
|
||||
const overweightRateEtb = findRate("OVERWEIGHT_PER_TON", "ETB");
|
||||
const shipLineRateUsd = findRate("DOUBLE_HANDLING", "USD");
|
||||
const shipLineRateEtb = findRate("DOUBLE_HANDLING", "ETB");
|
||||
const consolidRateUsd = findRate("LASHING", "USD");
|
||||
const consolidRateEtb = findRate("LASHING", "ETB");
|
||||
|
||||
await surRepo.createQueryBuilder().delete().execute();
|
||||
await surRepo.save([
|
||||
surRepo.create({
|
||||
code: "HAZARDOUS_CARGO",
|
||||
label: "Hazardous Cargo",
|
||||
triggerCondition: "CARGO_FLAG_HAZARDOUS",
|
||||
rateId: hazardRateUsd?.id ?? hazardRateEtb?.id,
|
||||
isActive: true,
|
||||
}),
|
||||
surRepo.create({
|
||||
code: "REEFER_CARGO",
|
||||
label: "Reefer Cargo",
|
||||
triggerCondition: "CARGO_FLAG_REEFER",
|
||||
rateId: reeferRateUsd?.id ?? reeferRateEtb?.id,
|
||||
isActive: true,
|
||||
}),
|
||||
surRepo.create({
|
||||
code: "OVERWEIGHT_CARGO",
|
||||
label: "Overweight Cargo",
|
||||
triggerCondition: "VGM_EXCEEDS_LIMIT",
|
||||
rateId: overweightRateUsd?.id ?? overweightRateEtb?.id,
|
||||
isActive: true,
|
||||
}),
|
||||
surRepo.create({
|
||||
code: "SHIPPING_LINE_FEE",
|
||||
label: "Shipping Line Fee",
|
||||
triggerCondition: "SHIPPING_LINE_MAPPED",
|
||||
rateId: shipLineRateUsd?.id ?? shipLineRateEtb?.id,
|
||||
isActive: true,
|
||||
}),
|
||||
surRepo.create({
|
||||
code: "CONSOLIDATION_FEE",
|
||||
label: "Consolidation Fee",
|
||||
triggerCondition: "CONSOLIDATION_ENABLED",
|
||||
rateId: consolidRateUsd?.id ?? consolidRateEtb?.id,
|
||||
isActive: true,
|
||||
}),
|
||||
]);
|
||||
this.logger.log("Seeded surcharge types");
|
||||
}
|
||||
|
||||
private async seedDraftBookings(
|
||||
ctByCode: Map<string, any>,
|
||||
yardByCode: Map<string, any>,
|
||||
stByCode: Map<string, any>,
|
||||
slByCode: Map<string, any>,
|
||||
cargoByCode: Map<string, any>,
|
||||
): Promise<void> {
|
||||
const djibouti = yardByCode.get("DJIBOUTI")!;
|
||||
const addis = yardByCode.get("ADDIS_ABABA")!;
|
||||
const railContainer = stByCode.get("RAIL_CONTAINER")!;
|
||||
const railBulk = stByCode.get("RAIL_BULK")!;
|
||||
const maersk = slByCode.get("MAERSK")!;
|
||||
const grain = cargoByCode.get("GRAIN")!;
|
||||
const twenty = ctByCode.get("20FT")!;
|
||||
const forty = ctByCode.get("40FT")!;
|
||||
const twentyReefer = ctByCode.get("20FT_REEFER")!;
|
||||
|
||||
const drafts = [
|
||||
{
|
||||
reference: "BKG-PRICE-001",
|
||||
description: "Standard 20FT container import — base rail only",
|
||||
freightType: "CONTAINER" as const,
|
||||
tradeDirection: "IMPORT",
|
||||
paymentCurrency: "USD",
|
||||
serviceTypeId: railContainer.id,
|
||||
originYardId: djibouti.id,
|
||||
destinationYardId: addis.id,
|
||||
isHazardous: false,
|
||||
allowConsolidation: false,
|
||||
shippingLineId: null,
|
||||
cargoTypeId: null,
|
||||
cargoTotalWeightVgm: 250,
|
||||
containers: [
|
||||
{ containerTypeId: twenty.id, quantity: 10, vgmPerUnitTons: 25 },
|
||||
],
|
||||
expectedBaseRate: 800,
|
||||
expectedSurcharges: [],
|
||||
},
|
||||
{
|
||||
reference: "BKG-PRICE-002",
|
||||
description: "40FT container import + hazardous surcharge",
|
||||
freightType: "CONTAINER" as const,
|
||||
tradeDirection: "IMPORT",
|
||||
paymentCurrency: "USD",
|
||||
serviceTypeId: railContainer.id,
|
||||
originYardId: djibouti.id,
|
||||
destinationYardId: addis.id,
|
||||
isHazardous: true,
|
||||
allowConsolidation: false,
|
||||
shippingLineId: null,
|
||||
cargoTypeId: null,
|
||||
cargoTotalWeightVgm: 135,
|
||||
containers: [
|
||||
{ containerTypeId: forty.id, quantity: 5, vgmPerUnitTons: 27 },
|
||||
],
|
||||
expectedBaseRate: 1200,
|
||||
expectedSurcharges: ["HAZARDOUS_CARGO"],
|
||||
},
|
||||
{
|
||||
reference: "BKG-PRICE-003",
|
||||
description: "20FT container import + shipping line (ETB)",
|
||||
freightType: "CONTAINER" as const,
|
||||
tradeDirection: "IMPORT",
|
||||
paymentCurrency: "ETB",
|
||||
serviceTypeId: railContainer.id,
|
||||
originYardId: djibouti.id,
|
||||
destinationYardId: addis.id,
|
||||
isHazardous: false,
|
||||
allowConsolidation: false,
|
||||
shippingLineId: maersk.id,
|
||||
cargoTypeId: null,
|
||||
cargoTotalWeightVgm: 480,
|
||||
containers: [
|
||||
{ containerTypeId: twenty.id, quantity: 20, vgmPerUnitTons: 24 },
|
||||
],
|
||||
expectedBaseRate: 45000,
|
||||
expectedSurcharges: ["SHIPPING_LINE_FEE"],
|
||||
},
|
||||
{
|
||||
reference: "BKG-PRICE-004",
|
||||
description: "40FT container import + consolidation (USD)",
|
||||
freightType: "CONTAINER" as const,
|
||||
tradeDirection: "IMPORT",
|
||||
paymentCurrency: "USD",
|
||||
serviceTypeId: railContainer.id,
|
||||
originYardId: djibouti.id,
|
||||
destinationYardId: addis.id,
|
||||
isHazardous: false,
|
||||
allowConsolidation: true,
|
||||
shippingLineId: null,
|
||||
cargoTypeId: null,
|
||||
cargoTotalWeightVgm: 224,
|
||||
containers: [
|
||||
{ containerTypeId: forty.id, quantity: 8, vgmPerUnitTons: 28 },
|
||||
],
|
||||
expectedBaseRate: 1200,
|
||||
expectedSurcharges: ["CONSOLIDATION_FEE"],
|
||||
},
|
||||
{
|
||||
reference: "BKG-PRICE-005",
|
||||
description: "Bulk import — grain",
|
||||
freightType: "BULK" as const,
|
||||
tradeDirection: "IMPORT",
|
||||
paymentCurrency: "USD",
|
||||
serviceTypeId: railBulk.id,
|
||||
originYardId: djibouti.id,
|
||||
destinationYardId: addis.id,
|
||||
isHazardous: false,
|
||||
allowConsolidation: false,
|
||||
shippingLineId: null,
|
||||
cargoTypeId: grain.id,
|
||||
cargoTotalWeightVgm: 500,
|
||||
containers: [],
|
||||
expectedBaseRate: 50,
|
||||
expectedSurcharges: [],
|
||||
},
|
||||
{
|
||||
reference: "BKG-PRICE-006",
|
||||
description: "20FT reefer container import + reefer surcharge",
|
||||
freightType: "CONTAINER" as const,
|
||||
tradeDirection: "IMPORT",
|
||||
paymentCurrency: "USD",
|
||||
serviceTypeId: railContainer.id,
|
||||
originYardId: djibouti.id,
|
||||
destinationYardId: addis.id,
|
||||
isHazardous: false,
|
||||
allowConsolidation: false,
|
||||
shippingLineId: null,
|
||||
cargoTypeId: null,
|
||||
cargoTotalWeightVgm: 75,
|
||||
containers: [
|
||||
{ containerTypeId: twentyReefer.id, quantity: 3, vgmPerUnitTons: 25 },
|
||||
],
|
||||
expectedBaseRate: 800,
|
||||
expectedSurcharges: ["REEFER_CARGO"],
|
||||
},
|
||||
{
|
||||
reference: "BKG-PRICE-007",
|
||||
description: "20FT container import + overweight (30t > 26t limit)",
|
||||
freightType: "CONTAINER" as const,
|
||||
tradeDirection: "IMPORT",
|
||||
paymentCurrency: "USD",
|
||||
serviceTypeId: railContainer.id,
|
||||
originYardId: djibouti.id,
|
||||
destinationYardId: addis.id,
|
||||
isHazardous: false,
|
||||
allowConsolidation: false,
|
||||
shippingLineId: null,
|
||||
cargoTypeId: null,
|
||||
cargoTotalWeightVgm: 300,
|
||||
containers: [
|
||||
{ containerTypeId: twenty.id, quantity: 10, vgmPerUnitTons: 30 },
|
||||
],
|
||||
expectedBaseRate: 800,
|
||||
expectedSurcharges: ["OVERWEIGHT_CARGO"],
|
||||
},
|
||||
];
|
||||
|
||||
this.logger.log(`Seeded ${drafts.length} DRAFT bookings for pricing`);
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
ShieldCheck,
|
||||
AlertTriangle,
|
||||
Info,
|
||||
Clock,
|
||||
Layers,
|
||||
CheckCircle2,
|
||||
History,
|
||||
@@ -36,7 +35,6 @@ import {
|
||||
import Breadcrumbs from "@/components/Breadcrumbs";
|
||||
import { api } from "@/services/api";
|
||||
import type { Freight } from "@edr/types";
|
||||
import type { GeneratePriceResponse } from "@/services/bookings.service";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
@@ -46,6 +44,14 @@ import {
|
||||
Badge,
|
||||
Button,
|
||||
Separator,
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogClose,
|
||||
} from "@edr/ui-common";
|
||||
import { cn } from "@/lib/utils";
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
@@ -57,12 +63,40 @@ const PROGRESS_STAGES = [
|
||||
{ label: "Complete", icon: PackageCheck, statuses: ["DELIVERED"] },
|
||||
];
|
||||
|
||||
const STATUS_MAP: Record<string, { title: string; description: string; color: string; stage: number }> = {
|
||||
DRAFT: { title: "Drafting Request", description: "Booking is being prepared and has not been submitted.", color: "text-slate-500", stage: 0 },
|
||||
CONFIRMED: { title: "Booking Confirmed", description: "Booking has been confirmed and approved.", color: "text-emerald-600", stage: 1 },
|
||||
IN_TRANSIT: { title: "Cargo Moving", description: "Shipment is currently moving through the rail network.", color: "text-sky-600", stage: 2 },
|
||||
DELIVERED: { title: "Service Complete", description: "Cargo delivered and service successfully terminated.", color: "text-emerald-600", stage: 3 },
|
||||
CANCELLED: { title: "Cancelled", description: "This booking process has been terminated.", color: "text-red-600", stage: -1 },
|
||||
const STATUS_MAP: Record<
|
||||
string,
|
||||
{ title: string; description: string; color: string; stage: number }
|
||||
> = {
|
||||
DRAFT: {
|
||||
title: "Drafting Request",
|
||||
description: "Booking is being prepared and has not been submitted.",
|
||||
color: "text-slate-500",
|
||||
stage: 0,
|
||||
},
|
||||
CONFIRMED: {
|
||||
title: "Booking Confirmed",
|
||||
description: "Booking has been confirmed and approved.",
|
||||
color: "text-emerald-600",
|
||||
stage: 1,
|
||||
},
|
||||
IN_TRANSIT: {
|
||||
title: "Cargo Moving",
|
||||
description: "Shipment is currently moving through the rail network.",
|
||||
color: "text-sky-600",
|
||||
stage: 2,
|
||||
},
|
||||
DELIVERED: {
|
||||
title: "Service Complete",
|
||||
description: "Cargo delivered and service successfully terminated.",
|
||||
color: "text-emerald-600",
|
||||
stage: 3,
|
||||
},
|
||||
CANCELLED: {
|
||||
title: "Cancelled",
|
||||
description: "This booking process has been terminated.",
|
||||
color: "text-red-600",
|
||||
stage: -1,
|
||||
},
|
||||
};
|
||||
|
||||
const REQUIRED_DOC_FIELDS = [
|
||||
@@ -74,10 +108,14 @@ const REQUIRED_DOC_FIELDS = [
|
||||
|
||||
export default function BookingDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: booking, isLoading, isError, error } = useQuery(
|
||||
const {
|
||||
data: booking,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
} = useQuery(
|
||||
api.bookings.get.queryOptions({
|
||||
input: { id: id! },
|
||||
enabled: !!id,
|
||||
@@ -85,7 +123,9 @@ export default function BookingDetailPage() {
|
||||
);
|
||||
|
||||
const refetchBooking = () => {
|
||||
queryClient.invalidateQueries({ queryKey: api.bookings.get.queryKey({ id: id! }) });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.bookings.get.queryKey({ id: id! }),
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
@@ -93,7 +133,9 @@ export default function BookingDetailPage() {
|
||||
<div className="container mx-auto flex items-center justify-center p-12">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<LoaderCircle className="size-8 animate-spin text-primary" />
|
||||
<p className="text-sm text-muted-foreground">Loading booking details…</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Loading booking details…
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -110,7 +152,9 @@ export default function BookingDetailPage() {
|
||||
Failed to load booking
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{error instanceof Error ? error.message : "An unexpected error occurred."}
|
||||
{error instanceof Error
|
||||
? error.message
|
||||
: "An unexpected error occurred."}
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -133,7 +177,9 @@ export default function BookingDetailPage() {
|
||||
}
|
||||
|
||||
if (booking.status === "DRAFT") {
|
||||
return <DraftBookingView booking={booking} onBookingUpdated={refetchBooking} />;
|
||||
return (
|
||||
<DraftBookingView booking={booking} onBookingUpdated={refetchBooking} />
|
||||
);
|
||||
}
|
||||
|
||||
return <ReadonlyBookingView booking={booking} />;
|
||||
@@ -151,20 +197,20 @@ function DraftBookingView({
|
||||
const { customer } = useAuth();
|
||||
const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
|
||||
|
||||
const [pricingData, setPricingData] = useState<GeneratePriceResponse | null>(null);
|
||||
const [selectedFiles, setSelectedFiles] = useState<Record<string, File | null>>({});
|
||||
const [selectedFiles, setSelectedFiles] = useState<
|
||||
Record<string, File | null>
|
||||
>({});
|
||||
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
|
||||
const [cancelReason, setCancelReason] = useState("");
|
||||
|
||||
const anyFileSelected = Object.values(selectedFiles).some(Boolean);
|
||||
const allDocsProvided = !anyFileSelected;
|
||||
|
||||
const priceMutation = useMutation({
|
||||
mutationFn: () => api.bookings.generatePrice.call({ id: booking.id }),
|
||||
onSuccess: (data) => {
|
||||
setPricingData(data);
|
||||
queryClient.invalidateQueries({ queryKey: api.bookings.get.queryKey({ id: booking.id }) });
|
||||
},
|
||||
});
|
||||
const pricingQuery = useQuery(
|
||||
api.bookings.generatePrice.queryOptions({
|
||||
input: { id: booking.id },
|
||||
enabled: !!booking.id,
|
||||
}),
|
||||
);
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: (files: Record<string, File | File[] | null>) =>
|
||||
@@ -187,6 +233,7 @@ function DraftBookingView({
|
||||
mutationFn: (reason: string) =>
|
||||
api.bookings.cancel.call({ id: booking.id, reason }),
|
||||
onSuccess: () => {
|
||||
setCancelDialogOpen(false);
|
||||
onBookingUpdated();
|
||||
},
|
||||
});
|
||||
@@ -211,17 +258,19 @@ function DraftBookingView({
|
||||
cancelMutation.mutate(reason);
|
||||
}
|
||||
|
||||
const canConfirm = !!pricingData && !uploadMutation.isPending && !submitMutation.isPending;
|
||||
const canConfirm =
|
||||
pricingQuery.isSuccess && !uploadMutation.isPending && !submitMutation.isPending;
|
||||
|
||||
const companyName = (customer as any)?.company?.name ?? "—";
|
||||
const companyTin = (customer as any)?.company?.tin ?? "—";
|
||||
const contactName = (customer as any)?.profile
|
||||
? `${(customer as any).profile.firstName ?? ""} ${(customer as any).profile.lastName ?? ""}`.trim() || "—"
|
||||
? `${(customer as any).profile.firstName ?? ""} ${(customer as any).profile.lastName ?? ""}`.trim() ||
|
||||
"—"
|
||||
: "—";
|
||||
const contactEmail = (customer as any)?.profile?.email ?? "—";
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-5xl px-4 py-8">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="flex flex-col gap-6">
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
@@ -251,14 +300,14 @@ function DraftBookingView({
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
{priceMutation.isError && (
|
||||
{pricingQuery.isError && (
|
||||
<div className="flex items-start gap-3 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-red-500" />
|
||||
<div>
|
||||
<p className="font-semibold">Pricing failed</p>
|
||||
<p className="mt-1 text-red-600">
|
||||
{priceMutation.error instanceof Error
|
||||
? priceMutation.error.message
|
||||
{pricingQuery.error instanceof Error
|
||||
? pricingQuery.error.message
|
||||
: "An unexpected error occurred."}
|
||||
</p>
|
||||
</div>
|
||||
@@ -307,50 +356,85 @@ function DraftBookingView({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card className={cn(pricingData ? "border-emerald-200 bg-emerald-50/30" : "")}>
|
||||
<Card
|
||||
className={cn(
|
||||
pricingQuery.isSuccess ? "border-emerald-200 bg-emerald-50/30" : "",
|
||||
)}
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<DollarSign className="size-4 text-primary" />
|
||||
Pricing Estimation
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Generate a price estimate based on your booking details.
|
||||
Price estimate based on your booking details.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{pricingData ? (
|
||||
{pricingQuery.isLoading ? (
|
||||
<div className="flex flex-col items-center gap-3 py-6 text-center">
|
||||
<LoaderCircle className="size-6 animate-spin text-primary" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Calculating price…
|
||||
</p>
|
||||
</div>
|
||||
) : pricingQuery.isError ? (
|
||||
<div className="flex flex-col items-center gap-3 py-6 text-center">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Could not calculate price.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => pricingQuery.refetch()}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
) : pricingQuery.data ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="bg-muted text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-4 py-2 font-semibold">Description</th>
|
||||
<th className="px-4 py-2 font-semibold text-right">Amount</th>
|
||||
<th className="px-4 py-2 font-semibold text-right">
|
||||
Amount
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{pricingData.lineItems.map((item, i) => (
|
||||
{pricingQuery.data.lineItems.map((item, i) => (
|
||||
<tr key={i}>
|
||||
<td className="px-4 py-2 text-foreground">{item.description}</td>
|
||||
<td className="px-4 py-2 text-foreground">
|
||||
{item.description}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right font-medium text-foreground">
|
||||
{item.amount.toLocaleString()} {item.currency}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr className="bg-primary/5 font-bold">
|
||||
<td className="px-4 py-2 text-foreground">Total Estimated Cost</td>
|
||||
<td className="px-4 py-2 text-foreground">
|
||||
Total Estimated Cost
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right text-foreground">
|
||||
{pricingData.totalAmount.toLocaleString()} {pricingData.currency}
|
||||
{pricingQuery.data.totalAmount.toLocaleString()}{" "}
|
||||
{pricingQuery.data.currency}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{pricingData.warnings.length > 0 && (
|
||||
{pricingQuery.data.warnings.length > 0 && (
|
||||
<div className="flex flex-col gap-2 rounded-lg border border-amber-200 bg-amber-50 p-3">
|
||||
{pricingData.warnings.map((w, i) => (
|
||||
<p key={i} className="flex items-start gap-2 text-xs text-amber-800">
|
||||
{pricingQuery.data.warnings.map((w, i) => (
|
||||
<p
|
||||
key={i}
|
||||
className="flex items-start gap-2 text-xs text-amber-800"
|
||||
>
|
||||
<AlertTriangle className="mt-0.5 size-3 shrink-0" />
|
||||
{w}
|
||||
</p>
|
||||
@@ -362,40 +446,13 @@ function DraftBookingView({
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs font-medium text-primary underline-offset-2 hover:underline"
|
||||
onClick={() => {
|
||||
setPricingData(null);
|
||||
priceMutation.mutate();
|
||||
}}
|
||||
onClick={() => pricingQuery.refetch()}
|
||||
>
|
||||
Re-calculate pricing
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-4 py-6 text-center">
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-muted">
|
||||
<DollarSign className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">No pricing yet</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Generate a price estimate to review before submitting.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => priceMutation.mutate()}
|
||||
disabled={priceMutation.isPending}
|
||||
>
|
||||
{priceMutation.isPending ? (
|
||||
<LoaderCircle className="mr-1 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<DollarSign className="mr-1 h-4 w-4" />
|
||||
)}
|
||||
{priceMutation.isPending ? "Calculating..." : "Request Pricing Estimation"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -406,8 +463,8 @@ function DraftBookingView({
|
||||
Required Documents
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Provide the necessary documents for this booking. Some information is
|
||||
pre-filled from your company profile.
|
||||
Provide the necessary documents for this booking. Some information
|
||||
is pre-filled from your company profile.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-6">
|
||||
@@ -460,7 +517,10 @@ function DraftBookingView({
|
||||
accept=".pdf,.jpg,.jpeg,.png"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
handleFileSelect(doc.key, e.target.files?.[0] ?? null);
|
||||
handleFileSelect(
|
||||
doc.key,
|
||||
e.target.files?.[0] ?? null,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
@@ -474,7 +534,9 @@ function DraftBookingView({
|
||||
onClick={() => fileInputRefs.current[doc.key]?.click()}
|
||||
>
|
||||
<Upload className="size-3" />
|
||||
{selectedFiles[doc.key] ? selectedFiles[doc.key]!.name : "Choose file"}
|
||||
{selectedFiles[doc.key]
|
||||
? selectedFiles[doc.key]!.name
|
||||
: "Choose file"}
|
||||
</button>
|
||||
{selectedFiles[doc.key] && (
|
||||
<button
|
||||
@@ -528,27 +590,61 @@ function DraftBookingView({
|
||||
If you no longer need this booking, you can cancel it.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Reason for cancellation (optional)"
|
||||
className="flex-1 rounded-lg border border-border bg-background px-3 py-2 text-xs text-foreground outline-none focus:border-destructive/50"
|
||||
value={cancelReason}
|
||||
onChange={(e) => setCancelReason(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={handleCancel}
|
||||
disabled={cancelMutation.isPending}
|
||||
>
|
||||
{cancelMutation.isPending ? (
|
||||
<LoaderCircle className="mr-1 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<XCircle className="mr-1 h-4 w-4" />
|
||||
)}
|
||||
Cancel Booking
|
||||
</Button>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Cancelling will terminate this booking request and cannot be
|
||||
undone.
|
||||
</p>
|
||||
<Dialog open={cancelDialogOpen} onOpenChange={setCancelDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" variant="destructive" className="self-start">
|
||||
<XCircle className="mr-1 h-4 w-4" />
|
||||
Cancel Booking
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Cancel Booking</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to cancel this booking? This action
|
||||
cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-3 py-2">
|
||||
<label className="text-xs font-medium text-foreground">
|
||||
Reason for cancellation
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Optional — provide a reason"
|
||||
className="rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground outline-none focus:border-destructive/50"
|
||||
value={cancelReason}
|
||||
onChange={(e) => setCancelReason(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="outline">
|
||||
Keep Booking
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={handleCancel}
|
||||
disabled={cancelMutation.isPending}
|
||||
>
|
||||
{cancelMutation.isPending ? (
|
||||
<LoaderCircle className="mr-1 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<XCircle className="mr-1 h-4 w-4" />
|
||||
)}
|
||||
Yes, Cancel Booking
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -556,9 +652,11 @@ function DraftBookingView({
|
||||
<div className="mx-auto flex max-w-5xl items-center justify-end gap-3">
|
||||
{!canConfirm && (
|
||||
<p className="mr-auto text-xs text-muted-foreground">
|
||||
{!pricingData
|
||||
? "Request pricing estimation before confirming."
|
||||
: "Upload documents before confirming."}
|
||||
{pricingQuery.isLoading
|
||||
? "Calculating price…"
|
||||
: pricingQuery.isError
|
||||
? "Price calculation failed."
|
||||
: "Upload documents before confirming."}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
@@ -599,7 +697,6 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
|
||||
return (
|
||||
<div className="container mx-auto max-w-7xl px-4 py-8">
|
||||
<div className="flex flex-col gap-8">
|
||||
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: "Bookings", href: "/bookings" },
|
||||
@@ -631,26 +728,27 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
{(booking.status === "CONFIRMED" || booking.status === "IN_TRANSIT") && (
|
||||
<Card className="border-primary/30 bg-primary/5">
|
||||
<CardContent className="flex flex-col gap-4 p-6 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="font-semibold text-foreground">Contract ready</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Review the agreement and apply your digital signature.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground"
|
||||
onClick={() => navigate(`/bookings/${booking.id}/contract`)}
|
||||
>
|
||||
<FileSignature className="size-4" />
|
||||
View & sign contract
|
||||
</button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{(booking.status === "CONFIRMED" ||
|
||||
booking.status === "IN_TRANSIT") && (
|
||||
<Card className="border-primary/30 bg-primary/5">
|
||||
<CardContent className="flex flex-col gap-4 p-6 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="font-semibold text-foreground">Contract ready</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Review the agreement and apply your digital signature.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground"
|
||||
onClick={() => navigate(`/bookings/${booking.id}/contract`)}
|
||||
>
|
||||
<FileSignature className="size-4" />
|
||||
View & sign contract
|
||||
</button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -658,14 +756,21 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
|
||||
<History className="size-4 text-primary" />
|
||||
Booking Status Lifecycle
|
||||
</CardTitle>
|
||||
<CardDescription>Track the journey from request to completion</CardDescription>
|
||||
<CardDescription>
|
||||
Track the journey from request to completion
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-8">
|
||||
<div className="relative flex w-full justify-between px-2">
|
||||
<div className="absolute top-4 left-0 h-0.5 w-full bg-muted">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-500"
|
||||
style={{ width: currentStageIndex >= 0 ? `${(currentStageIndex / (PROGRESS_STAGES.length - 1)) * 100}%` : '0%' }}
|
||||
style={{
|
||||
width:
|
||||
currentStageIndex >= 0
|
||||
? `${(currentStageIndex / (PROGRESS_STAGES.length - 1)) * 100}%`
|
||||
: "0%",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -674,19 +779,32 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
|
||||
const isActive = idx === currentStageIndex;
|
||||
|
||||
return (
|
||||
<div key={stage.label} className="relative z-10 flex flex-col items-center gap-2">
|
||||
<div className={cn(
|
||||
"flex size-8 items-center justify-center rounded-full border-2 transition-all duration-300 bg-background",
|
||||
isCompleted ? "border-primary text-primary" :
|
||||
isActive ? "border-primary text-primary shadow-[0_0_10px_rgba(16,185,129,0.3)] scale-110" :
|
||||
"border-muted text-muted-foreground"
|
||||
)}>
|
||||
{isCompleted ? <CheckCircle2 className="size-4" /> : <stage.icon className="size-4" />}
|
||||
<div
|
||||
key={stage.label}
|
||||
className="relative z-10 flex flex-col items-center gap-2"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-8 items-center justify-center rounded-full border-2 transition-all duration-300 bg-background",
|
||||
isCompleted
|
||||
? "border-primary text-primary"
|
||||
: isActive
|
||||
? "border-primary text-primary shadow-[0_0_10px_rgba(16,185,129,0.3)] scale-110"
|
||||
: "border-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{isCompleted ? (
|
||||
<CheckCircle2 className="size-4" />
|
||||
) : (
|
||||
<stage.icon className="size-4" />
|
||||
)}
|
||||
</div>
|
||||
<span className={cn(
|
||||
"text-[9px] font-bold uppercase tracking-widest",
|
||||
isActive ? "text-primary" : "text-muted-foreground"
|
||||
)}>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[9px] font-bold uppercase tracking-widest",
|
||||
isActive ? "text-primary" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{stage.label}
|
||||
</span>
|
||||
</div>
|
||||
@@ -695,26 +813,40 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 rounded-xl border border-border/50 bg-muted/20 p-5 md:flex-row md:items-center">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-background shadow-sm">
|
||||
{normalizedStatus === "CANCELLED" ? <AlertTriangle className="size-5 text-red-500" /> : <Info className="size-5 text-primary" />}
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<h4 className={cn("text-sm font-black uppercase tracking-tight", statusConfig.color)}>
|
||||
{statusConfig.title}
|
||||
</h4>
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
{statusConfig.description}
|
||||
</p>
|
||||
</div>
|
||||
{normalizedStatus !== "CANCELLED" && normalizedStatus !== "DELIVERED" && (
|
||||
<div className="ml-auto flex items-center gap-4 border-l border-border pl-6">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-background shadow-sm">
|
||||
{normalizedStatus === "CANCELLED" ? (
|
||||
<AlertTriangle className="size-5 text-red-500" />
|
||||
) : (
|
||||
<Info className="size-5 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<h4
|
||||
className={cn(
|
||||
"text-sm font-black uppercase tracking-tight",
|
||||
statusConfig.color,
|
||||
)}
|
||||
>
|
||||
{statusConfig.title}
|
||||
</h4>
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
{statusConfig.description}
|
||||
</p>
|
||||
</div>
|
||||
{normalizedStatus !== "CANCELLED" &&
|
||||
normalizedStatus !== "DELIVERED" && (
|
||||
<div className="ml-auto flex items-center gap-4 border-l border-border pl-6">
|
||||
<div className="flex flex-col">
|
||||
<p className="text-[9px] font-bold uppercase text-muted-foreground">Est. Waiting</p>
|
||||
<p className="text-xs font-black text-foreground">1-2 Working Days</p>
|
||||
<p className="text-[9px] font-bold uppercase text-muted-foreground">
|
||||
Est. Waiting
|
||||
</p>
|
||||
<p className="text-xs font-black text-foreground">
|
||||
1-2 Working Days
|
||||
</p>
|
||||
</div>
|
||||
<CreditCard className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -740,7 +872,10 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
|
||||
<Train className="size-5" />
|
||||
<ArrowRight className="size-4" />
|
||||
</div>
|
||||
<Badge variant="outline" className="border-primary/20 bg-primary/5 text-[9px] font-bold uppercase">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-primary/20 bg-primary/5 text-[9px] font-bold uppercase"
|
||||
>
|
||||
Rail
|
||||
</Badge>
|
||||
</div>
|
||||
@@ -752,9 +887,31 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<InfoItem icon={<Layers />} label="Service" value={booking.serviceType === "RAIL_AND_FORWARDING" ? "Rail & Forwarding" : "Rail Only"} />
|
||||
<InfoItem icon={<ShieldCheck />} label="Return" value={booking.equipmentReturn === "WITH_RETURN" ? "With Return" : "Without Return"} />
|
||||
<InfoItem icon={<FileText />} label="Trade" value={booking.tradeDirection === "IMPORT" ? "Import" : "Export"} />
|
||||
<InfoItem
|
||||
icon={<Layers />}
|
||||
label="Service"
|
||||
value={
|
||||
booking.serviceType === "RAIL_AND_FORWARDING"
|
||||
? "Rail & Forwarding"
|
||||
: "Rail Only"
|
||||
}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={<ShieldCheck />}
|
||||
label="Return"
|
||||
value={
|
||||
booking.equipmentReturn === "WITH_RETURN"
|
||||
? "With Return"
|
||||
: "Without Return"
|
||||
}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={<FileText />}
|
||||
label="Trade"
|
||||
value={
|
||||
booking.tradeDirection === "IMPORT" ? "Import" : "Export"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -771,14 +928,23 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
|
||||
<h3 className="border-l-4 border-primary pl-3 text-xs font-bold uppercase tracking-wide text-foreground">
|
||||
First Mile
|
||||
</h3>
|
||||
<InfoItem label="Address" value={booking.firstMileEnabled && booking.firstMilePickupAddress ? booking.firstMilePickupAddress : "Not requested"} />
|
||||
<InfoItem
|
||||
label="Address"
|
||||
value={
|
||||
booking.firstMileEnabled && booking.firstMilePickupAddress
|
||||
? booking.firstMilePickupAddress
|
||||
: "Not requested"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<h3 className="border-l-4 border-primary pl-3 text-xs font-bold uppercase tracking-wide text-foreground">
|
||||
Last Mile
|
||||
</h3>
|
||||
<p className="pl-4 text-xs text-muted-foreground italic">
|
||||
{booking.lastMileEnabled && booking.lastMileDeliveryAddress ? booking.lastMileDeliveryAddress : "Not requested"}
|
||||
{booking.lastMileEnabled && booking.lastMileDeliveryAddress
|
||||
? booking.lastMileDeliveryAddress
|
||||
: "Not requested"}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -793,31 +959,57 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-6">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<InfoItem icon={<Package />} label="Freight Type" value={booking.freightType === "BULK" ? "Bulk" : "Break Bulk"} />
|
||||
<InfoItem icon={<Weight />} label="Weight (VGM)" value={`${booking.cargoTotalWeightVgm} Tons`} />
|
||||
<InfoItem icon={<Ship />} label="Currency" value={booking.paymentCurrency} />
|
||||
<InfoItem
|
||||
icon={<Package />}
|
||||
label="Freight Type"
|
||||
value={
|
||||
booking.freightType === "BULK" ? "Bulk" : "Break Bulk"
|
||||
}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={<Weight />}
|
||||
label="Weight (VGM)"
|
||||
value={`${booking.cargoTotalWeightVgm} Tons`}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={<Ship />}
|
||||
label="Currency"
|
||||
value={booking.paymentCurrency}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{booking.containers && booking.containers.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="flex flex-col gap-3">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wide text-foreground">Load Details</h3>
|
||||
<h3 className="text-xs font-bold uppercase tracking-wide text-foreground">
|
||||
Load Details
|
||||
</h3>
|
||||
<div className="rounded-lg border overflow-hidden">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="bg-muted text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-2 font-semibold">Type</th>
|
||||
<th className="px-3 py-2 font-semibold text-center">Quantity</th>
|
||||
<th className="px-3 py-2 font-semibold text-right">VGM (Tons)</th>
|
||||
<th className="px-3 py-2 font-semibold text-center">
|
||||
Quantity
|
||||
</th>
|
||||
<th className="px-3 py-2 font-semibold text-right">
|
||||
VGM (Tons)
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{booking.containers.map((c, i) => (
|
||||
<tr key={i}>
|
||||
<td className="px-3 py-2 font-medium">{c.type}</td>
|
||||
<td className="px-3 py-2 text-center">{c.qty} Units</td>
|
||||
<td className="px-3 py-2 text-right">{c.vgm}t</td>
|
||||
<td className="px-3 py-2 font-medium">
|
||||
{c.type}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{c.qty} Units
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
{c.vgm}t
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -839,7 +1031,10 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<InfoItem label="Type" value={booking.contractType === "RENEWAL" ? "Renewal" : "New"} />
|
||||
<InfoItem
|
||||
label="Type"
|
||||
value={booking.contractType === "RENEWAL" ? "Renewal" : "New"}
|
||||
/>
|
||||
<InfoItem label="Customer ID" value={booking.customerId} />
|
||||
<Separator />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -860,15 +1055,21 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{booking.freightSubtype && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-[9px] font-bold uppercase tracking-tight text-muted-foreground">Cargo Description</p>
|
||||
<p className="text-xs text-foreground leading-relaxed italic">"{booking.freightSubtype}"</p>
|
||||
<p className="text-[9px] font-bold uppercase tracking-tight text-muted-foreground">
|
||||
Cargo Description
|
||||
</p>
|
||||
<p className="text-xs text-foreground leading-relaxed italic">
|
||||
"{booking.freightSubtype}"
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{booking.financialTerms && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-[9px] font-bold uppercase tracking-tight text-muted-foreground">Financial Terms</p>
|
||||
<p className="text-[9px] font-bold uppercase tracking-tight text-muted-foreground">
|
||||
Financial Terms
|
||||
</p>
|
||||
<div className="rounded-lg bg-amber-50/50 border border-amber-100 p-2">
|
||||
<p className="text-xs text-amber-900 flex gap-2">
|
||||
<StickyNote className="size-3 shrink-0 mt-0.5 text-amber-500" />
|
||||
@@ -879,7 +1080,9 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
|
||||
</>
|
||||
)}
|
||||
{!booking.freightSubtype && !booking.financialTerms && (
|
||||
<p className="text-xs text-muted-foreground italic">No additional information provided.</p>
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
No additional information provided.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -917,17 +1120,23 @@ function RouteEndpoint({
|
||||
function InfoItem({
|
||||
icon,
|
||||
label,
|
||||
value
|
||||
value,
|
||||
}: {
|
||||
icon?: React.ReactNode;
|
||||
label: string;
|
||||
value?: string | number | null
|
||||
value?: string | number | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start gap-2">
|
||||
{icon && <div className="mt-0.5 text-muted-foreground [&_svg]:size-3.5">{icon}</div>}
|
||||
{icon && (
|
||||
<div className="mt-0.5 text-muted-foreground [&_svg]:size-3.5">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
<p className="text-[9px] font-bold uppercase tracking-tight text-muted-foreground">{label}</p>
|
||||
<p className="text-[9px] font-bold uppercase tracking-tight text-muted-foreground">
|
||||
{label}
|
||||
</p>
|
||||
<p className="text-xs font-bold text-foreground">{value ?? "—"}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -946,9 +1155,12 @@ function StatusBadge({ status }: { status: string }) {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn("px-2 py-0.5 font-bold uppercase tracking-wider text-[9px]", statusColors[status] || "bg-muted")}
|
||||
className={cn(
|
||||
"px-2 py-0.5 font-bold uppercase tracking-wider text-[9px]",
|
||||
statusColors[status] || "bg-muted",
|
||||
)}
|
||||
>
|
||||
{status.replace(/_/g, ' ')}
|
||||
{status.replace(/_/g, " ")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import { trackingService } from "./tracking.service";
|
||||
import { fileUploadSettingsService } from "./fileUploadSettings.service";
|
||||
import { dropdownSettingsService } from "./dropdownSettings.service";
|
||||
import { authService } from "./auth.service";
|
||||
import { customersService } from "./customers.service";
|
||||
import { companiesService } from "./companies.service";
|
||||
import {
|
||||
CreateDropdownOptionDto,
|
||||
@@ -28,11 +27,6 @@ import {
|
||||
UpdateDropdownOptionDto,
|
||||
UpdateDropdownSettingDto,
|
||||
} from "@/types/dropdownSettings";
|
||||
import {
|
||||
CreateCustomerDto,
|
||||
Customer,
|
||||
UpdateCustomerDto,
|
||||
} from "@/types/customers";
|
||||
import type {
|
||||
CompanyInfoResponse,
|
||||
CreateCompanyPayload,
|
||||
|
||||
5
tasks.md
5
tasks.md
@@ -1,5 +0,0 @@
|
||||
- In ./apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step1-contract-type.tsx the input field for the previous contract ref should be a select field with the list of contracts from the API
|
||||
- the "Equipment Return" field in ./apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step3-first-last-mile.tsx should be a toggle like Last Mile - Delivery and should appear only if the last mile is toggled. and also add "( Door to Port)" to first mile description and vice versa to the last mile
|
||||
- add Type of container- Dry container, high cubic containers , reefer containers, open top containers, flat rack, tank container, open side containers to ./apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx
|
||||
- merge the "Unpaired 20ft Container" from ./apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step6-wagon-allocation.tsx to step 4 and fully remove the step 5
|
||||
- add Type of Shipping lines - MSC, CMA CGM, Evergreen, COSCO, Hapag-Lloyd, ONE, Yang Ming, ZIM, Messina Line, Safmarine, Wan Hai, Ethiopian Shipping Lines (ESLSE) to ./apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx
|
||||
Reference in New Issue
Block a user