feat(bookings): Integrate auto-pricing into DRAFT booking UI, and refactor customer-company relationships across services.

This commit is contained in:
ghost2023
2026-06-05 10:23:08 +03:00
parent 0184db9040
commit a991139cc2
7 changed files with 1683 additions and 521 deletions

View File

@@ -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();
}
}

View File

@@ -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;

View File

@@ -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");
}
}

View 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`);
}
}

View File

@@ -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 &amp; 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 &amp; 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>
);
}

View File

@@ -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,

View File

@@ -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