booking flow,summtion, approval, contract, mock payemnt and integration to back office, and also add permissions

This commit is contained in:
marshal
2026-06-05 11:01:00 +03:00
75 changed files with 7453 additions and 1132 deletions

View File

@@ -13,6 +13,11 @@ import { BookingsModule } from "./modules/bookings/bookings.module";
import { FilesModule } from "./modules/files/files.module";
import { ConsignmentsModule } from "./modules/consignments/consignments.module";
import { TrainsModule } from "./modules/trains/trains.module";
import { LocomotivesModule } from "./modules/locomotives/locomotives.module";
import { WagonTypesModule } from "./modules/wagon-types/wagon-types.module";
import { TrainSetsModule } from "./modules/train-sets/train-sets.module";
import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.module";
import { TrainSchedulingModule } from "./modules/train-scheduling/train-scheduling.module";
import { CustomersModule } from "./modules/customers/customers.module";
import { CompaniesModule } from "./modules/companies/companies.module";
import { TrackingModule } from "./modules/tracking/tracking.module";
@@ -32,6 +37,9 @@ import {
import { EdrOrgSeeder } from "./seed/edr-org.seeder";
import { DemoUsersSeeder } from "./seed/demo-users.seeder";
import { FreightStaffUsersSeeder } from "./seed/freight-staff-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({
imports: [
@@ -61,6 +69,11 @@ import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder";
FilesModule,
ConsignmentsModule,
TrainsModule,
LocomotivesModule,
WagonTypesModule,
TrainSetsModule,
TrainSchedulesModule,
TrainSchedulingModule,
CustomersModule,
CompaniesModule,
TrackingModule,
@@ -74,7 +87,7 @@ import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder";
DemoPermissionsModule,
FreightAuthModule,
],
providers: [EdrOrgSeeder, DemoUsersSeeder, FreightStaffUsersSeeder],
providers: [EdrOrgSeeder, DemoUsersSeeder,FreightStaffUsersSeeder, DemoBookingsSeeder, PricingDataSeeder, FileUploadSettingsSeeder],
})
export class AppModule implements OnApplicationBootstrap {
constructor(
@@ -82,6 +95,9 @@ export class AppModule implements OnApplicationBootstrap {
private readonly edrOrgSeeder: EdrOrgSeeder,
private readonly demoUsersSeeder: DemoUsersSeeder,
private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder,
private readonly demoBookingsSeeder: DemoBookingsSeeder,
private readonly pricingDataSeeder: PricingDataSeeder,
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
) { }
async onApplicationBootstrap() {
@@ -89,5 +105,8 @@ export class AppModule implements OnApplicationBootstrap {
await this.edrOrgSeeder.run();
await this.demoUsersSeeder.run();
await this.freightStaffUsersSeeder.run();
await this.demoBookingsSeeder.run();
await this.pricingDataSeeder.run();
await this.fileUploadSettingsSeeder.run();
}
}

View File

@@ -0,0 +1,153 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddTrainScheduling1749400000000 implements MigrationInterface {
name = 'AddTrainScheduling1749400000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.wagon_types (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(32) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
capacity_tons NUMERIC(10,3) NOT NULL,
length_meters NUMERIC(10,3) NOT NULL,
max_wagons_per_train INT NULL,
supported_load_types TEXT[] NOT NULL DEFAULT '{}',
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ NULL
);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.locomotives (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(32) NOT NULL UNIQUE,
name VARCHAR(100) NULL,
max_pull_weight_tons NUMERIC(10,3) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'AVAILABLE',
available_from TIMESTAMPTZ NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ NULL
);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.train_sets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
locomotive_id UUID NOT NULL,
total_weight_tons NUMERIC(10,3) NOT NULL,
total_length_meters NUMERIC(10,3) NOT NULL,
wagon_count INT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'DRAFT',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ NULL,
CONSTRAINT fk_train_sets_locomotive FOREIGN KEY (locomotive_id)
REFERENCES freight.locomotives(id)
);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.train_set_wagons (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
train_set_id UUID NOT NULL,
wagon_type_id UUID NOT NULL,
sequence_no INT NOT NULL,
capacity_tons NUMERIC(10,3) NOT NULL,
length_meters NUMERIC(10,3) NOT NULL,
assigned_weight_tons NUMERIC(10,3) NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ NULL,
CONSTRAINT uq_train_set_wagons_sequence UNIQUE (train_set_id, sequence_no),
CONSTRAINT fk_train_set_wagons_train_set FOREIGN KEY (train_set_id)
REFERENCES freight.train_sets(id) ON DELETE CASCADE,
CONSTRAINT fk_train_set_wagons_wagon_type FOREIGN KEY (wagon_type_id)
REFERENCES freight.wagon_types(id)
);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.train_schedules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
train_set_id UUID NOT NULL UNIQUE,
origin_station_id UUID NOT NULL,
destination_station_id UUID NOT NULL,
scheduled_departure_date TIMESTAMPTZ NOT NULL,
scheduled_arrival_date TIMESTAMPTZ NULL,
status VARCHAR(20) NOT NULL DEFAULT 'DRAFT',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ NULL,
CONSTRAINT fk_train_schedules_train_set FOREIGN KEY (train_set_id)
REFERENCES freight.train_sets(id),
CONSTRAINT fk_train_schedules_origin FOREIGN KEY (origin_station_id)
REFERENCES freight.yards(id),
CONSTRAINT fk_train_schedules_destination FOREIGN KEY (destination_station_id)
REFERENCES freight.yards(id)
);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.train_schedule_bookings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
train_schedule_id UUID NOT NULL,
booking_id UUID NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ NULL,
CONSTRAINT uq_train_schedule_booking UNIQUE (train_schedule_id, booking_id),
CONSTRAINT fk_train_schedule_bookings_schedule FOREIGN KEY (train_schedule_id)
REFERENCES freight.train_schedules(id) ON DELETE CASCADE,
CONSTRAINT fk_train_schedule_bookings_booking FOREIGN KEY (booking_id)
REFERENCES freight.bookings(id)
);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.wagon_booking_allocations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
train_set_wagon_id UUID NOT NULL,
booking_id UUID NOT NULL,
allocated_weight_tons NUMERIC(10,3) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ NULL,
CONSTRAINT fk_wagon_booking_allocations_wagon FOREIGN KEY (train_set_wagon_id)
REFERENCES freight.train_set_wagons(id) ON DELETE CASCADE,
CONSTRAINT fk_wagon_booking_allocations_booking FOREIGN KEY (booking_id)
REFERENCES freight.bookings(id)
);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_locomotives_status
ON freight.locomotives(status);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_train_sets_status
ON freight.train_sets(status);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_train_schedules_departure_status
ON freight.train_schedules(scheduled_departure_date, status);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_wagon_booking_allocations_booking
ON freight.wagon_booking_allocations(booking_id);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_booking_allocations;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.train_schedule_bookings;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.train_schedules;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.train_set_wagons;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.train_sets;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.locomotives;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_types;`);
}
}

View File

@@ -152,6 +152,18 @@ export class BookingsController {
return this.bookingsService.remove(id);
}
@Post(':id/documents')
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Upload documents for a booking (DRAFT only)' })
async uploadDocuments(
@Param('id', ParseUUIDPipe) id: string,
@UploadedFiles() files: Express.Multer.File[],
) {
const booking = await this.bookingsService.uploadDocuments(id, files ?? []);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/generate-price')
@ApiOperation({ summary: 'Generate price preview (DRAFT only)' })
@ApiOkResponse({ type: GeneratePriceResponseDto })

View File

@@ -62,6 +62,6 @@ import { ContractViewModelBuilder } from '../../contracts/contract-view-model.bu
ContractRendererService,
ContractPdfService,
],
exports: [BookingsService],
exports: [BookingsService, BookingsRepository],
})
export class BookingsModule {}

View File

@@ -502,6 +502,21 @@ export class BookingsService {
return this.findById(booking.id);
}
/** Upload documents for a DRAFT booking. */
async uploadDocuments(
id: string,
files: Express.Multer.File[],
): Promise<Booking> {
const booking = await this.findById(id);
if (booking.status !== 'DRAFT') {
throw new BadRequestException(
'Documents can only be uploaded for DRAFT bookings',
);
}
await this.filesService.uploadMany(id, 'bookings', files);
return this.findById(id);
}
async remove(id: string): Promise<void> {
const booking = await this.findById(id);
if (booking.status !== 'DRAFT') {

View File

@@ -1,6 +1,8 @@
import { Controller, Get, Post, Patch, Delete, Body, Param, Query, ParseUUIDPipe, HttpCode, HttpStatus } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { Controller, Get, Post, Patch, Delete, Body, Param, Query, ParseUUIDPipe, HttpCode, HttpStatus, UseInterceptors, UploadedFiles } from '@nestjs/common';
import { AnyFilesInterceptor } from '@nestjs/platform-express';
import { ApiOperation, ApiTags, ApiConsumes } from '@nestjs/swagger';
import { CurrentUser } from '@edr/api-common';
import { FilesService } from '../files/files.service';
import { CompaniesService } from './companies.service';
import { CreateCompanyDto } from './dto/create-company.dto';
import { UpdateCompanyDto } from './dto/update-company.dto';
@@ -11,6 +13,8 @@ import { ResponseCompanyDto } from './dto/response-company.dto';
import { ResponseExternalProfileDto } from './dto/response-external-profile.dto';
import { ResponseFFClientDto } from './dto/response-ff-client.dto';
import { CompanyInfoResponseDto } from './dto/company-info-response.dto';
import { UpdateProfileDto } from './dto/update-profile.dto';
import { ProfileResponseDto } from './dto/profile-response.dto';
interface CurrentIamUser {
id: string;
@@ -22,7 +26,10 @@ interface CurrentIamUser {
@ApiTags('Companies')
@Controller('companies')
export class CompaniesController {
constructor(private readonly companiesService: CompaniesService) {}
constructor(
private readonly companiesService: CompaniesService,
private readonly filesService: FilesService,
) {}
@Get('getInfo')
@ApiOperation({ summary: 'Get company info for the current user' })
@@ -31,6 +38,22 @@ export class CompaniesController {
return new CompanyInfoResponseDto(profile, company);
}
@Get('profile')
@ApiOperation({ summary: 'Get flattened profile for the settings page' })
async getProfile(@CurrentUser() user: CurrentIamUser): Promise<ProfileResponseDto> {
const { profile, company } = await this.companiesService.getCompanyInfoByUserId(user.id);
return new ProfileResponseDto(profile, company);
}
@Patch('profile')
@ApiOperation({ summary: 'Update profile (flattened settings page)' })
async updateProfile(
@CurrentUser() user: CurrentIamUser,
@Body() dto: UpdateProfileDto,
): Promise<ProfileResponseDto> {
return this.companiesService.updateProfile(user.id, dto);
}
@Post('create')
@ApiOperation({ summary: 'Create a company with its associated external profile (onboarding)' })
async createWithProfile(
@@ -105,6 +128,17 @@ export class CompaniesController {
await this.companiesService.deleteCompany(id);
}
@Post(':companyId/documents')
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Upload documents for a company (onboarding)' })
async uploadDocuments(
@Param('companyId', ParseUUIDPipe) companyId: string,
@UploadedFiles() files: Array<Express.Multer.File>,
) {
return this.filesService.uploadMany(companyId, 'companies', files);
}
@Post(':companyId/profiles')
@ApiOperation({ summary: 'Add a profile (employee) to a company' })
async createProfile(

View File

@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FilesModule } from '../files/files.module';
import { CompaniesController } from './companies.controller';
import { CompaniesService } from './companies.service';
import { CompaniesRepository } from './companies.repository';
@@ -10,7 +11,7 @@ import { ExternalProfile } from './entities/external-profile.entity';
import { FFClient } from './entities/ff-client.entity';
@Module({
imports: [TypeOrmModule.forFeature([Company, ExternalProfile, FFClient])],
imports: [TypeOrmModule.forFeature([Company, ExternalProfile, FFClient]), FilesModule],
controllers: [CompaniesController],
providers: [CompaniesService, CompaniesRepository, ExternalProfileRepository, FFClientRepository],
exports: [CompaniesService],

View File

@@ -7,6 +7,8 @@ import { UpdateCompanyDto } from './dto/update-company.dto';
import { CreateExternalProfileDto } from './dto/create-external-profile.dto';
import { CreateFFClientDto } from './dto/create-ff-client.dto';
import { CreateCompanyWithProfileDto } from './dto/create-company-with-profile.dto';
import { UpdateProfileDto } from './dto/update-profile.dto';
import { ProfileResponseDto } from './dto/profile-response.dto';
import { Company } from './entities/company.entity';
import { ExternalProfile } from './entities/external-profile.entity';
import { FFClient } from './entities/ff-client.entity';
@@ -103,6 +105,42 @@ export class CompaniesService {
return updated;
}
async updateProfile(userId: string, dto: UpdateProfileDto): Promise<ProfileResponseDto> {
const { profile, company } = await this.getCompanyInfoByUserId(userId);
const companyUpdates: Record<string, any> = {};
const attrUpdates: Record<string, any> = { ...(company.attributes ?? {}) };
if (dto.companyName !== undefined) companyUpdates.name = dto.companyName;
if (dto.companyEmail !== undefined) companyUpdates.email = dto.companyEmail;
if (dto.companyPhone !== undefined) companyUpdates.phone = dto.companyPhone;
if (dto.companyLocation !== undefined) companyUpdates.country = dto.companyLocation;
if (dto.companyAddress !== undefined) companyUpdates.address = dto.companyAddress;
if (dto.tin !== undefined) companyUpdates.tin = dto.tin;
if (dto.vatNumber !== undefined) companyUpdates.vatNumber = dto.vatNumber;
if (dto.fanNumber !== undefined) {
companyUpdates.businessLicense = dto.fanNumber;
companyUpdates.fanNumber = dto.fanNumber;
}
if (dto.contactPersonName !== undefined) attrUpdates.contactPersonName = dto.contactPersonName;
if (dto.contactPersonPhone !== undefined) attrUpdates.contactPersonPhone = dto.contactPersonPhone;
if (dto.generalManagerName !== undefined) attrUpdates.generalManagerName = dto.generalManagerName;
if (dto.generalManagerEmail !== undefined) attrUpdates.generalManagerEmail = dto.generalManagerEmail;
if (dto.generalManagerPhone !== undefined) attrUpdates.generalManagerPhone = dto.generalManagerPhone;
if (dto.poaName !== undefined) attrUpdates.poaName = dto.poaName;
if (dto.poaPhone !== undefined) attrUpdates.poaPhone = dto.poaPhone;
if (dto.poaEmail !== undefined) attrUpdates.poaEmail = dto.poaEmail;
if (dto.poaLocation !== undefined) attrUpdates.poaLocation = dto.poaLocation;
if (dto.poaAddress !== undefined) attrUpdates.poaAddress = dto.poaAddress;
companyUpdates.attributes = attrUpdates;
const updated = await this.companiesRepo.update(company.id, companyUpdates);
if (!updated) throw new NotFoundException(`Company ${company.id} not found`);
return new ProfileResponseDto(profile, updated);
}
async deleteCompany(id: string): Promise<void> {
await this.findCompanyById(id);
await this.companiesRepo.softDelete(id);

View File

@@ -0,0 +1,53 @@
import { Company } from '../entities/company.entity';
import { ExternalProfile } from '../entities/external-profile.entity';
export class ProfileResponseDto {
companyId: string;
companyName: string;
companyEmail: string | null;
companyPhone: string | null;
companyLocation: string;
companyAddress: string | null;
tinNumber: string;
vatNumber: string | null;
fanNumber: string | null;
contactPersonName: string | null;
contactPersonPhone: string | null;
generalManagerName: string | null;
generalManagerEmail: string | null;
generalManagerPhone: string | null;
poaName: string | null;
poaPhone: string | null;
poaEmail: string | null;
poaLocation: string | null;
poaAddress: string | null;
profileId: string;
constructor(profile: ExternalProfile, company: Company) {
this.companyId = company.id;
this.companyName = company.name;
this.companyEmail = company.email ?? null;
this.companyPhone = company.phone ?? null;
this.companyLocation = company.country;
this.companyAddress = company.address ?? null;
this.tinNumber = company.tin;
this.vatNumber = company.vatNumber ?? null;
this.fanNumber = company.fanNumber ?? null;
this.profileId = profile.id;
const attrs = company.attributes ?? {};
this.contactPersonName = attrs.contactPersonName ?? null;
this.contactPersonPhone = attrs.contactPersonPhone ?? null;
this.generalManagerName = attrs.generalManagerName ?? null;
this.generalManagerEmail = attrs.generalManagerEmail ?? null;
this.generalManagerPhone = attrs.generalManagerPhone ?? null;
this.poaName = attrs.poaName ?? null;
this.poaPhone = attrs.poaPhone ?? null;
this.poaEmail = attrs.poaEmail ?? null;
this.poaLocation = attrs.poaLocation ?? null;
this.poaAddress = attrs.poaAddress ?? null;
}
}

View File

@@ -0,0 +1,83 @@
import { IsString, IsOptional, IsEmail, MaxLength, Length, Matches } from 'class-validator';
export class UpdateProfileDto {
@IsOptional()
@IsString()
@MaxLength(200)
companyName?: string;
@IsOptional()
@IsEmail()
@MaxLength(150)
companyEmail?: string;
@IsOptional()
@IsString()
@MaxLength(20)
companyPhone?: string;
@IsOptional()
@IsString()
@MaxLength(32)
companyLocation?: string;
@IsOptional()
@IsString()
companyAddress?: string;
@IsOptional()
@IsString()
@Length(10, 10)
@Matches(/^\d+$/, { message: 'TIN must contain only digits' })
tin?: string;
@IsOptional()
@IsString()
@MaxLength(50)
vatNumber?: string;
@IsOptional()
@IsString()
@MaxLength(16)
fanNumber?: string;
@IsOptional()
@IsString()
contactPersonName?: string;
@IsOptional()
@IsString()
contactPersonPhone?: string;
@IsOptional()
@IsString()
generalManagerName?: string;
@IsOptional()
@IsEmail()
generalManagerEmail?: string;
@IsOptional()
@IsString()
generalManagerPhone?: string;
@IsOptional()
@IsString()
poaName?: string;
@IsOptional()
@IsString()
poaPhone?: string;
@IsOptional()
@IsEmail()
poaEmail?: string;
@IsOptional()
@IsString()
poaLocation?: string;
@IsOptional()
@IsString()
poaAddress?: string;
}

View File

@@ -0,0 +1,11 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsOptional } from 'class-validator';
import { LOCOMOTIVE_STATUSES } from '../entities/locomotive.entity';
export class FilterLocomotivesDto {
@ApiPropertyOptional({ enum: LOCOMOTIVE_STATUSES })
@IsOptional()
@IsIn([...LOCOMOTIVE_STATUSES])
status?: string;
}

View File

@@ -0,0 +1,36 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, OneToMany } from 'typeorm';
import { TrainSet } from '../../train-sets/entities/train-set.entity';
export const LOCOMOTIVE_STATUSES = [
'AVAILABLE',
'ASSIGNED',
'MAINTENANCE',
'INACTIVE',
] as const;
export type LocomotiveStatus = (typeof LOCOMOTIVE_STATUSES)[number];
@Entity({ schema: 'freight', name: 'locomotives' })
@Index(['code'])
@Index(['status'])
export class Locomotive extends BaseEntity {
@Column({ name: 'code', type: 'varchar', length: 32, unique: true })
code!: string;
@Column({ name: 'name', type: 'varchar', length: 100, nullable: true })
name?: string | null;
@Column({ name: 'max_pull_weight_tons', type: 'numeric', precision: 10, scale: 3 })
maxPullWeightTons!: number;
@Column({ name: 'status', type: 'varchar', length: 20, default: 'AVAILABLE' })
status!: LocomotiveStatus;
@Column({ name: 'available_from', type: 'timestamptz', nullable: true })
availableFrom?: Date | null;
@OneToMany(() => TrainSet, (trainSet) => trainSet.locomotive)
trainSets?: TrainSet[];
}

View File

@@ -0,0 +1,18 @@
import { Controller, Get, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
import { LocomotivesService } from './locomotives.service';
@ApiTags('locomotives')
@ApiBearerAuth()
@Controller('locomotives')
export class LocomotivesController {
constructor(private readonly locomotivesService: LocomotivesService) {}
@Get()
@ApiOperation({ summary: 'List locomotives' })
findAll(@Query() filter: FilterLocomotivesDto) {
return this.locomotivesService.findAll(filter);
}
}

View File

@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { LocomotivesController } from './locomotives.controller';
import { Locomotive } from './entities/locomotive.entity';
import { LocomotivesRepository } from './locomotives.repository';
import { LocomotivesService } from './locomotives.service';
@Module({
imports: [TypeOrmModule.forFeature([Locomotive])],
controllers: [LocomotivesController],
providers: [LocomotivesRepository, LocomotivesService],
exports: [LocomotivesRepository, LocomotivesService],
})
export class LocomotivesModule {}

View File

@@ -0,0 +1,16 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Locomotive } from './entities/locomotive.entity';
@Injectable()
export class LocomotivesRepository extends BaseRepository<Locomotive> {
constructor(
@InjectRepository(Locomotive)
repository: Repository<Locomotive>,
) {
super(repository);
}
}

View File

@@ -0,0 +1,29 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
import { Locomotive, type LocomotiveStatus } from './entities/locomotive.entity';
import { LocomotivesRepository } from './locomotives.repository';
@Injectable()
export class LocomotivesService {
constructor(private readonly locomotivesRepository: LocomotivesRepository) {}
findAll(filter: FilterLocomotivesDto): Promise<Locomotive[]> {
return this.locomotivesRepository.findAll({
where: filter.status
? { status: filter.status as LocomotiveStatus }
: undefined,
order: { code: 'ASC' },
});
}
async findById(id: string): Promise<Locomotive> {
const locomotive = await this.locomotivesRepository.findById(id);
if (!locomotive) {
throw new NotFoundException(`Locomotive ${id} not found`);
}
return locomotive;
}
}

View File

@@ -0,0 +1,26 @@
import { BaseEntity } from '@edr/api-common';
import { Entity, Index, JoinColumn, ManyToOne, Column } from 'typeorm';
import { Booking } from '../../bookings/entities/booking.entity';
import { TrainSchedule } from './train-schedule.entity';
@Entity({ schema: 'freight', name: 'train_schedule_bookings' })
@Index(['trainScheduleId', 'bookingId'], { unique: true })
@Index(['bookingId'], { unique: true })
export class TrainScheduleBooking extends BaseEntity {
@Column({ name: 'train_schedule_id', type: 'uuid' })
trainScheduleId!: string;
@ManyToOne(() => TrainSchedule, (trainSchedule) => trainSchedule.scheduleBookings, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'train_schedule_id' })
trainSchedule?: TrainSchedule;
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
@ManyToOne(() => Booking)
@JoinColumn({ name: 'booking_id' })
booking?: Booking;
}

View File

@@ -0,0 +1,54 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, OneToOne } from 'typeorm';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { TrainSet } from '../../train-sets/entities/train-set.entity';
import { TrainScheduleBooking } from './train-schedule-booking.entity';
export const TRAIN_SCHEDULE_STATUSES = [
'DRAFT',
'SCHEDULED',
'DISPATCHED',
'ARRIVED',
'CANCELLED',
] as const;
export type TrainScheduleStatus = (typeof TRAIN_SCHEDULE_STATUSES)[number];
@Entity({ schema: 'freight', name: 'train_schedules' })
@Index(['scheduledDepartureDate'])
@Index(['status'])
export class TrainSchedule extends BaseEntity {
@Column({ name: 'train_set_id', type: 'uuid', unique: true })
trainSetId!: string;
@OneToOne(() => TrainSet, (trainSet) => trainSet.trainSchedule)
@JoinColumn({ name: 'train_set_id' })
trainSet?: TrainSet;
@Column({ name: 'origin_station_id', type: 'uuid' })
originStationId!: string;
@ManyToOne(() => Yard)
@JoinColumn({ name: 'origin_station_id' })
originStation?: Yard;
@Column({ name: 'destination_station_id', type: 'uuid' })
destinationStationId!: string;
@ManyToOne(() => Yard)
@JoinColumn({ name: 'destination_station_id' })
destinationStation?: Yard;
@Column({ name: 'scheduled_departure_date', type: 'timestamptz' })
scheduledDepartureDate!: Date;
@Column({ name: 'scheduled_arrival_date', type: 'timestamptz', nullable: true })
scheduledArrivalDate?: Date | null;
@Column({ name: 'status', type: 'varchar', length: 20, default: 'DRAFT' })
status!: TrainScheduleStatus;
@OneToMany(() => TrainScheduleBooking, (scheduleBooking) => scheduleBooking.trainSchedule)
scheduleBookings?: TrainScheduleBooking[];
}

View File

@@ -0,0 +1,26 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Booking } from '../../bookings/entities/booking.entity';
import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity';
@Entity({ schema: 'freight', name: 'wagon_booking_allocations' })
@Index(['trainSetWagonId', 'bookingId'])
export class WagonBookingAllocation extends BaseEntity {
@Column({ name: 'train_set_wagon_id', type: 'uuid' })
trainSetWagonId!: string;
@ManyToOne(() => TrainSetWagon, (wagon) => wagon.allocations, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'train_set_wagon_id' })
trainSetWagon?: TrainSetWagon;
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
@ManyToOne(() => Booking)
@JoinColumn({ name: 'booking_id' })
booking?: Booking;
@Column({ name: 'allocated_weight_tons', type: 'numeric', precision: 10, scale: 3 })
allocatedWeightTons!: number;
}

View File

@@ -0,0 +1,16 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { TrainScheduleBooking } from './entities/train-schedule-booking.entity';
@Injectable()
export class TrainScheduleBookingsRepository extends BaseRepository<TrainScheduleBooking> {
constructor(
@InjectRepository(TrainScheduleBooking)
repository: Repository<TrainScheduleBooking>,
) {
super(repository);
}
}

View File

@@ -0,0 +1,24 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TrainScheduleBooking } from './entities/train-schedule-booking.entity';
import { TrainSchedule } from './entities/train-schedule.entity';
import { WagonBookingAllocation } from './entities/wagon-booking-allocation.entity';
import { TrainScheduleBookingsRepository } from './train-schedule-bookings.repository';
import { TrainSchedulesRepository } from './train-schedules.repository';
import { WagonBookingAllocationsRepository } from './wagon-booking-allocations.repository';
@Module({
imports: [TypeOrmModule.forFeature([TrainSchedule, TrainScheduleBooking, WagonBookingAllocation])],
providers: [
TrainSchedulesRepository,
TrainScheduleBookingsRepository,
WagonBookingAllocationsRepository,
],
exports: [
TrainSchedulesRepository,
TrainScheduleBookingsRepository,
WagonBookingAllocationsRepository,
],
})
export class TrainSchedulesModule {}

View File

@@ -0,0 +1,16 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { TrainSchedule } from './entities/train-schedule.entity';
@Injectable()
export class TrainSchedulesRepository extends BaseRepository<TrainSchedule> {
constructor(
@InjectRepository(TrainSchedule)
repository: Repository<TrainSchedule>,
) {
super(repository);
}
}

View File

@@ -0,0 +1,16 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { WagonBookingAllocation } from './entities/wagon-booking-allocation.entity';
@Injectable()
export class WagonBookingAllocationsRepository extends BaseRepository<WagonBookingAllocation> {
constructor(
@InjectRepository(WagonBookingAllocation)
repository: Repository<WagonBookingAllocation>,
) {
super(repository);
}
}

View File

@@ -0,0 +1,10 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsUUID } from 'class-validator';
import { PreviewContainerTrainScheduleDto } from './preview-container-train-schedule.dto';
export class CreateContainerTrainScheduleDto extends PreviewContainerTrainScheduleDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
locomotiveId!: string;
}

View File

@@ -0,0 +1,23 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsDateString, IsOptional, IsUUID } from 'class-validator';
export class GetEligibleContainerBookingsDto {
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
originStationId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
destinationStationId?: string;
@ApiPropertyOptional({ example: '2026-06-20T08:00:00.000Z' })
@IsOptional()
@IsDateString()
scheduleDate?: string;
@ApiPropertyOptional()
@IsOptional()
status?: string;
}

View File

@@ -0,0 +1,22 @@
import { ApiProperty } from '@nestjs/swagger';
import { ArrayMinSize, IsArray, IsDateString, IsUUID } from 'class-validator';
export class PreviewContainerTrainScheduleDto {
@ApiProperty({ type: [String] })
@IsArray()
@ArrayMinSize(1)
@IsUUID('4', { each: true })
bookingIds!: string[];
@ApiProperty({ example: '2026-06-20T08:00:00.000Z' })
@IsDateString()
scheduleDate!: string;
@ApiProperty({ format: 'uuid' })
@IsUUID()
originStationId!: string;
@ApiProperty({ format: 'uuid' })
@IsUUID()
destinationStationId!: string;
}

View File

@@ -0,0 +1,58 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
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 { TrainSchedulingService } from './train-scheduling.service';
@ApiTags('train-scheduling')
@ApiBearerAuth()
@Controller('train-scheduling')
export class TrainSchedulingController {
constructor(private readonly trainSchedulingService: TrainSchedulingService) {}
@Get('container/eligible-bookings')
@ApiOperation({ summary: 'List eligible container bookings' })
getEligibleContainerBookings(@Query() query: GetEligibleContainerBookingsDto) {
return this.trainSchedulingService.getEligibleContainerBookings(query);
}
@Post('container/preview')
@ApiOperation({ summary: 'Preview a container train schedule' })
previewContainerTrainSchedule(@Body() dto: PreviewContainerTrainScheduleDto) {
return this.trainSchedulingService.previewContainerTrainSchedule(dto);
}
@Post('container/schedules')
@ApiOperation({ summary: 'Create a container train schedule' })
createContainerTrainSchedule(@Body() dto: CreateContainerTrainScheduleDto) {
return this.trainSchedulingService.createContainerTrainSchedule(dto);
}
@Get('container/schedules')
@ApiOperation({ summary: 'List container train schedules' })
getContainerTrainSchedules() {
return this.trainSchedulingService.getContainerTrainSchedules();
}
@Get('container/schedules/:id')
@ApiOperation({ summary: 'Get container train schedule detail' })
getContainerTrainScheduleById(@Param('id', ParseUUIDPipe) id: string) {
return this.trainSchedulingService.getContainerTrainScheduleById(id);
}
@Post('container/schedules/:id/cancel')
@ApiOperation({ summary: 'Cancel container train schedule' })
cancelTrainSchedule(@Param('id', ParseUUIDPipe) id: string) {
return this.trainSchedulingService.cancelTrainSchedule(id);
}
}

View File

@@ -0,0 +1,46 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BookingsModule } from '../bookings/bookings.module';
import { Booking } from '../bookings/entities/booking.entity';
import { BookingContainer } from '../bookings/entities/booking-container.entity';
import { LocomotivesModule } from '../locomotives/locomotives.module';
import { Locomotive } from '../locomotives/entities/locomotive.entity';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { WagonTypesModule } from '../wagon-types/wagon-types.module';
import { TrainSet } from '../train-sets/entities/train-set.entity';
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
import { TrainSetsModule } from '../train-sets/train-sets.module';
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 { TrainSchedulesModule } from '../train-schedules/train-schedules.module';
import { Yard } from '../rule-engine/entities/yard.entity';
import { TrainSchedulingController } from './train-scheduling.controller';
import { TrainSchedulingService } from './train-scheduling.service';
@Module({
imports: [
TypeOrmModule.forFeature([
Booking,
BookingContainer,
Locomotive,
WagonType,
TrainSet,
TrainSetWagon,
TrainSchedule,
TrainScheduleBooking,
WagonBookingAllocation,
Yard,
]),
BookingsModule,
LocomotivesModule,
WagonTypesModule,
TrainSetsModule,
TrainSchedulesModule,
],
controllers: [TrainSchedulingController],
providers: [TrainSchedulingService],
exports: [TrainSchedulingService],
})
export class TrainSchedulingModule {}

View File

@@ -0,0 +1,328 @@
import { ConflictException } from '@nestjs/common';
import { TrainSchedulingService } from './train-scheduling.service';
const nw5 = {
id: 'wagon-type-1',
code: 'NW5',
name: 'Flat Wagon',
capacityTons: 70,
lengthMeters: 14,
maxWagonsPerTrain: 53,
supportedLoadTypes: ['CONTAINER'],
isActive: true,
};
const locomotive = {
id: 'loc-1',
code: 'LOC-001',
maxPullWeightTons: 3500,
status: 'AVAILABLE',
};
const makeBooking = (
id: string,
reference: string,
weight: number,
quantity: number,
containerCode: string,
scheduledDate = '2026-06-20T08:00:00.000Z',
originYardId = 'yard-origin',
destinationYardId = 'yard-destination',
) => ({
id,
reference,
freightType: 'CONTAINER',
cargoTotalWeightVgm: weight,
scheduledDate: new Date(scheduledDate),
originYardId,
destinationYardId,
status: 'APPROVED',
customer: { companyName: 'Demo Customer' },
originYard: { label: 'Djibouti', code: 'DJIBOUTI' },
destinationYard: { label: 'Addis Ababa', code: 'ADDIS_ABABA' },
bookingContainers: [
{
quantity,
containerType: { code: containerCode, label: containerCode },
},
],
});
describe('TrainSchedulingService', () => {
let service: TrainSchedulingService;
let dataSource: {
getRepository: jest.Mock;
transaction: jest.Mock;
};
let locomotivesRepository: {
findById: jest.Mock;
};
let wagonTypesRepository: {
findAll: jest.Mock;
};
beforeEach(() => {
dataSource = {
getRepository: jest.fn(),
transaction: jest.fn(),
};
locomotivesRepository = {
findById: jest.fn(),
};
wagonTypesRepository = {
findAll: jest.fn(),
};
service = new TrainSchedulingService(
dataSource as never,
locomotivesRepository as never,
wagonTypesRepository as never,
);
});
it('computes the expected valid preview for Group A', async () => {
const bookings = [
makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT'),
makeBooking('b2', 'BKG-CONT-002', 300, 10, '20FT'),
makeBooking('b3', 'BKG-CONT-003', 450, 15, '40FT'),
];
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
dataSource.getRepository.mockImplementation((entity: { name?: string }) => {
if (entity?.name === 'Booking') {
return { find: jest.fn().mockResolvedValue(bookings) };
}
if (entity?.name === 'TrainScheduleBooking') {
return { find: jest.fn().mockResolvedValue([]) };
}
if (entity?.name === 'Locomotive') {
return {
count: jest.fn().mockResolvedValue(2),
find: jest.fn().mockResolvedValue([locomotive]),
};
}
throw new Error(`Unexpected repository ${entity?.name}`);
});
const result = await service.previewContainerTrainSchedule({
bookingIds: bookings.map((booking) => booking.id),
scheduleDate: '2026-06-20T08:00:00.000Z',
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
});
expect(result.valid).toBe(true);
expect(result.violations).toEqual([]);
expect(result.summary).toEqual({
totalBookings: 3,
totalWeightTons: 1250,
wagonType: 'NW5',
wagonsNeeded: 18,
totalLengthMeters: 252,
});
expect(result.wagonPlan).toHaveLength(18);
expect(result.wagonPlan[0]?.allocations[0]).toEqual({
bookingId: 'b1',
bookingReference: 'BKG-CONT-001',
allocatedWeightTons: 70,
});
});
it('flags the overweight booking as invalid', async () => {
const bookings = [makeBooking('b6', 'BKG-CONT-006', 3600, 80, '40FT')];
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
dataSource.getRepository.mockImplementation((entity: { name?: string }) => {
if (entity?.name === 'Booking') {
return { find: jest.fn().mockResolvedValue(bookings) };
}
if (entity?.name === 'TrainScheduleBooking') {
return { find: jest.fn().mockResolvedValue([]) };
}
if (entity?.name === 'Locomotive') {
return {
count: jest.fn().mockResolvedValue(1),
find: jest.fn().mockResolvedValue([locomotive]),
};
}
throw new Error(`Unexpected repository ${entity?.name}`);
});
const result = await service.previewContainerTrainSchedule({
bookingIds: ['b6'],
scheduleDate: '2026-06-20T08:00:00.000Z',
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
});
expect(result.valid).toBe(false);
expect(result.summary.totalWeightTons).toBe(3600);
expect(result.violations).toContain(
'Total booking weight 3600T exceeds max train weight 3500T',
);
});
it('creates a schedule transactionally when validation passes', async () => {
const bookings = [makeBooking('b1', 'BKG-CONT-001', 140, 2, '40FT')];
const validation = {
valid: true,
violations: [],
bookings,
wagonType: nw5,
summary: {
totalBookings: 1,
totalWeightTons: 140,
wagonType: 'NW5',
wagonsNeeded: 2,
totalLengthMeters: 28,
},
wagonPlan: [
{
sequenceNo: 1,
capacityTons: 70,
lengthMeters: 14,
assignedWeightTons: 70,
allocations: [
{
bookingId: 'b1',
bookingReference: 'BKG-CONT-001',
allocatedWeightTons: 70,
},
],
},
{
sequenceNo: 2,
capacityTons: 70,
lengthMeters: 14,
assignedWeightTons: 70,
allocations: [
{
bookingId: 'b1',
bookingReference: 'BKG-CONT-001',
allocatedWeightTons: 70,
},
],
},
],
};
const lockedLocomotiveRepo = {
findOne: jest.fn().mockResolvedValue(locomotive),
update: jest.fn().mockResolvedValue(undefined),
};
const trainScheduleRepo = {
create: jest.fn().mockImplementation((value) => value),
save: jest.fn().mockResolvedValue({ id: 'schedule-1' }),
};
const trainScheduleBookingRepo = {
count: jest.fn().mockResolvedValue(0),
create: jest.fn().mockImplementation((value) => value),
save: jest.fn().mockResolvedValue(undefined),
};
const trainSetWagonRepo = {
create: jest.fn().mockImplementation((value) => value),
save: jest.fn().mockResolvedValue(undefined),
find: jest.fn().mockResolvedValue([
{ id: 'wagon-1', sequenceNo: 1 },
{ id: 'wagon-2', sequenceNo: 2 },
]),
};
const wagonAllocRepo = {
create: jest.fn().mockImplementation((value) => value),
save: jest.fn().mockResolvedValue(undefined),
};
const trainSetRepo = {
create: jest.fn().mockImplementation((value) => value),
save: jest.fn().mockResolvedValue({ id: 'train-set-1' }),
};
const manager = {
getRepository: jest.fn((entity: { name?: string }) => {
switch (entity?.name) {
case 'Locomotive':
return lockedLocomotiveRepo;
case 'TrainSchedule':
return trainScheduleRepo;
case 'TrainScheduleBooking':
return trainScheduleBookingRepo;
case 'TrainSetWagon':
return trainSetWagonRepo;
case 'WagonBookingAllocation':
return wagonAllocRepo;
case 'TrainSet':
return trainSetRepo;
default:
throw new Error(`Unexpected transaction repository ${entity?.name}`);
}
}),
};
jest.spyOn(service, 'validateContainerBookingsForScheduling').mockResolvedValue(validation as never);
jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never);
jest.spyOn(service, 'getContainerTrainScheduleById').mockResolvedValue({ id: 'schedule-1' } as never);
dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise<string>) =>
callback(manager),
);
const result = await service.createContainerTrainSchedule({
bookingIds: ['b1'],
scheduleDate: '2026-06-20T08:00:00.000Z',
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
locomotiveId: 'loc-1',
});
expect(trainSetRepo.save).toHaveBeenCalled();
expect(trainScheduleRepo.save).toHaveBeenCalled();
expect(trainSetWagonRepo.save).toHaveBeenCalled();
expect(wagonAllocRepo.save).toHaveBeenCalled();
expect(lockedLocomotiveRepo.update).toHaveBeenCalledWith('loc-1', { status: 'ASSIGNED' });
expect(result).toEqual({ id: 'schedule-1' });
});
it('rejects create when the locked locomotive is no longer available', async () => {
const validation = {
valid: true,
violations: [],
bookings: [makeBooking('b1', 'BKG-CONT-001', 70, 1, '40FT')],
wagonType: nw5,
summary: {
totalBookings: 1,
totalWeightTons: 70,
wagonType: 'NW5',
wagonsNeeded: 1,
totalLengthMeters: 14,
},
wagonPlan: [
{
sequenceNo: 1,
capacityTons: 70,
lengthMeters: 14,
assignedWeightTons: 70,
allocations: [],
},
],
};
const manager = {
getRepository: jest.fn(() => ({
findOne: jest.fn().mockResolvedValue({ ...locomotive, status: 'ASSIGNED' }),
})),
};
jest.spyOn(service, 'validateContainerBookingsForScheduling').mockResolvedValue(validation as never);
jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never);
dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise<string>) =>
callback(manager),
);
await expect(
service.createContainerTrainSchedule({
bookingIds: ['b1'],
scheduleDate: '2026-06-20T08:00:00.000Z',
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
locomotiveId: 'loc-1',
}),
).rejects.toBeInstanceOf(ConflictException);
});
});

View File

@@ -0,0 +1,814 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} 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";
const DEFAULT_WAGON_TYPE_CODE = "NW5";
const MAX_TRAIN_WEIGHT_TONS = 3500;
const MAX_TRAIN_LENGTH_METERS = 760;
type EligibleBookingItem = {
id: string;
reference: string;
customer: string;
containerType: string;
quantity: number;
weightTons: number;
origin: string;
destination: string;
preferredDepartureDate: string;
status: string;
};
type WagonAllocationRecord = {
bookingId: string;
bookingReference: string;
allocatedWeightTons: number;
};
type WagonPlanRecord = {
sequenceNo: number;
capacityTons: number;
lengthMeters: number;
assignedWeightTons: number;
allocations: WagonAllocationRecord[];
};
type ValidationResult = {
valid: boolean;
violations: string[];
bookings: Booking[];
wagonType: WagonType;
summary: {
totalBookings: number;
totalWeightTons: number;
wagonType: string;
wagonsNeeded: number;
totalLengthMeters: number;
};
wagonPlan: WagonPlanRecord[];
};
@Injectable()
export class TrainSchedulingService {
constructor(
@InjectDataSource()
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");
if (query.originStationId) {
queryBuilder.andWhere("booking.originYardId = :originStationId", {
originStationId: query.originStationId,
});
}
if (query.destinationStationId) {
queryBuilder.andWhere(
"booking.destinationYardId = :destinationStationId",
{
destinationStationId: query.destinationStationId,
},
);
}
if (query.scheduleDate) {
queryBuilder.andWhere(
`DATE(booking.scheduled_date AT TIME ZONE 'UTC') = :scheduleDate`,
{ scheduleDate: this.toUtcDateKey(query.scheduleDate) },
);
}
if (query.status) {
queryBuilder.andWhere("booking.status = :status", {
status: query.status,
});
}
const bookings = await queryBuilder
.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.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",
preferredDepartureDate: booking.scheduledDate.toISOString(),
status: booking.status,
}));
return {
count: items.length,
items,
};
}
async previewContainerTrainSchedule(dto: PreviewContainerTrainScheduleDto) {
const validation = await this.validateContainerBookingsForScheduling(dto);
return {
valid: validation.valid,
violations: validation.violations,
summary: validation.summary,
bookingIds: validation.bookings.map((booking) => booking.id),
wagonPlan: validation.wagonPlan,
};
}
async createContainerTrainSchedule(dto: CreateContainerTrainScheduleDto) {
const validation = await this.validateContainerBookingsForScheduling(dto);
if (!validation.valid) {
throw new BadRequestException({
message: "train_schedule_invalid",
violations: validation.violations,
});
}
const locomotive = await this.selectOrValidateLocomotive(
dto.locomotiveId,
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" },
});
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}`,
);
}
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);
}
async validateContainerBookingsForScheduling(
dto: PreviewContainerTrainScheduleDto,
): Promise<ValidationResult> {
const bookingIds = [...new Set(dto.bookingIds)];
if (!bookingIds.length) {
throw new BadRequestException("At least one booking is required");
}
const [wagonType] = await this.wagonTypesRepository.findAll({
where: { code: DEFAULT_WAGON_TYPE_CODE, isActive: true },
});
if (!wagonType) {
throw new NotFoundException(
`Wagon type ${DEFAULT_WAGON_TYPE_CODE} not found`,
);
}
const bookings = await this.loadBookingsForScheduling(bookingIds);
const violations: string[] = [];
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(", ")}`);
}
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",
);
}
const nonContainerBookings = bookings.filter(
(booking) => booking.freightType !== "CONTAINER",
);
if (nonContainerBookings.length > 0) {
violations.push(
"Only CONTAINER bookings are supported for train scheduling",
);
}
const scheduleDateKey = this.toUtcDateKey(dto.scheduleDate);
const routeMismatch = bookings.some(
(booking) =>
booking.originYardId !== dto.originStationId ||
booking.destinationYardId !== dto.destinationStationId,
);
if (routeMismatch) {
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");
}
const uniqueOriginCount = new Set(
bookings.map((booking) => booking.originYardId),
).size;
if (uniqueOriginCount > 1) {
violations.push("Selected bookings must share the same origin station");
}
const uniqueDestinationCount = new Set(
bookings.map((booking) => booking.destinationYardId),
).size;
if (uniqueDestinationCount > 1) {
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",
);
}
const totalWeightTons = this.roundTons(
bookings.reduce(
(sum, booking) => sum + Number(booking.cargoTotalWeightVgm ?? 0),
0,
),
);
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`,
);
}
if (totalLengthMeters > MAX_TRAIN_LENGTH_METERS) {
violations.push(
`Total wagon length ${totalLengthMeters}m exceeds max train length ${MAX_TRAIN_LENGTH_METERS}m`,
);
}
if (
wagonType.maxWagonsPerTrain != null &&
wagonPlan.length > Number(wagonType.maxWagonsPerTrain)
) {
violations.push(
`Wagon count ${wagonPlan.length} exceeds wagon marshalling limit ${wagonType.maxWagonsPerTrain}`,
);
}
const availableLocomotiveCount = await this.dataSource
.getRepository(Locomotive)
.count({
where: { status: "AVAILABLE" as LocomotiveStatus },
});
if (availableLocomotiveCount === 0) {
violations.push("No available locomotive exists for scheduling");
} else {
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");
}
}
return {
valid: violations.length === 0,
violations,
bookings,
wagonType,
summary: {
totalBookings: bookings.length,
totalWeightTons,
wagonType: wagonType.code,
wagonsNeeded: wagonPlan.length,
totalLengthMeters,
},
wagonPlan,
};
}
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),
);
return {
sequenceNo: index + 1,
capacityTons: wagonCapacityTons,
lengthMeters: this.roundTons(Number(wagonType.lengthMeters)),
assignedWeightTons,
allocations: [],
};
});
}
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 (Number(locomotive.maxPullWeightTons) < totalWeightTons) {
throw new BadRequestException(
`Locomotive ${locomotive.code} cannot pull ${totalWeightTons}T`,
);
}
return locomotive;
}
async buildTrainSet(
manager: EntityManager,
locomotive: Locomotive,
wagonType: WagonType,
totalWeightTons: number,
totalLengthMeters: number,
wagonPlan: WagonPlanRecord[],
) {
const trainSet = manager.getRepository(TrainSet).create({
locomotiveId: locomotive.id,
totalWeightTons,
totalLengthMeters,
wagonCount: wagonPlan.length,
status: "ASSIGNED",
});
const savedTrainSet = await manager.getRepository(TrainSet).save(trainSet);
const wagons = wagonPlan.map((wagon) =>
manager.getRepository(TrainSetWagon).create({
trainSetId: savedTrainSet.id,
wagonTypeId: wagonType.id,
sequenceNo: wagon.sequenceNo,
capacityTons: wagon.capacityTons,
lengthMeters: wagon.lengthMeters,
assignedWeightTons: wagon.assignedWeightTons,
}),
);
await manager.getRepository(TrainSetWagon).save(wagons);
return savedTrainSet;
}
allocateBookingsToWagons(
bookings: Booking[],
baseWagonPlan: WagonPlanRecord[],
): WagonPlanRecord[] {
const remaining = bookings.map((booking) => ({
bookingId: booking.id,
bookingReference: booking.reference,
remainingWeightTons: this.roundTons(
Number(booking.cargoTotalWeightVgm ?? 0),
),
}));
let bookingIndex = 0;
return baseWagonPlan.map((wagon) => {
let wagonRemaining = this.roundTons(wagon.capacityTons);
const allocations: WagonAllocationRecord[] = [];
let assignedWeightTons = 0;
while (wagonRemaining > 0 && bookingIndex < remaining.length) {
const booking = remaining[bookingIndex];
const allocatedWeightTons = this.roundTons(
Math.min(wagonRemaining, booking.remainingWeightTons),
);
if (allocatedWeightTons <= 0) {
bookingIndex += 1;
continue;
}
allocations.push({
bookingId: booking.bookingId,
bookingReference: booking.bookingReference,
allocatedWeightTons,
});
booking.remainingWeightTons = this.roundTons(
booking.remainingWeightTons - allocatedWeightTons,
);
wagonRemaining = this.roundTons(wagonRemaining - allocatedWeightTons);
assignedWeightTons = this.roundTons(
assignedWeightTons + allocatedWeightTons,
);
if (booking.remainingWeightTons <= 0) {
bookingIndex += 1;
}
}
return {
...wagon,
assignedWeightTons,
allocations,
};
});
}
async getContainerTrainSchedules() {
const schedules = await this.dataSource.getRepository(TrainSchedule).find({
relations: {
trainSet: { locomotive: true },
originStation: true,
destinationStation: true,
scheduleBookings: true,
},
order: { scheduledDepartureDate: "DESC", createdAt: "DESC" },
});
return schedules.map((schedule) => ({
id: schedule.id,
scheduleDate: schedule.scheduledDepartureDate,
origin:
schedule.originStation?.label ?? schedule.originStation?.code ?? null,
destination:
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,
}
: null,
wagonCount: schedule.trainSet?.wagonCount ?? 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: { company: true, originYard: true, destinationYard: true },
},
},
});
if (!schedule) {
throw new NotFoundException(`Train schedule ${id} not found`);
}
return {
id: schedule.id,
status: schedule.status,
scheduledDepartureDate: schedule.scheduledDepartureDate,
scheduledArrivalDate: schedule.scheduledArrivalDate,
originStation: schedule.originStation,
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),
),
}
: 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,
bookings:
schedule.scheduleBookings?.map((scheduleBooking) => ({
id: scheduleBooking.booking?.id ?? scheduleBooking.bookingId,
reference: scheduleBooking.booking?.reference ?? null,
customer:
scheduleBooking.booking?.company?.name ??
scheduleBooking.booking?.company?.email ??
null,
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 } },
});
if (!schedule) {
throw new NotFoundException(`Train schedule ${id} not found`);
}
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(TrainSchedule).update(schedule.id, {
status: "CANCELLED",
});
if (schedule.trainSetId) {
await manager.getRepository(TrainSet).update(schedule.trainSetId, {
status: "CANCELLED",
});
}
if (schedule.trainSet?.locomotiveId) {
await manager
.getRepository(Locomotive)
.update(schedule.trainSet.locomotiveId, {
status: "AVAILABLE",
});
}
});
return this.getContainerTrainScheduleById(id);
}
private async loadBookingsForScheduling(bookingIds: string[]) {
return this.dataSource.getRepository(Booking).find({
where: { id: In(bookingIds) },
relations: {
company: true,
originYard: true,
destinationYard: true,
bookingContainers: { containerType: true },
},
order: { createdAt: "ASC" },
});
}
private toUtcDateKey(value: Date | string) {
const date = value instanceof Date ? value : new Date(value);
return date.toISOString().slice(0, 10);
}
private roundTons(value: number | string | null | undefined) {
const numericValue = typeof value === "number" ? value : Number(value ?? 0);
if (!Number.isFinite(numericValue)) {
return 0;
}
return Number(numericValue.toFixed(3));
}
}

View File

@@ -0,0 +1,39 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-booking-allocation.entity';
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
import { TrainSet } from './train-set.entity';
@Entity({ schema: 'freight', name: 'train_set_wagons' })
@Index(['trainSetId', 'sequenceNo'], { unique: true })
export class TrainSetWagon extends BaseEntity {
@Column({ name: 'train_set_id', type: 'uuid' })
trainSetId!: string;
@ManyToOne(() => TrainSet, (trainSet) => trainSet.wagons, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'train_set_id' })
trainSet?: TrainSet;
@Column({ name: 'wagon_type_id', type: 'uuid' })
wagonTypeId!: string;
@ManyToOne(() => WagonType, (wagonType) => wagonType.trainSetWagons)
@JoinColumn({ name: 'wagon_type_id' })
wagonType?: WagonType;
@Column({ name: 'sequence_no', type: 'int' })
sequenceNo!: number;
@Column({ name: 'capacity_tons', type: 'numeric', precision: 10, scale: 3 })
capacityTons!: number;
@Column({ name: 'length_meters', type: 'numeric', precision: 10, scale: 3 })
lengthMeters!: number;
@Column({ name: 'assigned_weight_tons', type: 'numeric', precision: 10, scale: 3, default: 0 })
assignedWeightTons!: number;
@OneToMany(() => WagonBookingAllocation, (allocation) => allocation.trainSetWagon)
allocations?: WagonBookingAllocation[];
}

View File

@@ -0,0 +1,46 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, OneToOne } from 'typeorm';
import { Locomotive } from '../../locomotives/entities/locomotive.entity';
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
import { TrainSetWagon } from './train-set-wagon.entity';
export const TRAIN_SET_STATUSES = [
'DRAFT',
'ASSIGNED',
'DISPATCHED',
'COMPLETED',
'CANCELLED',
] as const;
export type TrainSetStatus = (typeof TRAIN_SET_STATUSES)[number];
@Entity({ schema: 'freight', name: 'train_sets' })
@Index(['locomotiveId'])
@Index(['status'])
export class TrainSet extends BaseEntity {
@Column({ name: 'locomotive_id', type: 'uuid' })
locomotiveId!: string;
@ManyToOne(() => Locomotive, (locomotive) => locomotive.trainSets)
@JoinColumn({ name: 'locomotive_id' })
locomotive?: Locomotive;
@Column({ name: 'total_weight_tons', type: 'numeric', precision: 10, scale: 3 })
totalWeightTons!: number;
@Column({ name: 'total_length_meters', type: 'numeric', precision: 10, scale: 3 })
totalLengthMeters!: number;
@Column({ name: 'wagon_count', type: 'int' })
wagonCount!: number;
@Column({ name: 'status', type: 'varchar', length: 20, default: 'DRAFT' })
status!: TrainSetStatus;
@OneToMany(() => TrainSetWagon, (wagon) => wagon.trainSet)
wagons?: TrainSetWagon[];
@OneToOne(() => TrainSchedule, (schedule) => schedule.trainSet)
trainSchedule?: TrainSchedule;
}

View File

@@ -0,0 +1,16 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { TrainSetWagon } from './entities/train-set-wagon.entity';
@Injectable()
export class TrainSetWagonsRepository extends BaseRepository<TrainSetWagon> {
constructor(
@InjectRepository(TrainSetWagon)
repository: Repository<TrainSetWagon>,
) {
super(repository);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TrainSet } from './entities/train-set.entity';
import { TrainSetWagon } from './entities/train-set-wagon.entity';
import { TrainSetWagonsRepository } from './train-set-wagons.repository';
import { TrainSetsRepository } from './train-sets.repository';
@Module({
imports: [TypeOrmModule.forFeature([TrainSet, TrainSetWagon])],
providers: [TrainSetsRepository, TrainSetWagonsRepository],
exports: [TrainSetsRepository, TrainSetWagonsRepository],
})
export class TrainSetsModule {}

View File

@@ -0,0 +1,16 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { TrainSet } from './entities/train-set.entity';
@Injectable()
export class TrainSetsRepository extends BaseRepository<TrainSet> {
constructor(
@InjectRepository(TrainSet)
repository: Repository<TrainSet>,
) {
super(repository);
}
}

View File

@@ -0,0 +1,33 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, OneToMany } from 'typeorm';
import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity';
@Entity({ schema: 'freight', name: 'wagon_types' })
@Index(['code'])
@Index(['isActive'])
export class WagonType extends BaseEntity {
@Column({ name: 'code', type: 'varchar', length: 32, unique: true })
code!: string;
@Column({ name: 'name', type: 'varchar', length: 100 })
name!: string;
@Column({ name: 'capacity_tons', type: 'numeric', precision: 10, scale: 3 })
capacityTons!: number;
@Column({ name: 'length_meters', type: 'numeric', precision: 10, scale: 3 })
lengthMeters!: number;
@Column({ name: 'max_wagons_per_train', type: 'int', nullable: true })
maxWagonsPerTrain?: number | null;
@Column({ name: 'supported_load_types', type: 'text', array: true, default: '{}' })
supportedLoadTypes!: string[];
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
@OneToMany(() => TrainSetWagon, (wagon) => wagon.wagonType)
trainSetWagons?: TrainSetWagon[];
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { WagonType } from './entities/wagon-type.entity';
import { WagonTypesRepository } from './wagon-types.repository';
import { WagonTypesService } from './wagon-types.service';
@Module({
imports: [TypeOrmModule.forFeature([WagonType])],
providers: [WagonTypesRepository, WagonTypesService],
exports: [WagonTypesRepository, WagonTypesService],
})
export class WagonTypesModule {}

View File

@@ -0,0 +1,16 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { WagonType } from './entities/wagon-type.entity';
@Injectable()
export class WagonTypesRepository extends BaseRepository<WagonType> {
constructor(
@InjectRepository(WagonType)
repository: Repository<WagonType>,
) {
super(repository);
}
}

View File

@@ -0,0 +1,19 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { WagonType } from './entities/wagon-type.entity';
import { WagonTypesRepository } from './wagon-types.repository';
@Injectable()
export class WagonTypesService {
constructor(private readonly wagonTypesRepository: WagonTypesRepository) {}
async findByCode(code: string): Promise<WagonType> {
const [wagonType] = await this.wagonTypesRepository.findAll({ where: { code } });
if (!wagonType) {
throw new NotFoundException(`Wagon type ${code} not found`);
}
return wagonType;
}
}

View File

@@ -0,0 +1,302 @@
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";
const SEED_FLAG = "SEED_DEMO_BOOKINGS";
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,
},
];
const CONTAINER_TYPES = [
{ code: "20FT", label: "20FT", sizeFt: 20 },
{ code: "40FT", label: "40FT", sizeFt: 40 },
];
const DEMO_BOOKINGS = [
{
reference: "BKG-CONT-001",
containerCode: "40FT",
quantity: 20,
totalWeightTons: 500,
originCode: "DJIBOUTI",
destinationCode: "ADDIS_ABABA",
scheduledDate: "2026-06-20T08:00:00.000Z",
},
{
reference: "BKG-CONT-002",
containerCode: "20FT",
quantity: 10,
totalWeightTons: 300,
originCode: "DJIBOUTI",
destinationCode: "ADDIS_ABABA",
scheduledDate: "2026-06-20T08:00:00.000Z",
},
{
reference: "BKG-CONT-003",
containerCode: "40FT",
quantity: 15,
totalWeightTons: 450,
originCode: "DJIBOUTI",
destinationCode: "ADDIS_ABABA",
scheduledDate: "2026-06-20T08:00:00.000Z",
},
{
reference: "BKG-CONT-007",
containerCode: "20FT",
quantity: 6,
totalWeightTons: 180,
originCode: "DJIBOUTI",
destinationCode: "ADDIS_ABABA",
scheduledDate: "2026-06-20T08:00:00.000Z",
},
{
reference: "BKG-CONT-004",
containerCode: "40FT",
quantity: 12,
totalWeightTons: 360,
originCode: "ADDIS_ABABA",
destinationCode: "DIRE_DAWA",
scheduledDate: "2026-06-20T08:00:00.000Z",
},
{
reference: "BKG-CONT-005",
containerCode: "20FT",
quantity: 8,
totalWeightTons: 160,
originCode: "DJIBOUTI",
destinationCode: "ADDIS_ABABA",
scheduledDate: "2026-06-21T08:00:00.000Z",
},
{
reference: "BKG-CONT-006",
containerCode: "40FT",
quantity: 80,
totalWeightTons: 3600,
originCode: "DJIBOUTI",
destinationCode: "ADDIS_ABABA",
scheduledDate: "2026-06-20T08:00:00.000Z",
},
];
@Injectable()
export class DemoBookingsSeeder {
private readonly logger = new Logger(DemoBookingsSeeder.name);
constructor(private readonly dataSource: DataSource) { }
async run() {
const shouldSeed = process.env[SEED_FLAG]?.trim().toLowerCase() === "true";
if (!shouldSeed) {
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",
capacityTons: 70,
lengthMeters: 14,
maxWagonsPerTrain: 53,
supportedLoadTypes: ["CONTAINER"],
isActive: true,
},
{ conflictPaths: { code: true } },
);
await manager.getRepository(Locomotive).upsert(
[
{
code: "LOC-001",
name: "Demo Locomotive 1",
maxPullWeightTons: 3500,
status: "AVAILABLE",
},
{
code: "LOC-002",
name: "Demo Locomotive 2",
maxPullWeightTons: 2500,
status: "AVAILABLE",
},
],
{ conflictPaths: { code: true } },
);
await manager.getRepository(Yard).upsert(
YARDS.map((yard) => ({ ...yard, isActive: true })),
{ conflictPaths: { code: true } },
);
await manager.getRepository(ServiceType).upsert(
{
code: SERVICE_TYPE_CODE,
serviceName: "Rail Container Service",
description: "Temporary service type for train scheduling demos",
canBeBookedAlone: true,
includesFirstMile: false,
includesLastMile: false,
includesCustoms: false,
priorityBonusPoints: 0,
isActive: true,
displayOrder: 1,
},
{ conflictPaths: { code: true } },
);
await manager.getRepository(ContainerType).upsert(
CONTAINER_TYPES.map((containerType, index) => ({
...containerType,
wagonsPerUnit: 1,
isReefer: false,
isOpenTop: false,
isActive: true,
displayOrder: index + 1,
})),
{ conflictPaths: { code: true } },
);
await manager.getRepository(Customer).upsert(
{
userId: "00000000-0000-0000-0000-000000000111",
firstName: "Train",
lastName: "Scheduling",
email: CUSTOMER_EMAIL,
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",
generalManagerEmail: CUSTOMER_EMAIL,
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(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,
]),
);
for (const demoBooking of DEMO_BOOKINGS) {
const origin = yardByCode.get(demoBooking.originCode);
const destination = yardByCode.get(demoBooking.destinationCode);
const containerType = containerTypeByCode.get(
demoBooking.containerCode,
);
if (!origin || !destination || !containerType) {
throw new Error(
`demo_booking_seed_dependency_missing:${demoBooking.reference}`,
);
}
const vgmPerUnitTons =
demoBooking.totalWeightTons / demoBooking.quantity;
await manager.getRepository(Booking).upsert(
{
reference: demoBooking.reference,
companyId: customer.id,
status: "APPROVED",
scheduledDate: new Date(demoBooking.scheduledDate),
totalAmount: 0,
paymentStatus: "PENDING",
contractType: "NEW",
serviceTypeId: serviceType.id,
equipmentReturn: "WITHOUT_RETURN",
originYardId: origin.id,
destinationYardId: destination.id,
tradeDirection: "IMPORT",
freightType: "CONTAINER",
cargoTypeId: null,
cargoFreeText: null,
shippingLineId: null,
cargoTotalWeightVgm: demoBooking.totalWeightTons,
isHazardous: false,
paymentCurrency: "USD",
allowConsolidation: false,
priorityScore: 0,
versionNumber: 1,
},
{ conflictPaths: { reference: true } },
);
const booking = await manager.getRepository(Booking).findOneByOrFail({
reference: demoBooking.reference,
});
await manager
.getRepository(BookingContainer)
.delete({ bookingId: booking.id });
await manager.getRepository(BookingContainer).insert({
id: randomUUID(),
bookingId: booking.id,
containerTypeId: containerType.id,
quantity: demoBooking.quantity,
vgmPerUnitTons,
totalVgmTons: demoBooking.totalWeightTons,
wagonsRequired: Math.ceil(demoBooking.totalWeightTons / 70),
weightLimitRuleId: null,
isOverweight: demoBooking.totalWeightTons > 70,
overweightExcessTons:
demoBooking.totalWeightTons > 70
? demoBooking.totalWeightTons - 70
: null,
});
}
});
this.logger.log("Seeded demo train scheduling data");
}
}

View File

@@ -0,0 +1,125 @@
import { Injectable, Logger } from "@nestjs/common";
import { DataSource } from "typeorm";
import { FileUploadField } from "../modules/file-upload-settings/entities/file-upload-field.entity";
import { FileUploadSetting } from "../modules/file-upload-settings/entities/file-upload-setting.entity";
const COMPANY_ONBOARDING_DOCUMENTS = [
{
code: "company_onboarding_documents_customer",
label: "Customer onboarding documents",
entity: "customer",
},
{
code: "company_onboarding_documents_forwarder",
label: "Forwarder onboarding documents",
entity: "other",
},
{
code: "company_onboarding_documents_transporter",
label: "Transporter onboarding documents",
entity: "other",
},
{
code: "company_onboarding_documents_forwarder_dj",
label: "Djibouti forwarder onboarding documents",
entity: "other",
},
] as const;
const COMPANY_ONBOARDING_DESCRIPTION =
"Required documents for external company onboarding. The same set applies to customers, forwarders, transporters, and brokers.";
const COMPANY_ONBOARDING_FIELDS = [
{
fileKey: "business_license",
fileLabel: "Business License / Trade License",
helpText: "Verified against the government trade system during registration.",
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
maxSizeMb: 10,
displayOrder: 1,
},
{
fileKey: "tin_certificate",
fileLabel: "TIN Certificate",
helpText: "Verified against the TIN registry during registration.",
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
maxSizeMb: 10,
displayOrder: 2,
},
{
fileKey: "national_id_passport",
fileLabel: "National ID / Passport",
helpText: "Verified against the National ID API during registration.",
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
maxSizeMb: 10,
displayOrder: 3,
},
] as const;
@Injectable()
export class FileUploadSettingsSeeder {
private readonly logger = new Logger(FileUploadSettingsSeeder.name);
constructor(private readonly dataSource: DataSource) {}
async run() {
await this.dataSource.transaction(async (manager) => {
const settingRepository = manager.getRepository(FileUploadSetting);
const fieldRepository = manager.getRepository(FileUploadField);
for (const documentSetting of COMPANY_ONBOARDING_DOCUMENTS) {
await settingRepository.upsert(
{
code: documentSetting.code,
label: documentSetting.label,
description: COMPANY_ONBOARDING_DESCRIPTION,
entity: documentSetting.entity,
},
{
conflictPaths: { code: true },
},
);
const setting = await settingRepository.findOne({
where: { code: documentSetting.code },
select: { id: true, code: true },
});
if (!setting) {
throw new Error(`file_upload_setting_seed_failed:${documentSetting.code}`);
}
await fieldRepository.delete({ settingId: setting.id });
await fieldRepository.insert(
COMPANY_ONBOARDING_FIELDS.map((field, index) => ({
settingId: setting.id,
fileKey: field.fileKey,
fileLabel: field.fileLabel,
helpText: field.helpText,
isRequired: field.isRequired,
isMultiple: field.isMultiple,
maxFiles: field.maxFiles,
allowedExtensions: [...field.allowedExtensions],
maxSizeMb: field.maxSizeMb,
displayOrder: field.displayOrder ?? index + 1,
})),
);
}
});
this.logger.log(
"Ensured company onboarding file upload settings for external companies",
);
}
}

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

@@ -8,6 +8,7 @@ import {
Paperclip,
Settings,
SlidersHorizontal,
TrainTrack,
} from "lucide-react";
import { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@/components/layout";
@@ -34,6 +35,7 @@ import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
<<<<<<< HEAD
import {
getCategorySidebarChildren,
RULE_ENGINE_RESOURCES,
@@ -42,6 +44,97 @@ import {
import type { RuleEngineResourceSlug } from "./types/rule-engine";
const filterRuleEngineChildren = (
=======
import TrainsPage from "./pages/trains/TrainsPage";
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
{
title: "Main menu",
mutedTitle: true,
items: [
{
label: "Overview",
href: "/dashboard/overview",
icon: <LayoutDashboard />,
},
{
label: "Booking requests",
href: "/dashboard/booking-requests",
icon: <FileText />,
},
{
label: "Train scheduling",
href: "/dashboard/operations/train-scheduling",
icon: <TrainTrack />,
},
...demoItems,
],
},
{
title: "Administration",
items: [
{
label: "User management",
href: "/dashboard/user-management",
icon: <Network />,
children: [
{
label: "Users",
href: "/dashboard/user-management/users",
},
{
label: "Employees",
href: "/dashboard/user-management/employees",
},
{
label: "Position Types",
href: "/dashboard/user-management/position-types",
},
{
label: "Permissions",
href: "/dashboard/user-management/permissions",
},
{
label: "Roles",
href: "/dashboard/user-management/roles",
},
],
},
{
label: "File settings",
href: "/dashboard/file-settings",
icon: <Paperclip />,
},
{
label: "Dropdown settings",
href: "/dashboard/dropdown-settings",
icon: <Settings />,
},
],
},
{
title: "Freight configuration",
mutedTitle: true,
items: [
{
label: "Configuration",
href: "/dashboard/configuration",
icon: <Boxes />,
children: getCategorySidebarChildren("configuration"),
},
{
label: "Rules",
href: "/dashboard/rules",
icon: <SlidersHorizontal />,
children: getCategorySidebarChildren("rules"),
},
],
},
];
const hasPermission = (
>>>>>>> bd6ad54eea229305a214d06081e0ffd27fe163b8
user: ReturnType<typeof useAuth>["user"],
category: RuleEngineNavCategory,
): SidebarItem[] =>
@@ -202,6 +295,7 @@ const App = () => {
<Route path="/dashboard" element={<DashboardShell />}>
<Route path="overview" element={<OverviewPage />} />
<<<<<<< HEAD
<Route
path="booking-requests"
element={
@@ -226,6 +320,15 @@ const App = () => {
</PermissionRoute>
}
/>
=======
<Route path="booking-requests" element={<BookingRequestsPage />} />
<Route path="booking-requests/:id" element={<BookingRequestDetailPage />} />
<Route
path="booking-requests/:id/contract"
element={<BookingContractPage />}
/>
<Route path="operations/train-scheduling" element={<TrainsPage />} />
>>>>>>> bd6ad54eea229305a214d06081e0ffd27fe163b8
<Route path="user-management" element={<UserManagementPage />} />
<Route path="user-management/users" element={<UsersPage />} />

View File

@@ -1,5 +1,6 @@
import type { BookingListFilter } from "@/services/bookings.service";
import type { RuleEngineListParams } from "@/services/ruleEngine/ruleEngine.service";
import type { TrainScheduleFilters } from "@/types/trainScheduling";
import type { RuleEngineResourceSlug } from "@/types/rule-engine";
export const QUERY_KEYS = {
@@ -37,6 +38,16 @@ export const QUERY_KEYS = {
byId: (id: string) => ["bookings", "detail", id] as const,
},
TRAIN_SCHEDULING: {
ROOT: ["train-scheduling"] as const,
eligible: (filters?: TrainScheduleFilters) =>
["train-scheduling", "eligible-bookings", filters ?? {}] as const,
locomotives: () => ["train-scheduling", "locomotives"] as const,
stations: () => ["train-scheduling", "stations"] as const,
schedules: () => ["train-scheduling", "schedules"] as const,
scheduleById: (id: string) => ["train-scheduling", "schedule", id] as const,
},
RULE_ENGINE: {
ROOT: ["rule-engine"] as const,
list: (resource: RuleEngineResourceSlug | string, params?: RuleEngineListParams) =>

View File

@@ -108,6 +108,19 @@ export const URL_CONSTANTS = {
VERIFY: "/api/otp/verify",
},
LOCOMOTIVES: {
BASE: "/locomotives",
},
TRAIN_SCHEDULING: {
ELIGIBLE_BOOKINGS: "/train-scheduling/container/eligible-bookings",
PREVIEW: "/train-scheduling/container/preview",
SCHEDULES: "/train-scheduling/container/schedules",
SCHEDULE_BY_ID: (id: string) => `/train-scheduling/container/schedules/${id}`,
CANCEL_SCHEDULE: (id: string) =>
`/train-scheduling/container/schedules/${id}/cancel`,
},
RULE_ENGINE: {
CARGO_TYPES: "/cargo-types",
CARGO_TYPE_BY_ID: (id: string) => `/cargo-types/${id}`,

View File

@@ -76,7 +76,9 @@ export const useContainerTypeOptions = (
enabled = true,
) =>
useQuery({
queryKey: QUERY_KEYS.RULE_ENGINE.selectOptions("container-types", {
queryKey: QUERY_KEYS.RULE_ENGINE.selectOptions('container-types', {
page: 1,
pageSize: CONTAINER_TYPE_OPTIONS_PAGE_SIZE,
includeNone,
}),
queryFn: () =>

View File

@@ -1,11 +1,760 @@
import FeaturePlaceholder from "@/components/FeaturePlaceholder";
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { isAxiosError } from 'axios';
import toast from 'react-hot-toast';
import { Calendar, RefreshCw, TrainTrack } from 'lucide-react';
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@edr/ui-common';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
import { trainSchedulingService } from '@/services/trainScheduling.service';
import type {
EligibleContainerBooking,
TrainScheduleFilters,
TrainSchedulePreviewResponse,
YardOption,
} from '@/types/trainScheduling';
const inputClassName =
'w-full rounded-xl border border-border bg-background px-3 py-2.5 text-sm text-foreground outline-none transition focus:border-emerald-500 focus:ring-2 focus:ring-emerald-100 dark:focus:ring-emerald-950';
const formatDate = (value?: string | null) => {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '-';
return new Intl.DateTimeFormat('en', {
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(date);
};
const formatDayInput = (value?: string | null) => {
if (!value) return '';
return value.slice(0, 10);
};
const parseError = (error: unknown, fallback: string) => {
if (isAxiosError(error)) {
const message = error.response?.data?.message;
if (Array.isArray(message)) return message.join(', ');
if (typeof message === 'string') return message;
const violations = error.response?.data?.violations;
if (Array.isArray(violations)) return violations.join(', ');
}
return fallback;
};
const deriveFromBooking = (
booking: EligibleContainerBooking | undefined,
stations: YardOption[],
) => {
if (!booking) {
return { originStationId: '', destinationStationId: '', scheduleDate: '' };
}
const originStationId = stations.find((station) => station.name === booking.origin)?.id ?? '';
const destinationStationId =
stations.find((station) => station.name === booking.destination)?.id ?? '';
return {
originStationId,
destinationStationId,
scheduleDate: formatDayInput(booking.preferredDepartureDate),
};
};
const TrainsPage = () => {
const qc = useQueryClient();
const [filters, setFilters] = useState<TrainScheduleFilters>({});
const [selectedBookingIds, setSelectedBookingIds] = useState<string[]>([]);
const [preview, setPreview] = useState<TrainSchedulePreviewResponse | null>(null);
const [selectedLocomotiveId, setSelectedLocomotiveId] = useState('');
const [detailId, setDetailId] = useState<string | null>(null);
const [scheduleSearch, setScheduleSearch] = useState('');
const [scheduleStatusFilter, setScheduleStatusFilter] = useState('ALL');
const stationsQuery = useQuery({
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.stations(),
queryFn: () => trainSchedulingService.getStations(),
});
const eligibleQuery = useQuery({
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.eligible(filters),
queryFn: () => trainSchedulingService.getEligibleBookings(filters),
});
const locomotivesQuery = useQuery({
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.locomotives(),
queryFn: () => trainSchedulingService.getAvailableLocomotives(),
});
const schedulesQuery = useQuery({
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.schedules(),
queryFn: () => trainSchedulingService.listSchedules(),
});
const detailQuery = useQuery({
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(detailId ?? ''),
queryFn: () => trainSchedulingService.getScheduleById(detailId!),
enabled: Boolean(detailId),
});
const eligibleItems = eligibleQuery.data?.items ?? [];
const filteredSchedules = useMemo(() => {
const query = scheduleSearch.trim().toLowerCase();
return (schedulesQuery.data ?? []).filter((schedule) => {
const matchesStatus =
scheduleStatusFilter === 'ALL' || schedule.status === scheduleStatusFilter;
if (!matchesStatus) {
return false;
}
if (!query) {
return true;
}
const haystack = [
schedule.id,
schedule.origin ?? '',
schedule.destination ?? '',
schedule.locomotive?.code ?? '',
schedule.status,
]
.join(' ')
.toLowerCase();
return haystack.includes(query);
});
}, [scheduleSearch, scheduleStatusFilter, schedulesQuery.data]);
const selectedBookings = useMemo(
() => eligibleItems.filter((booking) => selectedBookingIds.includes(booking.id)),
[eligibleItems, selectedBookingIds],
);
const summary = useMemo(() => {
const totalWeightTons = selectedBookings.reduce((sum, booking) => sum + booking.weightTons, 0);
const wagonsNeeded = Math.ceil(totalWeightTons / 70);
const totalLengthMeters = wagonsNeeded * 14;
const routeSet = new Set(selectedBookings.map((booking) => `${booking.origin} -> ${booking.destination}`));
const dateSet = new Set(selectedBookings.map((booking) => formatDayInput(booking.preferredDepartureDate)));
return {
count: selectedBookings.length,
totalWeightTons,
wagonsNeeded: Number.isFinite(wagonsNeeded) ? wagonsNeeded : 0,
totalLengthMeters: Number.isFinite(totalLengthMeters) ? totalLengthMeters : 0,
route: routeSet.size === 1 ? [...routeSet][0] : selectedBookings.length ? 'Mixed route' : '-',
scheduleDate: dateSet.size === 1 ? [...dateSet][0] : selectedBookings.length ? 'Mixed date' : '-',
};
}, [selectedBookings]);
const previewMutation = useMutation({
mutationFn: () => {
if (!filters.originStationId || !filters.destinationStationId || !filters.scheduleDate) {
throw new Error('Please select origin, destination, and schedule date');
}
return trainSchedulingService.preview({
bookingIds: selectedBookingIds,
scheduleDate: new Date(`${filters.scheduleDate}T08:00:00.000Z`).toISOString(),
originStationId: filters.originStationId,
destinationStationId: filters.destinationStationId,
});
},
onSuccess: (data) => {
setPreview(data);
toast.success(data.valid ? 'Preview generated' : 'Preview has validation issues');
},
onError: (error) => {
toast.error(parseError(error, 'Failed to preview train schedule'));
},
});
const createMutation = useMutation({
mutationFn: () => {
if (!selectedLocomotiveId) {
throw new Error('Please select a locomotive');
}
if (!filters.originStationId || !filters.destinationStationId || !filters.scheduleDate) {
throw new Error('Please select origin, destination, and schedule date');
}
return trainSchedulingService.createSchedule({
bookingIds: selectedBookingIds,
scheduleDate: new Date(`${filters.scheduleDate}T08:00:00.000Z`).toISOString(),
originStationId: filters.originStationId,
destinationStationId: filters.destinationStationId,
locomotiveId: selectedLocomotiveId,
});
},
onSuccess: (data) => {
toast.success('Train schedule created');
setSelectedBookingIds([]);
setSelectedLocomotiveId('');
setPreview(null);
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.ROOT });
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.schedules() });
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.locomotives() });
setDetailId(data.id);
},
onError: (error) => {
toast.error(parseError(error, 'Failed to create train schedule'));
},
});
const cancelMutation = useMutation({
mutationFn: (id: string) => trainSchedulingService.cancelSchedule(id),
onSuccess: (data) => {
toast.success('Train schedule cancelled');
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.schedules() });
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.locomotives() });
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.ROOT });
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(data.id) });
setDetailId(data.id);
},
onError: (error) => {
toast.error(parseError(error, 'Failed to cancel train schedule'));
},
});
const toggleBooking = (booking: EligibleContainerBooking, checked: boolean) => {
setSelectedBookingIds((current) => {
if (checked) {
const next = [...new Set([...current, booking.id])];
if (next.length === 1) {
const defaults = deriveFromBooking(booking, stationsQuery.data ?? []);
setFilters((prev) => ({
...prev,
originStationId: prev.originStationId || defaults.originStationId,
destinationStationId: prev.destinationStationId || defaults.destinationStationId,
scheduleDate: prev.scheduleDate || defaults.scheduleDate,
}));
}
return next;
}
return current.filter((id) => id !== booking.id);
});
setPreview(null);
};
const detail = detailQuery.data;
const isBusy = previewMutation.isPending || createMutation.isPending;
return (
<FeaturePlaceholder
title="Trains"
description="Coordinate train assignments, scheduling visibility, and operational readiness."
/>
<div className="space-y-6 p-6">
<Breadcrumbs items={[{ label: 'Operations' }, { label: 'Train scheduling' }]} />
<section className="overflow-hidden rounded-3xl border border-border bg-card shadow-sm">
<div className="flex flex-col gap-5 border-b border-border px-6 py-6 lg:flex-row lg:items-center lg:justify-between">
<div className="flex items-start gap-4">
<div className="flex size-14 items-center justify-center rounded-2xl bg-primary text-primary-foreground">
<TrainTrack className="size-7" />
</div>
<div>
<h1 className="text-2xl font-bold tracking-tight">Train Scheduling</h1>
<p className="mt-1 text-sm text-muted-foreground">
Build container train schedules from compatible bookings, preview wagon plans, and assign locomotives.
</p>
</div>
</div>
<Button
variant="outline"
className="gap-2"
onClick={() => {
void eligibleQuery.refetch();
void schedulesQuery.refetch();
void locomotivesQuery.refetch();
}}
>
<RefreshCw className="size-4" />
Refresh
</Button>
</div>
<div className="grid gap-6 p-6 xl:grid-cols-[1.8fr,1fr]">
<div className="space-y-6">
<section className="rounded-2xl border border-border bg-background/60 p-5">
<div className="mb-4 flex items-center gap-2">
<Calendar className="size-4 text-muted-foreground" />
<h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Filters
</h2>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="space-y-2">
<label className="text-sm font-medium">Origin station</label>
<Select
value={filters.originStationId ?? ''}
onValueChange={(value) =>
setFilters((current) => ({
...current,
originStationId: value === '__all__' ? undefined : value,
}))
}
>
<SelectTrigger>
<SelectValue placeholder="All origins" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">All origins</SelectItem>
{(stationsQuery.data ?? []).map((station) => (
<SelectItem key={station.id} value={station.id}>
{station.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Destination station</label>
<Select
value={filters.destinationStationId ?? ''}
onValueChange={(value) =>
setFilters((current) => ({
...current,
destinationStationId: value === '__all__' ? undefined : value,
}))
}
>
<SelectTrigger>
<SelectValue placeholder="All destinations" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">All destinations</SelectItem>
{(stationsQuery.data ?? []).map((station) => (
<SelectItem key={station.id} value={station.id}>
{station.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Schedule date</label>
<input
className={inputClassName}
type="date"
value={filters.scheduleDate ?? ''}
onChange={(event) =>
setFilters((current) => ({
...current,
scheduleDate: event.target.value || undefined,
}))
}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Booking status</label>
<input
className={inputClassName}
placeholder="APPROVED"
value={filters.status ?? ''}
onChange={(event) =>
setFilters((current) => ({
...current,
status: event.target.value || undefined,
}))
}
/>
</div>
</div>
</section>
<section className="rounded-2xl border border-border bg-background/60 p-5">
<div className="mb-4 flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold">Eligible container bookings</h2>
<p className="text-sm text-muted-foreground">
Only container bookings not already assigned to a schedule appear here.
</p>
</div>
<span className="rounded-full bg-muted px-3 py-1 text-xs font-medium text-muted-foreground">
{eligibleQuery.data?.count ?? 0} bookings
</span>
</div>
<div className="overflow-x-auto rounded-2xl border border-border">
<table className="min-w-full divide-y divide-border text-sm">
<thead className="bg-muted/40 text-left text-xs uppercase tracking-wide text-muted-foreground">
<tr>
<th className="px-3 py-3">Select</th>
<th className="px-3 py-3">Booking</th>
<th className="px-3 py-3">Customer</th>
<th className="px-3 py-3">Container</th>
<th className="px-3 py-3">Qty</th>
<th className="px-3 py-3">Weight</th>
<th className="px-3 py-3">Origin</th>
<th className="px-3 py-3">Destination</th>
<th className="px-3 py-3">Departure</th>
<th className="px-3 py-3">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-border bg-card">
{eligibleItems.map((booking) => (
<tr key={booking.id} className="hover:bg-muted/20">
<td className="px-3 py-3">
<input
type="checkbox"
checked={selectedBookingIds.includes(booking.id)}
onChange={(event) => toggleBooking(booking, event.target.checked)}
/>
</td>
<td className="px-3 py-3 font-medium">{booking.reference}</td>
<td className="px-3 py-3">{booking.customer}</td>
<td className="px-3 py-3">{booking.containerType}</td>
<td className="px-3 py-3">{booking.quantity}</td>
<td className="px-3 py-3">{booking.weightTons.toLocaleString()} T</td>
<td className="px-3 py-3">{booking.origin}</td>
<td className="px-3 py-3">{booking.destination}</td>
<td className="px-3 py-3">{formatDate(booking.preferredDepartureDate)}</td>
<td className="px-3 py-3">{booking.status}</td>
</tr>
))}
{!eligibleQuery.isLoading && eligibleItems.length === 0 ? (
<tr>
<td className="px-3 py-8 text-center text-sm text-muted-foreground" colSpan={10}>
No eligible container bookings matched the current filters.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</section>
</div>
<div className="space-y-6">
<section className="rounded-2xl border border-border bg-background/60 p-5">
<h2 className="text-lg font-semibold">Schedule builder</h2>
<div className="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-1">
<div className="rounded-xl border border-border bg-card p-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Selected bookings</p>
<p className="mt-2 text-2xl font-semibold">{summary.count}</p>
</div>
<div className="rounded-xl border border-border bg-card p-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Total weight</p>
<p className="mt-2 text-2xl font-semibold">{summary.totalWeightTons.toLocaleString()} T</p>
</div>
<div className="rounded-xl border border-border bg-card p-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Route</p>
<p className="mt-2 text-sm font-medium">{summary.route}</p>
</div>
<div className="rounded-xl border border-border bg-card p-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Schedule date</p>
<p className="mt-2 text-sm font-medium">{summary.scheduleDate}</p>
</div>
<div className="rounded-xl border border-border bg-card p-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Estimated wagon type</p>
<p className="mt-2 text-sm font-medium">NW5</p>
</div>
<div className="rounded-xl border border-border bg-card p-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Estimated wagons / length</p>
<p className="mt-2 text-sm font-medium">
{summary.wagonsNeeded} wagons / {summary.totalLengthMeters} m
</p>
</div>
</div>
<div className="mt-5 flex flex-col gap-3">
<Button
className="w-full"
disabled={!selectedBookingIds.length || isBusy}
onClick={() => previewMutation.mutate()}
>
Preview schedule
</Button>
<div className="space-y-2">
<label className="text-sm font-medium">Locomotive</label>
<Select value={selectedLocomotiveId} onValueChange={setSelectedLocomotiveId}>
<SelectTrigger>
<SelectValue placeholder="Select available locomotive" />
</SelectTrigger>
<SelectContent>
{(locomotivesQuery.data ?? []).map((locomotive) => (
<SelectItem key={locomotive.id} value={locomotive.id}>
{locomotive.code} - {locomotive.maxPullWeightTons}T
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
className="w-full"
disabled={!preview?.valid || !selectedLocomotiveId || isBusy}
onClick={() => createMutation.mutate()}
>
Create schedule
</Button>
</div>
{preview ? (
<div className="mt-5 space-y-4 rounded-2xl border border-border bg-card p-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold">Preview result</h3>
<span
className={`rounded-full px-3 py-1 text-xs font-medium ${
preview.valid
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300'
: 'bg-rose-100 text-rose-700 dark:bg-rose-950 dark:text-rose-300'
}`}
>
{preview.valid ? 'Valid' : 'Invalid'}
</span>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-xl border border-border p-3">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Wagons</p>
<p className="mt-1 font-semibold">{preview.summary.wagonsNeeded}</p>
</div>
<div className="rounded-xl border border-border p-3">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Weight</p>
<p className="mt-1 font-semibold">{preview.summary.totalWeightTons} T</p>
</div>
<div className="rounded-xl border border-border p-3">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Length</p>
<p className="mt-1 font-semibold">{preview.summary.totalLengthMeters} m</p>
</div>
</div>
{preview.violations.length > 0 ? (
<div className="rounded-xl border border-rose-200 bg-rose-50 p-3 text-sm text-rose-700 dark:border-rose-950 dark:bg-rose-950/30 dark:text-rose-300">
<ul className="list-disc space-y-1 pl-5">
{preview.violations.map((violation) => (
<li key={violation}>{violation}</li>
))}
</ul>
</div>
) : null}
</div>
) : null}
</section>
<section className="rounded-2xl border border-border bg-background/60 p-5">
<div className="mb-4 flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold">Created schedules</h2>
<p className="text-sm text-muted-foreground">Open a schedule to inspect wagons and allocations.</p>
</div>
<span className="rounded-full bg-muted px-3 py-1 text-xs font-medium text-muted-foreground">
{filteredSchedules.length} schedules
</span>
</div>
<div className="mb-4 grid gap-3 md:grid-cols-[1fr,220px]">
<input
className={inputClassName}
placeholder="Search by schedule, route, locomotive, or status"
value={scheduleSearch}
onChange={(event) => setScheduleSearch(event.target.value)}
/>
<Select value={scheduleStatusFilter} onValueChange={setScheduleStatusFilter}>
<SelectTrigger>
<SelectValue placeholder="All statuses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">All statuses</SelectItem>
<SelectItem value="DRAFT">DRAFT</SelectItem>
<SelectItem value="SCHEDULED">SCHEDULED</SelectItem>
<SelectItem value="DISPATCHED">DISPATCHED</SelectItem>
<SelectItem value="ARRIVED">ARRIVED</SelectItem>
<SelectItem value="CANCELLED">CANCELLED</SelectItem>
</SelectContent>
</Select>
</div>
<div className="overflow-x-auto rounded-2xl border border-border">
<table className="min-w-full divide-y divide-border text-sm">
<thead className="bg-muted/40 text-left text-xs uppercase tracking-wide text-muted-foreground">
<tr>
<th className="px-3 py-3">Schedule</th>
<th className="px-3 py-3">Departure</th>
<th className="px-3 py-3">Route</th>
<th className="px-3 py-3">Locomotive</th>
<th className="px-3 py-3">Bookings</th>
<th className="px-3 py-3">Wagons</th>
<th className="px-3 py-3">Weight</th>
<th className="px-3 py-3">Length</th>
<th className="px-3 py-3">Status</th>
<th className="px-3 py-3">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border bg-card">
{filteredSchedules.map((schedule) => (
<tr key={schedule.id} className="hover:bg-muted/20">
<td className="px-3 py-3 font-mono text-xs">{schedule.id}</td>
<td className="px-3 py-3">{formatDate(schedule.scheduleDate)}</td>
<td className="px-3 py-3">
{schedule.origin} to {schedule.destination}
</td>
<td className="px-3 py-3">{schedule.locomotive?.code ?? '-'}</td>
<td className="px-3 py-3">{schedule.bookingsCount}</td>
<td className="px-3 py-3">{schedule.wagonCount}</td>
<td className="px-3 py-3">{schedule.totalWeightTons} T</td>
<td className="px-3 py-3">{schedule.totalLengthMeters} m</td>
<td className="px-3 py-3">{schedule.status}</td>
<td className="px-3 py-3">
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => setDetailId(schedule.id)}>
View
</Button>
{schedule.status !== 'CANCELLED' ? (
<Button
variant="outline"
size="sm"
onClick={() => cancelMutation.mutate(schedule.id)}
>
Cancel
</Button>
) : null}
</div>
</td>
</tr>
))}
{!schedulesQuery.isLoading && filteredSchedules.length === 0 ? (
<tr>
<td className="px-3 py-8 text-center text-sm text-muted-foreground" colSpan={10}>
No train schedules matched the current filters.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</section>
</div>
</div>
</section>
<Dialog open={Boolean(detailId)} onOpenChange={(open) => (!open ? setDetailId(null) : null)}>
<DialogContent className="max-h-[90vh] max-w-5xl overflow-y-auto">
<DialogHeader>
<DialogTitle>Train schedule detail</DialogTitle>
<DialogDescription>
Inspect the selected schedule, locomotive, wagons, and booking allocations.
</DialogDescription>
</DialogHeader>
{detail ? (
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-xl border border-border p-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Schedule</p>
<p className="mt-2 break-all font-mono text-xs">{detail.id}</p>
</div>
<div className="rounded-xl border border-border p-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Departure</p>
<p className="mt-2 text-sm font-medium">{formatDate(detail.scheduledDepartureDate)}</p>
</div>
<div className="rounded-xl border border-border p-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Route</p>
<p className="mt-2 text-sm font-medium">
{detail.originStation?.label ?? detail.originStation?.code ?? '-'} to{' '}
{detail.destinationStation?.label ?? detail.destinationStation?.code ?? '-'}
</p>
</div>
<div className="rounded-xl border border-border p-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Status</p>
<p className="mt-2 text-sm font-medium">{detail.status}</p>
</div>
</div>
<div className="rounded-2xl border border-border p-4">
<h3 className="text-lg font-semibold">Locomotive</h3>
<p className="mt-2 text-sm text-muted-foreground">
{detail.trainSet?.locomotive
? `${detail.trainSet.locomotive.code} (${detail.trainSet.locomotive.maxPullWeightTons}T pull capacity)`
: 'No locomotive attached'}
</p>
</div>
<div className="rounded-2xl border border-border p-4">
<h3 className="text-lg font-semibold">Wagons and allocations</h3>
<div className="mt-4 space-y-4">
{(detail.trainSet?.wagons ?? []).map((wagon) => (
<div key={wagon.id} className="rounded-xl border border-border p-4">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="font-semibold">
Wagon {wagon.sequenceNo} - {wagon.wagonType?.code ?? 'NW5'}
</p>
<p className="text-sm text-muted-foreground">
{wagon.assignedWeightTons}T assigned / {wagon.capacityTons}T capacity / {wagon.lengthMeters}m
</p>
</div>
</div>
<div className="mt-3 overflow-x-auto rounded-xl border border-border">
<table className="min-w-full divide-y divide-border text-sm">
<thead className="bg-muted/40 text-left text-xs uppercase tracking-wide text-muted-foreground">
<tr>
<th className="px-3 py-2">Booking</th>
<th className="px-3 py-2">Allocated weight</th>
</tr>
</thead>
<tbody className="divide-y divide-border bg-card">
{wagon.allocations.map((allocation) => (
<tr key={allocation.id}>
<td className="px-3 py-2">{allocation.bookingReference ?? allocation.bookingId}</td>
<td className="px-3 py-2">{allocation.allocatedWeightTons} T</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
))}
</div>
</div>
<div className="rounded-2xl border border-border p-4">
<h3 className="text-lg font-semibold">Bookings in schedule</h3>
<div className="mt-4 overflow-x-auto rounded-xl border border-border">
<table className="min-w-full divide-y divide-border text-sm">
<thead className="bg-muted/40 text-left text-xs uppercase tracking-wide text-muted-foreground">
<tr>
<th className="px-3 py-2">Reference</th>
<th className="px-3 py-2">Customer</th>
<th className="px-3 py-2">Weight</th>
<th className="px-3 py-2">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-border bg-card">
{detail.bookings.map((booking) => (
<tr key={booking.id}>
<td className="px-3 py-2">{booking.reference ?? booking.id}</td>
<td className="px-3 py-2">{booking.customer ?? '-'}</td>
<td className="px-3 py-2">{booking.weightTons} T</td>
<td className="px-3 py-2">{booking.status ?? '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">Loading schedule detail...</p>
)}
</DialogContent>
</Dialog>
</div>
);
};

View File

@@ -0,0 +1,87 @@
import { api as client } from '../auth/http';
import { unwrap } from '@/utils/endpoint';
import { URL_CONSTANTS } from '@/constants/URLS';
import type {
CreateTrainSchedulePayload,
EligibleContainerBookingsResponse,
LocomotiveRecord,
TrainScheduleDetail,
TrainScheduleFilters,
TrainScheduleListItem,
TrainSchedulePreviewPayload,
TrainSchedulePreviewResponse,
YardOption,
} from '@/types/trainScheduling';
interface BookingReferenceDataResponse {
yard?: YardOption[];
}
export const trainSchedulingService = {
getEligibleBookings: async (
filters?: TrainScheduleFilters,
): Promise<EligibleContainerBookingsResponse> => {
const response = await client.get<EligibleContainerBookingsResponse>(
URL_CONSTANTS.TRAIN_SCHEDULING.ELIGIBLE_BOOKINGS,
{ params: filters },
);
return unwrap(response.data);
},
preview: async (
payload: TrainSchedulePreviewPayload,
): Promise<TrainSchedulePreviewResponse> => {
const response = await client.post<TrainSchedulePreviewResponse>(
URL_CONSTANTS.TRAIN_SCHEDULING.PREVIEW,
payload,
);
return unwrap(response.data);
},
createSchedule: async (
payload: CreateTrainSchedulePayload,
): Promise<TrainScheduleDetail> => {
const response = await client.post<TrainScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.SCHEDULES,
payload,
);
return unwrap(response.data);
},
listSchedules: async (): Promise<TrainScheduleListItem[]> => {
const response = await client.get<TrainScheduleListItem[]>(
URL_CONSTANTS.TRAIN_SCHEDULING.SCHEDULES,
);
return unwrap(response.data);
},
getScheduleById: async (id: string): Promise<TrainScheduleDetail> => {
const response = await client.get<TrainScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.SCHEDULE_BY_ID(id),
);
return unwrap(response.data);
},
cancelSchedule: async (id: string): Promise<TrainScheduleDetail> => {
const response = await client.post<TrainScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.CANCEL_SCHEDULE(id),
{},
);
return unwrap(response.data);
},
getAvailableLocomotives: async (): Promise<LocomotiveRecord[]> => {
const response = await client.get<LocomotiveRecord[]>(URL_CONSTANTS.LOCOMOTIVES.BASE, {
params: { status: 'AVAILABLE' },
});
return unwrap(response.data);
},
getStations: async (): Promise<YardOption[]> => {
const response = await client.get<BookingReferenceDataResponse>(
URL_CONSTANTS.BOOKINGS.REFERENCE_DATA,
);
const data = unwrap(response.data);
return data.yard ?? [];
},
};

View File

@@ -0,0 +1,154 @@
export interface YardOption {
id: string;
name: string;
code: string;
country?: string;
}
export interface EligibleContainerBooking {
id: string;
reference: string;
customer: string;
containerType: string;
quantity: number;
weightTons: number;
origin: string;
destination: string;
preferredDepartureDate: string;
status: string;
}
export interface EligibleContainerBookingsResponse {
count: number;
items: EligibleContainerBooking[];
}
export interface WagonPlanAllocation {
bookingId: string;
bookingReference: string;
allocatedWeightTons: number;
}
export interface WagonPlanRow {
sequenceNo: number;
capacityTons: number;
lengthMeters: number;
assignedWeightTons: number;
allocations: WagonPlanAllocation[];
}
export interface TrainSchedulePreviewResponse {
valid: boolean;
violations: string[];
summary: {
totalBookings: number;
totalWeightTons: number;
wagonType: string;
wagonsNeeded: number;
totalLengthMeters: number;
};
bookingIds: string[];
wagonPlan: WagonPlanRow[];
}
export interface LocomotiveRecord {
id: string;
code: string;
name?: string | null;
maxPullWeightTons: number;
status: 'AVAILABLE' | 'ASSIGNED' | 'MAINTENANCE' | 'INACTIVE';
availableFrom?: string | null;
}
export interface TrainScheduleListItem {
id: string;
scheduleDate: string;
origin: string | null;
destination: string | null;
locomotive:
| {
id: string;
code: string;
name?: string | null;
}
| null;
wagonCount: number;
totalWeightTons: number;
totalLengthMeters: number;
bookingsCount: number;
status: string;
}
export interface TrainScheduleDetail {
id: string;
status: string;
scheduledDepartureDate: string;
scheduledArrivalDate?: string | null;
originStation?: {
id: string;
label?: string;
code?: string;
} | null;
destinationStation?: {
id: string;
label?: string;
code?: string;
} | null;
trainSet?: {
id: string;
status: string;
wagonCount: number;
totalWeightTons: number;
totalLengthMeters: number;
locomotive?: {
id: string;
code: string;
name?: string | null;
status: string;
maxPullWeightTons: number;
} | null;
wagons: Array<{
id: string;
sequenceNo: number;
capacityTons: number;
lengthMeters: number;
assignedWeightTons: number;
wagonType?: {
id: string;
code: string;
name: string;
} | null;
allocations: Array<{
id: string;
bookingId: string;
bookingReference: string | null;
allocatedWeightTons: number;
}>;
}>;
} | null;
bookings: Array<{
id: string;
reference: string | null;
customer: string | null;
weightTons: number;
status: string | null;
}>;
}
export interface TrainScheduleFilters {
originStationId?: string;
destinationStationId?: string;
scheduleDate?: string;
status?: string;
}
export interface TrainSchedulePreviewPayload {
bookingIds: string[];
scheduleDate: string;
originStationId: string;
destinationStationId: string;
}
export interface CreateTrainSchedulePayload extends TrainSchedulePreviewPayload {
locomotiveId: string;
}

View File

@@ -50,9 +50,6 @@ export function endpoint<TInput, TResponse>(
if (queryKeyBuilder && input !== undefined) {
return queryKeyBuilder(input as TInput);
}
if (queryKeyBuilder && input === undefined) {
return queryKeyBuilder(undefined as TInput);
}
return input === undefined
? [service, action]
: [service, action, input];
@@ -124,4 +121,4 @@ export function unwrap<T>(response: { data: T } | T): T {
}
return response as T;
}
}

View File

@@ -25,6 +25,7 @@
"react": "19.2.6",
"react-dom": "19.2.6",
"react-hook-form": "^7.76.0",
"react-hot-toast": "^2.6.0",
"react-router-dom": "^6.27.0",
"recharts": "^3.8.1",
"tailwind-merge": "^3.6.0",

View File

@@ -14,11 +14,13 @@ import {
Home,
Loader2,
User,
Settings,
} from "lucide-react";
import useAuth from "./hooks/useAuth";
import ProfilePage from "./pages/ProfilePage";
import SettingsPage from "./pages/SettingsPage";
import MyPortalPage from "./pages/MyPortalPage";
import EDRFreightLandingPage from "./pages/EDRFreightLandingPage";
import SignupPage from "./pages/accounts/SignupPage";
@@ -40,12 +42,14 @@ const sidebarItems: SidebarItem[] = [
{ label: "Tracking", href: "/tracking", icon: <MapPin /> },
{ label: "Billing", href: "/billing", icon: <Receipt /> },
{ label: "Profile", href: "/profile", icon: <User /> },
{ label: "Settings", href: "/settings", icon: <Settings /> },
];
const App = () => {
const navigate = useNavigate();
const location = useLocation();
const { user, isPending, logout, customer, customerQuery } = useAuth();
useEffect(() => {
if (isPending) return;
const isInProtectedRoutes = sidebarItems.find((item) =>
@@ -107,6 +111,7 @@ const App = () => {
<Route path="/tracking" element={<TrackingPage />} />
<Route path="/billing" element={<BillingPage />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/settings" element={<SettingsPage />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>

View File

@@ -82,6 +82,8 @@ export const URL_CONSTANTS = {
COMPANIES_API: {
GET_INFO: "/api/companies/getInfo",
CREATE: "/api/companies/create",
PROFILE: "/api/companies/profile",
DOCUMENTS: (id: string) => `/api/companies/${id}/documents`,
},
BOOKINGS: {

View File

@@ -29,7 +29,6 @@ const useAuth = () => {
const authQuery = useQuery(
api.auth.getMyInfo.queryOptions({
enabled: !!getCookie("auth-token"),
retry: false,
staleTime: 10 * 60 * 1000,
}),

View File

@@ -1,4 +1,4 @@
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { Link } from "react-router-dom";
import {
ArrowRight,
@@ -14,6 +14,8 @@ import {
Plus,
Receipt,
Truck,
UploadCloud,
X,
} from "lucide-react";
import {
@@ -48,6 +50,7 @@ export default function MyPortalPage() {
const outstandingInvoices = myInvoices.filter(
(inv) => inv.status === "Sent" || inv.status === "Overdue",
);
const [dismissed, setDismissed] = useState(false);
const totalOutstanding = outstandingInvoices
.filter((inv) => inv.currency === "USD")
.reduce((sum, inv) => sum + inv.amount, 0);
@@ -61,6 +64,34 @@ export default function MyPortalPage() {
return (
<div className="min-h-screen bg-background p-6">
<div className="mx-auto max-w-7xl space-y-6">
{/* Documents banner */}
{!me.documentsComplete && !dismissed && (
<div className="flex items-start gap-3 rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">
<UploadCloud className="mt-0.5 h-5 w-5 shrink-0 text-amber-500" />
<div className="flex-1">
<p className="font-semibold">Upload your documents</p>
<p className="mt-0.5 text-amber-700">
To enable all account features, please upload your Business
License, TIN Certificate, and National ID / Passport.
</p>
<Link
to="/settings?tab=documents"
className="mt-2 inline-flex items-center gap-1 font-medium text-amber-900 underline underline-offset-2 transition hover:text-amber-700"
>
Upload now
</Link>
</div>
<button
type="button"
onClick={() => setDismissed(true)}
className="shrink-0 rounded-lg p-1 text-amber-400 transition hover:bg-amber-100 hover:text-amber-600"
aria-label="Dismiss"
>
<X className="h-4 w-4" />
</button>
</div>
)}
{/* Welcome banner */}
<div className="overflow-hidden rounded-3xl bg-gradient-to-r from-[#059669] to-[#10B981] p-6 text-white shadow-sm">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">

View File

@@ -1,135 +1,46 @@
import { useMemo } from "react";
import {
User,
Building2,
Phone,
Mail,
MapPin,
ShieldCheck,
Briefcase,
UserCheck,
Building,
Globe,
Fingerprint,
FileCheck,
Settings2,
ExternalLink,
} from "lucide-react";
import useAuth from "@/hooks/useAuth";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardAction,
Badge,
Separator,
SmartFileInput,
Button,
} from "@edr/ui-common";
import type { IFileUploadSetting } from "@edr/types/freight";
import { cn } from "@/lib/utils";
import { User, Building2, Phone, Mail, MapPin, ShieldCheck, Briefcase, UserCheck, Fingerprint, FileCheck, Globe, Building } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Badge, Separator } from "@edr/ui-common";
function InfoItem({ icon, label, value }: { icon?: React.ReactNode; label: string; value?: string | null }) {
return (
<div className="flex items-start gap-3">
{icon && <div className="mt-1 text-muted-foreground [&_svg]:size-4">{icon}</div>}
<div className="flex flex-col gap-0.5">
<p className="text-[10px] font-bold uppercase tracking-tight text-muted-foreground">{label}</p>
<p className="text-sm font-bold text-foreground">{value || "—"}</p>
</div>
</div>
);
}
export default function ProfilePage() {
const { user, customer, isPending } = useAuth();
const documentSettings = useMemo<IFileUploadSetting>(() => ({
id: "profile-docs",
code: "customer_documents",
label: "Customer Documents",
entity: "customer",
createdAt: new Date(),
updatedAt: new Date(),
fields: [
{
id: "doc-tin",
settingId: "profile-docs",
fileKey: "tin_certificate",
fileLabel: "TIN Certificate",
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: [".pdf", ".jpg", ".jpeg", ".png"],
maxSizeMb: 5,
order: 1,
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: "doc-license",
settingId: "profile-docs",
fileKey: "business_license",
fileLabel: "Business/Investment License",
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: [".pdf", ".jpg", ".jpeg", ".png"],
maxSizeMb: 5,
order: 2,
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: "doc-reg",
settingId: "profile-docs",
fileKey: "registration_certificate",
fileLabel: "Business Registration Certificate",
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: [".pdf", ".jpg", ".jpeg", ".png"],
maxSizeMb: 5,
order: 3,
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: "doc-id",
settingId: "profile-docs",
fileKey: "national_id",
fileLabel: "National ID",
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: [".pdf", ".jpg", ".jpeg", ".png"],
maxSizeMb: 5,
order: 4,
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: "doc-poa",
settingId: "profile-docs",
fileKey: "power_of_attorney",
fileLabel: "Power of Attorney",
isRequired: false,
isMultiple: false,
maxFiles: 1,
allowedExtensions: [".pdf", ".jpg", ".jpeg", ".png"],
maxSizeMb: 5,
order: 5,
createdAt: new Date(),
updatedAt: new Date(),
},
],
}), []);
const { data: profile, isPending } = useQuery(
api.companies.getProfile.queryOptions(),
);
if (isPending) {
return (
<div className="flex h-full items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-primary"></div>
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-primary" />
</div>
);
}
const displayName = user?.name?.en || user?.username || user?.email || "User";
if (!profile) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground">No company profile found.</p>
</div>
);
}
return (
<div className="container mx-auto max-w-7xl px-4 py-8">
<div className="flex flex-col gap-8">
{/* Header Section */}
<div className="flex flex-col gap-6 md:flex-row md:items-center md:justify-between">
<div className="px-4 py-8">
<div className="mx-auto max-w-7xl">
<div className="flex flex-col gap-8">
{/* Header */}
<div className="flex items-center gap-6">
<div className="flex size-24 items-center justify-center rounded-2xl bg-primary/10 text-primary shadow-inner">
<User className="size-12" />
@@ -137,7 +48,7 @@ export default function ProfilePage() {
<div className="flex flex-col gap-1">
<div className="flex items-center gap-3">
<h1 className="text-3xl font-black tracking-tight text-foreground">
{displayName}
{profile.companyName}
</h1>
<Badge variant="secondary" className="font-bold uppercase tracking-wider">
Verified
@@ -145,182 +56,129 @@ export default function ProfilePage() {
</div>
<p className="flex items-center gap-2 font-medium text-muted-foreground">
<Building className="size-4" />
{customer?.companyName || "No Company Linked"}
{profile.companyName}
</p>
</div>
</div>
<div className="flex items-center gap-3">
<Button variant="outline">
<Settings2 data-icon="inline-start" />
Account Settings
</Button>
</div>
</div>
<Separator />
<Separator />
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
{/* Left Column - Personal & Company Info */}
<div className="flex flex-col gap-8 lg:col-span-2">
<div className="grid grid-cols-1 gap-8 md:grid-cols-2">
{/* Personal Details Card */}
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
{/* Left Column */}
<div className="flex flex-col gap-8 lg:col-span-2">
<div className="grid grid-cols-1 gap-8 md:grid-cols-2">
{/* Company Details */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building2 className="size-5 text-primary" />
Company Details
</CardTitle>
<CardDescription>Business registration information</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<InfoItem icon={<Globe />} label="Location" value={profile.companyLocation} />
<InfoItem icon={<MapPin />} label="Address" value={profile.companyAddress} />
<InfoItem icon={<FileCheck />} label="TIN Number" value={profile.tinNumber} />
<InfoItem icon={<ShieldCheck />} label="FAN Number" value={profile.fanNumber} />
<InfoItem icon={<Mail />} label="Email" value={profile.companyEmail} />
<InfoItem icon={<Phone />} label="Phone" value={profile.companyPhone} />
</CardContent>
</Card>
{/* Personal Details (from ExternalProfile) */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Fingerprint className="size-5 text-primary" />
Profile Details
</CardTitle>
<CardDescription>Your linked user profile</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<InfoItem icon={<User />} label="Profile" value="Primary Contact" />
</CardContent>
</Card>
</div>
{/* Personnel Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Fingerprint className="size-5 text-primary" />
Personal Details
<Briefcase className="size-5 text-primary" />
Key Personnel
</CardTitle>
<CardDescription>Your account contact information</CardDescription>
<CardAction>
<Button variant="ghost" size="icon">
<ExternalLink />
</Button>
</CardAction>
<CardDescription>Management and contact persons</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<InfoItem icon={<Mail />} label="Email Address" value={user?.email} />
<InfoItem icon={<Phone />} label="Phone Number" value={user?.phoneNumber} />
<InfoItem icon={<UserCheck />} label="Username" value={user?.username} />
<CardContent className="grid grid-cols-1 gap-8 md:grid-cols-2">
<div className="flex flex-col gap-4">
<h3 className="border-l-4 border-primary pl-3 text-sm font-bold uppercase tracking-wide text-foreground">
Contact Person
</h3>
<div className="flex flex-col gap-3 pl-4">
<InfoItem label="Name" value={profile.contactPersonName} />
<InfoItem label="Phone" value={profile.contactPersonPhone} />
</div>
</div>
<div className="flex flex-col gap-4">
<h3 className="border-l-4 border-accent pl-3 text-sm font-bold uppercase tracking-wide text-foreground">
General Manager
</h3>
<div className="flex flex-col gap-3 pl-4">
<InfoItem label="Name" value={profile.generalManagerName} />
<InfoItem label="Email" value={profile.generalManagerEmail} />
<InfoItem label="Phone" value={profile.generalManagerPhone} />
</div>
</div>
</CardContent>
</Card>
{/* Company Details Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building2 className="size-5 text-primary" />
Company Details
</CardTitle>
<CardDescription>Business registration information</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<InfoItem icon={<Globe />} label="Location" value={customer?.companyLocation} />
<InfoItem icon={<MapPin />} label="Address" value={customer?.companyAddress} />
<InfoItem icon={<FileCheck />} label="TIN Number" value={customer?.tinNumber} />
<InfoItem icon={<ShieldCheck />} label="FAN Number" value={customer?.fanNumber} />
{/* Power of Attorney */}
{profile.poaName && (
<Card className="border-dashed">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<UserCheck className="size-5 text-accent" />
Power of Attorney
</CardTitle>
<CardDescription>Authorized representative details</CardDescription>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
<InfoItem label="PoA Name" value={profile.poaName} />
<InfoItem label="PoA Email" value={profile.poaEmail} />
<InfoItem label="PoA Phone" value={profile.poaPhone} />
<InfoItem label="PoA Location" value={profile.poaLocation} />
</CardContent>
</Card>
)}
</div>
{/* Right Column */}
<div className="flex flex-col gap-8">
<Card className="relative overflow-hidden border-none bg-foreground text-background shadow-xl">
<div className="absolute right-0 top-0 p-4 opacity-10">
<ShieldCheck className="size-32" />
</div>
<CardContent className="relative z-10 flex flex-col gap-4 px-6 py-8">
<h3 className="text-xl font-black">Secure Account</h3>
<p className="text-sm leading-relaxed text-muted-foreground/80">
Your information is protected by enterprise-grade security.
Contact support for verified information updates.
</p>
<div className="pt-2">
<a
href="/settings"
className="inline-flex h-9 items-center justify-center rounded-md bg-background px-4 text-sm font-medium text-foreground hover:bg-background/90"
>
Edit Settings
</a>
</div>
</CardContent>
</Card>
</div>
{/* Personnel Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Briefcase className="size-5 text-primary" />
Key Personnel
</CardTitle>
<CardDescription>Management and contact persons</CardDescription>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-8 md:grid-cols-2">
<div className="flex flex-col gap-4">
<h3 className="border-l-4 border-primary pl-3 text-sm font-bold uppercase tracking-wide text-foreground">
Contact Person
</h3>
<div className="flex flex-col gap-3 pl-4">
<InfoItem label="Name" value={customer?.contactPersonName} />
<InfoItem label="Phone" value={customer?.contactPersonPhone} />
</div>
</div>
<div className="flex flex-col gap-4">
<h3 className="border-l-4 border-accent pl-3 text-sm font-bold uppercase tracking-wide text-foreground">
General Manager
</h3>
<div className="flex flex-col gap-3 pl-4">
<InfoItem label="Name" value={customer?.generalManagerName} />
<InfoItem label="Email" value={customer?.generalManagerEmail} />
<InfoItem label="Phone" value={customer?.generalManagerPhone} />
</div>
</div>
</CardContent>
</Card>
{/* Power of Attorney Section (Conditional) */}
{customer?.poaName && (
<Card className="border-dashed">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<UserCheck className="size-5 text-accent" />
Power of Attorney
</CardTitle>
<CardDescription>Authorized representative details</CardDescription>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
<InfoItem label="PoA Name" value={customer.poaName} />
<InfoItem label="PoA Email" value={customer.poaEmail} />
<InfoItem label="PoA Phone" value={customer.poaPhone} />
<InfoItem label="PoA Location" value={customer.poaLocation} />
</CardContent>
</Card>
)}
</div>
{/* Right Column - Documents */}
<div className="flex flex-col gap-8">
<Card className="border-primary/20 bg-primary/[0.02] shadow-md">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileCheck className="size-6 text-primary" />
Documents
</CardTitle>
<CardDescription>Manage required business documents</CardDescription>
</CardHeader>
<CardContent className="px-6 pb-6 pt-0">
<SmartFileInput
file={documentSettings}
variant="minimal"
className="flex flex-col gap-4"
/>
</CardContent>
</Card>
<Card className="relative overflow-hidden border-none bg-foreground text-background shadow-xl">
<div className="absolute right-0 top-0 p-4 opacity-10">
<ShieldCheck className="size-32" />
</div>
<CardContent className="relative z-10 flex flex-col gap-4 px-6 py-8">
<h3 className="text-xl font-black">Secure Account</h3>
<p className="text-sm leading-relaxed text-muted-foreground/80">
Your information is protected by enterprise-grade security.
Contact support for verified information updates.
</p>
<div className="pt-2">
<Button variant="secondary" size="sm">
Contact Support
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</div>
);
}
function InfoItem({
icon,
label,
value,
}: {
icon?: React.ReactNode;
label: string;
value?: string | null;
}) {
return (
<div className="flex items-start gap-3">
{icon && (
<div className="mt-1 text-muted-foreground [&_svg]:size-4">
{icon}
</div>
)}
<div className="flex flex-col gap-0.5">
<p className="text-[10px] font-bold uppercase tracking-tight text-muted-foreground">
{label}
</p>
<p className="text-sm font-bold text-foreground">
{value || "—"}
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,617 @@
import { useState, useMemo } from "react";
import { useSearchParams } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Building2,
User,
Briefcase,
UserCheck,
FileCheck,
Loader2,
Save,
UploadCloud,
CheckCircle2,
XCircle,
} from "lucide-react";
import { api } from "@/services/api";
import { companiesService } from "@/services/companies.service";
import PhoneInput from "@/components/auth/PhoneInput";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
Button,
Input,
Field,
FieldLabel,
FieldError,
FieldGroup,
SmartFileInput,
Badge,
} from "@edr/ui-common";
import { cn } from "@/lib/utils";
type SettingsTab =
| "company"
| "contact"
| "gm"
| "poa"
| "documents";
const settingsSchema = z.object({
companyName: z.string().min(1, "Company name is required"),
companyEmail: z.string().email("Invalid email address"),
companyPhone: z.string().min(1, "Company phone is required"),
companyPhoneCountryCode: z.string().min(1, "Country code is required"),
companyLocation: z.string().min(1, "Location is required"),
companyAddress: z.string().min(1, "Address is required"),
tinNumber: z.string().length(10, "TIN must be exactly 10 digits"),
fanNumber: z.string().length(16, "FAN must be exactly 16 digits"),
contactPersonName: z.string().min(1, "Contact person name is required"),
contactPersonPhone: z.string().min(1, "Contact person phone is required"),
contactPersonPhoneCountryCode: z.string().min(1, "Country code is required"),
generalManagerName: z.string().min(1, "GM name is required"),
generalManagerEmail: z.string().email("Invalid GM email"),
generalManagerPhone: z.string().min(1, "GM phone is required"),
generalManagerPhoneCountryCode: z.string().min(1, "Country code is required"),
poaName: z.string().optional(),
poaEmail: z.string().optional(),
poaPhone: z.string().optional(),
poaPhoneCountryCode: z.string().optional(),
poaLocation: z.string().optional(),
poaAddress: z.string().optional(),
});
type FormData = z.infer<typeof settingsSchema>;
const TABS: { id: SettingsTab; label: string; icon: React.ReactNode }[] = [
{ id: "company", label: "Company Profile", icon: <Building2 className="size-4" /> },
{ id: "contact", label: "Contact Person", icon: <User className="size-4" /> },
{ id: "gm", label: "General Manager", icon: <Briefcase className="size-4" /> },
{ id: "poa", label: "Power of Attorney", icon: <UserCheck className="size-4" /> },
{ id: "documents", label: "Documents", icon: <FileCheck className="size-4" /> },
];
function splitPhone(fullPhone?: string | null): { code: string; number: string } {
if (!fullPhone) return { code: "+251", number: "" };
const match = fullPhone.match(/^(\+\d{1,3})(.*)$/);
if (match) return { code: match[1], number: match[2] };
return { code: "+251", number: fullPhone };
}
export default function SettingsPage() {
const queryClient = useQueryClient();
const [searchParams, setSearchParams] = useSearchParams();
const tab = (searchParams.get("tab") as SettingsTab) || "company";
const setTab = (t: SettingsTab) => {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set("tab", t);
return next;
}, { replace: true });
};
const [documentFiles, setDocumentFiles] = useState<
Record<string, File | File[] | null>
>({});
const profileQuery = useQuery(
api.companies.getProfile.queryOptions(),
);
const docSettingQuery = useQuery(
api.fileUploadSettings.getByCode.queryOptions({
input: { code: "customer_documents" },
enabled: tab === "documents",
}),
);
const profile = profileQuery.data;
const defaultValues = useMemo((): FormData => {
if (!profile) {
return {
companyName: "",
companyEmail: "",
companyPhone: "",
companyPhoneCountryCode: "+251",
companyLocation: "",
companyAddress: "",
tinNumber: "",
fanNumber: "",
contactPersonName: "",
contactPersonPhone: "",
contactPersonPhoneCountryCode: "+251",
generalManagerName: "",
generalManagerEmail: "",
generalManagerPhone: "",
generalManagerPhoneCountryCode: "+251",
poaName: "",
poaEmail: "",
poaPhone: "",
poaPhoneCountryCode: "+251",
poaLocation: "",
poaAddress: "",
};
}
const contactPhone = splitPhone(profile.contactPersonPhone);
const gmPhone = splitPhone(profile.generalManagerPhone);
const poaPhone = splitPhone(profile.poaPhone);
return {
companyName: profile.companyName,
companyEmail: profile.companyEmail ?? "",
companyPhone: profile.companyPhone ?? "",
companyPhoneCountryCode: splitPhone(profile.companyPhone).code,
companyLocation: profile.companyLocation,
companyAddress: profile.companyAddress ?? "",
tinNumber: profile.tinNumber,
fanNumber: profile.fanNumber ?? "",
contactPersonName: profile.contactPersonName ?? "",
contactPersonPhone: contactPhone.number,
contactPersonPhoneCountryCode: contactPhone.code,
generalManagerName: profile.generalManagerName ?? "",
generalManagerEmail: profile.generalManagerEmail ?? "",
generalManagerPhone: gmPhone.number,
generalManagerPhoneCountryCode: gmPhone.code,
poaName: profile.poaName ?? "",
poaEmail: profile.poaEmail ?? "",
poaPhone: poaPhone.number,
poaPhoneCountryCode: poaPhone.code,
poaLocation: profile.poaLocation ?? "",
poaAddress: profile.poaAddress ?? "",
};
}, [profile]);
const {
register,
handleSubmit,
reset,
formState: { errors, isDirty },
} = useForm<FormData>({
resolver: zodResolver(settingsSchema),
values: defaultValues,
});
const updateMutation = useMutation({
mutationFn: (data: FormData) =>
api.companies.updateProfile.call({
companyName: data.companyName,
companyEmail: data.companyEmail,
companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`,
companyLocation: data.companyLocation,
companyAddress: data.companyAddress,
tin: data.tinNumber,
fanNumber: data.fanNumber,
contactPersonName: data.contactPersonName,
contactPersonPhone: `${data.contactPersonPhoneCountryCode}${data.contactPersonPhone}`,
generalManagerName: data.generalManagerName,
generalManagerEmail: data.generalManagerEmail,
generalManagerPhone: `${data.generalManagerPhoneCountryCode}${data.generalManagerPhone}`,
poaName: data.poaName || undefined,
poaPhone:
data.poaPhone && data.poaPhoneCountryCode
? `${data.poaPhoneCountryCode}${data.poaPhone}`
: undefined,
poaEmail: data.poaEmail || undefined,
poaLocation: data.poaLocation || undefined,
poaAddress: data.poaAddress || undefined,
}),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: api.companies.getProfile.queryKey(),
});
},
});
const docUploadMutation = useMutation({
mutationFn: (files: Record<string, File | File[] | null>) =>
companiesService.uploadDocuments(profile!.companyId, files),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: api.companies.getProfile.queryKey(),
});
},
});
const isPending = profileQuery.isPending || updateMutation.isPending || docUploadMutation.isPending;
if (profileQuery.isPending) {
return (
<div className="flex h-full items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-primary" />
</div>
);
}
if (!profile) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground">No company profile found.</p>
</div>
);
}
const onSubmit = (data: FormData) => {
updateMutation.mutate(data);
};
return (
<div className="px-4 py-8">
<div className="mb-8 flex items-center justify-between">
<div>
<h1 className="text-2xl font-black tracking-tight text-foreground">
Account Settings
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Manage your company profile, personnel, and documents
</p>
</div>
<Badge variant="secondary" className="font-bold uppercase tracking-wider">
Verified
</Badge>
</div>
{/* Tab Bar */}
<div className="mb-6 flex flex-wrap gap-1 border-b border-border">
{TABS.map((t) => (
<button
key={t.id}
type="button"
onClick={() => setTab(t.id)}
className={cn(
"flex items-center gap-2 border-b-2 px-4 py-3 text-sm font-semibold transition-colors",
tab === t.id
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground",
)}
>
{t.icon}
{t.label}
</button>
))}
</div>
<form onSubmit={handleSubmit(onSubmit)}>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
{tab === "company" && <><Building2 className="size-5 text-primary" /> Company Profile</>}
{tab === "contact" && <><User className="size-5 text-primary" /> Contact Person</>}
{tab === "gm" && <><Briefcase className="size-5 text-primary" /> General Manager</>}
{tab === "poa" && <><UserCheck className="size-5 text-accent" /> Power of Attorney</>}
{tab === "documents" && <><FileCheck className="size-5 text-primary" /> Documents</>}
</CardTitle>
<CardDescription>
{tab === "company" && "Edit your company registration details"}
{tab === "contact" && "Manage the primary contact person for your account"}
{tab === "gm" && "Manage the general manager information"}
{tab === "poa" && "Power of Attorney details are optional"}
{tab === "documents" && "Upload and manage required business documents"}
</CardDescription>
</CardHeader>
<CardContent>
<FieldGroup className="gap-4">
{/* Company Profile Tab */}
{tab === "company" && (
<>
<Field data-invalid={Boolean(errors.companyName)}>
<FieldLabel>Company Name</FieldLabel>
<Input
placeholder="Global Logistics Ltd"
aria-invalid={Boolean(errors.companyName)}
{...register("companyName")}
/>
<FieldError errors={[errors.companyName]} />
</Field>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={Boolean(errors.companyEmail)}>
<FieldLabel>Company Email</FieldLabel>
<Input
type="email"
placeholder="ops@company.com"
aria-invalid={Boolean(errors.companyEmail)}
{...register("companyEmail")}
/>
<FieldError errors={[errors.companyEmail]} />
</Field>
<PhoneInput
countryCode={{ ...register("companyPhoneCountryCode") }}
phone={{
...register("companyPhone"),
placeholder: "912345678",
}}
countryCodeError={errors.companyPhoneCountryCode}
phoneError={errors.companyPhone}
label="Company Phone"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={Boolean(errors.companyLocation)}>
<FieldLabel>Location</FieldLabel>
<Input
placeholder="Addis Ababa, Ethiopia"
aria-invalid={Boolean(errors.companyLocation)}
{...register("companyLocation")}
/>
<FieldError errors={[errors.companyLocation]} />
</Field>
<Field data-invalid={Boolean(errors.companyAddress)}>
<FieldLabel>Address</FieldLabel>
<Input
placeholder="Bole Subcity, Woreda 03"
aria-invalid={Boolean(errors.companyAddress)}
{...register("companyAddress")}
/>
<FieldError errors={[errors.companyAddress]} />
</Field>
</div>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={Boolean(errors.tinNumber)}>
<FieldLabel>TIN Number (10 digits)</FieldLabel>
<Input
placeholder="1234567890"
maxLength={10}
aria-invalid={Boolean(errors.tinNumber)}
{...register("tinNumber")}
/>
<FieldError errors={[errors.tinNumber]} />
</Field>
<Field data-invalid={Boolean(errors.fanNumber)}>
<FieldLabel>FAN Number (16 digits)</FieldLabel>
<Input
placeholder="1234567890123456"
maxLength={16}
aria-invalid={Boolean(errors.fanNumber)}
{...register("fanNumber")}
/>
<FieldError errors={[errors.fanNumber]} />
</Field>
</div>
</>
)}
{/* Contact Person Tab */}
{tab === "contact" && (
<>
<Field data-invalid={Boolean(errors.contactPersonName)}>
<FieldLabel>Full Name</FieldLabel>
<Input
placeholder="Jane Smith"
aria-invalid={Boolean(errors.contactPersonName)}
{...register("contactPersonName")}
/>
<FieldError errors={[errors.contactPersonName]} />
</Field>
<PhoneInput
countryCode={{ ...register("contactPersonPhoneCountryCode") }}
phone={{
...register("contactPersonPhone"),
placeholder: "912345678",
}}
countryCodeError={errors.contactPersonPhoneCountryCode}
phoneError={errors.contactPersonPhone}
label="Phone Number"
/>
</>
)}
{/* General Manager Tab */}
{tab === "gm" && (
<>
<Field data-invalid={Boolean(errors.generalManagerName)}>
<FieldLabel>Full Name</FieldLabel>
<Input
placeholder="Abebe Bikila"
aria-invalid={Boolean(errors.generalManagerName)}
{...register("generalManagerName")}
/>
<FieldError errors={[errors.generalManagerName]} />
</Field>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={Boolean(errors.generalManagerEmail)}>
<FieldLabel>Email Address</FieldLabel>
<Input
type="email"
placeholder="gm@company.com"
aria-invalid={Boolean(errors.generalManagerEmail)}
{...register("generalManagerEmail")}
/>
<FieldError errors={[errors.generalManagerEmail]} />
</Field>
<PhoneInput
countryCode={{ ...register("generalManagerPhoneCountryCode") }}
phone={{
...register("generalManagerPhone"),
placeholder: "912345678",
}}
countryCodeError={errors.generalManagerPhoneCountryCode}
phoneError={errors.generalManagerPhone}
label="Phone Number"
/>
</div>
</>
)}
{/* Power of Attorney Tab */}
{tab === "poa" && (
<>
<p className="text-sm text-muted-foreground">
Power of Attorney details are optional. Fill them in if you have
an authorized representative, or leave blank.
</p>
<Field data-invalid={Boolean(errors.poaName)}>
<FieldLabel>PoA Full Name</FieldLabel>
<Input
placeholder="Authorized Representative Name"
aria-invalid={Boolean(errors.poaName)}
{...register("poaName")}
/>
<FieldError errors={[errors.poaName]} />
</Field>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={Boolean(errors.poaEmail)}>
<FieldLabel>PoA Email</FieldLabel>
<Input
type="email"
placeholder="poa@company.com"
aria-invalid={Boolean(errors.poaEmail)}
{...register("poaEmail")}
/>
<FieldError errors={[errors.poaEmail]} />
</Field>
<PhoneInput
countryCode={{ ...register("poaPhoneCountryCode") }}
phone={{ ...register("poaPhone"), placeholder: "912345678" }}
label="PoA Phone"
countryCodeError={errors.poaPhoneCountryCode}
phoneError={errors.poaPhone}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={Boolean(errors.poaLocation)}>
<FieldLabel>PoA Location</FieldLabel>
<Input
placeholder="City, Country"
aria-invalid={Boolean(errors.poaLocation)}
{...register("poaLocation")}
/>
<FieldError errors={[errors.poaLocation]} />
</Field>
<Field data-invalid={Boolean(errors.poaAddress)}>
<FieldLabel>PoA Address</FieldLabel>
<Input
placeholder="Full Address"
aria-invalid={Boolean(errors.poaAddress)}
{...register("poaAddress")}
/>
<FieldError errors={[errors.poaAddress]} />
</Field>
</div>
</>
)}
{/* Documents Tab */}
{tab === "documents" && (
<>
{docSettingQuery.isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : !docSettingQuery.data ? (
<p className="text-sm text-muted-foreground text-center py-4">
No document requirements configured for your account.
</p>
) : (
<SmartFileInput
file={docSettingQuery.data}
value={documentFiles}
onChange={setDocumentFiles}
/>
)}
{docSettingQuery.data && (
<div className="flex items-center justify-between pt-4">
<div className="flex items-center gap-2">
{docUploadMutation.isSuccess && (
<span className="flex items-center gap-1.5 text-sm font-medium text-emerald-600">
<CheckCircle2 className="size-4" />
Documents uploaded successfully
</span>
)}
{docUploadMutation.isError && (
<span className="flex items-center gap-1.5 text-sm font-medium text-red-600">
<XCircle className="size-4" />
Upload failed
</span>
)}
</div>
<Button
type="button"
onClick={() => docUploadMutation.mutate(documentFiles)}
disabled={docUploadMutation.isPending}
>
{docUploadMutation.isPending ? (
<>
<Loader2 className="size-4 animate-spin" />
Uploading...
</>
) : (
<>
<UploadCloud className="size-4" />
Upload Documents
</>
)}
</Button>
</div>
)}
</>
)}
</FieldGroup>
</CardContent>
{tab !== "documents" && (
<CardFooter className="flex items-center justify-between gap-4 border-t border-border px-6 py-4">
<div className="flex items-center gap-2">
{updateMutation.isSuccess && (
<span className="flex items-center gap-1.5 text-sm font-medium text-emerald-600">
<CheckCircle2 className="size-4" />
Saved successfully
</span>
)}
{updateMutation.isError && (
<span className="flex items-center gap-1.5 text-sm font-medium text-red-600">
<XCircle className="size-4" />
Save failed
</span>
)}
</div>
<div className="flex items-center gap-3">
<Button
type="button"
variant="outline"
disabled={isPending || !isDirty}
onClick={() => reset()}
>
Reset
</Button>
<Button type="submit" disabled={isPending}>
{updateMutation.isPending ? (
<>
<Loader2 className="size-4 animate-spin" />
Saving...
</>
) : (
<>
<Save className="size-4" />
Save Changes
</>
)}
</Button>
</div>
</CardFooter>
)}
</Card>
</form>
</div>
);
}

View File

@@ -14,10 +14,8 @@ import {
ChevronLeft,
UploadCloud,
} from "lucide-react";
import type { OnboardingUserType } from "./types";
import type { AuthUser } from "@/types/auth";
import type { CreateCompanyPayload } from "@/services/companies.service";
import type { FileUploadSetting } from "@/types/fileUploadSettings";
import PhoneInput from "@/components/auth/PhoneInput";
import {
Button,
@@ -30,7 +28,7 @@ import {
} from "@edr/ui-common";
import { api } from "@/services/api";
type CompanyStep = "company" | "personnel" | "poa" | "documents";
type CompanyStep = "company" | "personnel" | "poa" | "documents" | "confirm";
const onboardingSchema = z.object({
companyName: z.string().min(1, "Company name is required"),
@@ -85,6 +83,7 @@ const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
],
poa: [],
documents: [],
confirm: [],
};
function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
@@ -116,26 +115,34 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
}
export default function CompanyProfileForm({
userType,
documentSettingCode,
documentFiles: controlledFiles,
onDocumentFilesChange,
user,
onSubmit,
isPending,
onBack,
}: {
userType: OnboardingUserType;
documentSettingCode: string;
documentFiles?: Record<string, File | File[] | null>;
onDocumentFilesChange?: (
files: Record<string, File | File[] | null>,
) => void;
user: AuthUser;
onSubmit: (data: CreateCompanyPayload) => void;
isPending: boolean;
onBack: () => void;
}) {
const [step, setStep] = useState<CompanyStep>("company");
const [documentFiles, setDocumentFiles] = useState<
const [internalFiles, setInternalFiles] = useState<
Record<string, File | File[] | null>
>({});
const documentFiles = controlledFiles ?? internalFiles;
const setDocumentFiles = onDocumentFilesChange ?? setInternalFiles;
const { data: uploadSettings = [], isLoading: loadingDocuments } = useQuery(
api.fileUploadSettings.getByEntity.queryOptions({
input: { entity: "customer" },
const { data: uploadSetting, isLoading: loadingDocuments } = useQuery(
api.fileUploadSettings.getByCode.queryOptions({
input: { code: documentSettingCode },
refetchOnMount: false,
}),
);
@@ -144,6 +151,7 @@ export default function CompanyProfileForm({
register,
handleSubmit,
trigger,
watch,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(onboardingSchema),
@@ -173,18 +181,20 @@ export default function CompanyProfileForm({
},
});
const hasDocuments = uploadSettings.length > 0;
const formValues = watch();
const hasDocuments = Boolean(uploadSetting?.fields?.length);
const totalSteps = 5;
const nextStep = async () => {
if (step === "poa") {
if (hasDocuments) {
setStep("documents");
} else {
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
}
setStep("documents");
return;
}
if (step === "documents") {
setStep("confirm");
return;
}
if (step === "confirm") {
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
return;
}
@@ -201,8 +211,10 @@ export default function CompanyProfileForm({
setStep("company");
} else if (step === "poa") {
setStep("personnel");
} else {
} else if (step === "documents") {
setStep("poa");
} else {
setStep("documents");
}
};
@@ -228,31 +240,41 @@ export default function CompanyProfileForm({
<StepIcon
icon={<User className="size-5" />}
active={step === "personnel"}
completed={step === "poa"}
completed={
step === "poa" || step === "documents" || step === "confirm"
}
/>
<StepIcon
icon={<FileText className="size-5" />}
active={step === "poa"}
completed={hasDocuments ? step === "documents" : step === "personnel"}
completed={step === "documents" || step === "confirm"}
/>
<StepIcon
icon={<UploadCloud className="size-5" />}
active={step === "documents"}
completed={step === "confirm"}
/>
<StepIcon
icon={<CheckCircle2 className="size-5" />}
active={step === "confirm"}
completed={false}
/>
{hasDocuments && (
<StepIcon
icon={<UploadCloud className="size-5" />}
active={step === "documents"}
completed={false}
/>
)}
</div>
<p className="text-center text-sm text-muted-foreground mt-3">
{step === "company" && `Step 1 of ${hasDocuments ? 4 : 3} — Company Information`}
{step === "personnel" && `Step 2 of ${hasDocuments ? 4 : 3} — Personnel Details`}
{step === "poa" && `Step 3 of ${hasDocuments ? 4 : 3} — Power of Attorney (Optional)`}
{step === "documents" && "Step 4 of 4 — Upload Documents (Optional)"}
{step === "company" &&
`Step 1 of ${totalSteps} — Company Information`}
{step === "personnel" &&
`Step 2 of ${totalSteps} — Personnel Details`}
{step === "poa" &&
`Step 3 of ${totalSteps} — Power of Attorney (Optional)`}
{step === "documents" &&
`Step 4 of ${totalSteps} — Upload Documents (Optional)`}
{step === "confirm" && `Step 5 of ${totalSteps} — Review & Confirm`}
</p>
</div>
<form
onSubmit={handleSubmit((data) => onSubmit(buildPayload(data, user)))}
onSubmit={(e) => e.preventDefault()}
className="flex flex-col gap-4"
>
<FieldGroup className="gap-4">
@@ -353,11 +375,6 @@ export default function CompanyProfileForm({
{step === "personnel" && (
<>
<p className="text-sm text-muted-foreground">
Personal details are pulled from your account. Contact and
management info is collected below.
</p>
<div>
<h3 className="text-sm font-semibold text-foreground mb-3">
Contact Person
@@ -500,62 +517,155 @@ export default function CompanyProfileForm({
{step === "documents" && (
<>
<p className="text-sm text-muted-foreground">
Upload required documents for your registration. You can skip
this step and upload later from your account settings.
</p>
{loadingDocuments ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : uploadSettings.length === 0 ? (
) : !uploadSetting ? (
<p className="text-sm text-muted-foreground text-center py-4">
No document requirements found for your account type.
</p>
) : (
<div className="flex flex-col gap-6">
{uploadSettings.map((setting) => (
<SmartFileInput
key={setting.id}
file={setting}
value={documentFiles}
onChange={setDocumentFiles}
/>
))}
<SmartFileInput
file={uploadSetting}
value={documentFiles}
onChange={setDocumentFiles}
/>
</div>
)}
</>
)}
{step === "confirm" && (
<div className="space-y-4 rounded-xl border border-border bg-card p-4">
<div>
<h3 className="text-base font-semibold text-foreground">
Review your registration
</h3>
<p className="text-sm text-muted-foreground mt-1">
Confirm the company details below before saving.
</p>
</div>
<div className="grid gap-3 md:grid-cols-2">
<ReviewRow
label="Company name"
value={formValues.companyName}
/>
<ReviewRow
label="Company email"
value={formValues.companyEmail}
/>
<ReviewRow
label="Company phone"
value={formValues.companyPhone}
/>
<ReviewRow
label="Location"
value={formValues.companyLocation}
/>
<ReviewRow label="Address" value={formValues.companyAddress} />
<ReviewRow label="TIN" value={formValues.tinNumber} />
<ReviewRow label="VAT" value={formValues.vatNumber} />
<ReviewRow label="FAN" value={formValues.fanNumber} />
<ReviewRow
label="Contact person"
value={formValues.contactPersonName}
/>
<ReviewRow
label="Contact phone"
value={`${formValues.contactPersonPhoneCountryCode}${formValues.contactPersonPhone}`}
/>
<ReviewRow
label="General manager"
value={formValues.generalManagerName}
/>
<ReviewRow
label="GM email"
value={formValues.generalManagerEmail}
/>
<ReviewRow
label="GM phone"
value={`${formValues.generalManagerPhoneCountryCode}${formValues.generalManagerPhone}`}
/>
<ReviewRow
label="PoA name"
value={formValues.poaName || undefined}
/>
<ReviewRow
label="PoA phone"
value={
formValues.poaPhone && formValues.poaPhoneCountryCode
? `${formValues.poaPhoneCountryCode}${formValues.poaPhone}`
: undefined
}
/>
<ReviewRow
label="PoA email"
value={formValues.poaEmail || undefined}
/>
<ReviewRow
label="PoA location"
value={formValues.poaLocation || undefined}
/>
</div>
</div>
)}
</FieldGroup>
<div className="flex items-center justify-between pt-2">
<Button type="button" variant="outline" onClick={prevStep}>
<ArrowLeft />
{step === "company" ? "Change Type" : "Back"}
{step === "company"
? "Change Type"
: step === "confirm"
? "Back to Documents"
: "Back"}
</Button>
<Button type="button" onClick={nextStep} disabled={isPending}>
{isPending ? (
<>
<Loader2 className="animate-spin" />
Submitting...
</>
) : step === "documents" ? (
"Complete Registration"
) : (
<>
Next Step
<ArrowRight />
</>
)}
</Button>
<div className="flex items-center gap-3">
<Button
type="button"
onClick={step === "confirm" ? handleSubmit((data) => onSubmit(buildPayload(data, user))) : nextStep}
disabled={isPending || (step === "documents" && !hasDocuments && loadingDocuments)}
>
{isPending ? (
<>
<Loader2 className="animate-spin" />
Submitting...
</>
) : step === "documents" ? (
"Continue"
) : step === "confirm" ? (
"Submit Registration"
) : (
<>
Next Step
<ArrowRight />
</>
)}
</Button>
</div>
</div>
</form>
</>
);
}
function ReviewRow({ label, value }: { label: string; value?: string | null }) {
return (
<div className="rounded-lg border border-border bg-muted/20 p-3">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{label}
</div>
<div className="mt-1 text-sm font-medium text-foreground">
{value?.trim() ? value : "Not provided"}
</div>
</div>
);
}
function StepIcon({
icon,
active,

View File

@@ -1,5 +1,6 @@
import { useState } from "react";
import { useForm } from "react-hook-form";
import { useQuery } from "@tanstack/react-query";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
@@ -10,6 +11,7 @@ import {
CheckCircle2,
Loader2,
ChevronLeft,
UploadCloud,
} from "lucide-react";
import type { AuthUser } from "@/types/auth";
import type { CreateCompanyPayload } from "@/services/companies.service";
@@ -21,9 +23,11 @@ import {
FieldLabel,
FieldError,
FieldGroup,
SmartFileInput,
} from "@edr/ui-common";
import { api } from "@/services/api";
type DjiboutiStep = "company" | "representative";
type DjiboutiStep = "company" | "representative" | "documents" | "confirm";
const djiboutiSchema = z.object({
companyName: z.string().min(1, "Company name is required"),
@@ -40,9 +44,23 @@ const djiboutiSchema = z.object({
type FormData = z.infer<typeof djiboutiSchema>;
const stepLabels: Record<DjiboutiStep, string> = {
company: "Step 1 of 2 — Company Information",
representative: "Step 2 of 2 — Representative Details",
const stepFields: Record<DjiboutiStep, (keyof FormData)[]> = {
company: [
"companyName",
"companyEmail",
"companyPhone",
"companyPhoneCountryCode",
"companyLocation",
"companyAddress",
],
representative: [
"repName",
"repEmail",
"repPhone",
"repPhoneCountryCode",
],
documents: [],
confirm: [],
};
function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
@@ -64,22 +82,43 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
}
export default function DjiboutiAgentForm({
documentSettingCode,
documentFiles: controlledFiles,
onDocumentFilesChange,
user,
onSubmit,
isPending,
onBack,
}: {
documentSettingCode: string;
documentFiles?: Record<string, File | File[] | null>;
onDocumentFilesChange?: (
files: Record<string, File | File[] | null>,
) => void;
user: AuthUser;
onSubmit: (data: CreateCompanyPayload) => void;
isPending: boolean;
onBack: () => void;
}) {
const [step, setStep] = useState<DjiboutiStep>("company");
const [internalFiles, setInternalFiles] = useState<
Record<string, File | File[] | null>
>({});
const documentFiles = controlledFiles ?? internalFiles;
const setDocumentFiles = onDocumentFilesChange ?? setInternalFiles;
const { data: uploadSetting, isLoading: loadingDocuments } = useQuery(
api.fileUploadSettings.getByCode.queryOptions({
input: { code: documentSettingCode },
refetchOnMount: false,
}),
);
const {
register,
handleSubmit,
trigger,
watch,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(djiboutiSchema),
@@ -97,32 +136,42 @@ export default function DjiboutiAgentForm({
},
});
const formValues = watch();
const hasDocuments = Boolean(uploadSetting?.fields?.length);
const totalSteps = 4;
const nextStep = async () => {
if (step === "representative") {
setStep("documents");
return;
}
if (step === "documents") {
setStep("confirm");
return;
}
if (step === "confirm") {
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
return;
}
const fields: (keyof FormData)[] =
step === "company"
? [
"companyName",
"companyEmail",
"companyPhone",
"companyPhoneCountryCode",
"companyLocation",
"companyAddress",
]
: ["repName", "repEmail", "repPhone", "repPhoneCountryCode"];
const fields = stepFields[step];
const isValid = await trigger(fields);
if (!isValid) return;
setStep("representative");
};
const skipDocuments = () => {
setStep("confirm");
};
const prevStep = () => {
if (step === "company") {
onBack();
} else {
} else if (step === "representative") {
setStep("company");
} else if (step === "documents") {
setStep("representative");
} else {
setStep("documents");
}
};
@@ -143,21 +192,34 @@ export default function DjiboutiAgentForm({
<StepIcon
icon={<Building2 className="size-5" />}
active={step === "company"}
completed={step === "representative"}
completed={step !== "company"}
/>
<StepIcon
icon={<UserRound className="size-5" />}
active={step === "representative"}
completed={step === "documents" || step === "confirm"}
/>
<StepIcon
icon={<UploadCloud className="size-5" />}
active={step === "documents"}
completed={step === "confirm"}
/>
<StepIcon
icon={<CheckCircle2 className="size-5" />}
active={step === "confirm"}
completed={false}
/>
</div>
<p className="text-center text-sm text-muted-foreground mt-3">
{stepLabels[step]}
{step === "company" && `Step 1 of ${totalSteps} — Company Information`}
{step === "representative" && `Step 2 of ${totalSteps} — Representative Details`}
{step === "documents" && `Step 3 of ${totalSteps} — Upload Documents (Optional)`}
{step === "confirm" && `Step 4 of ${totalSteps} — Review & Confirm`}
</p>
</div>
<form
onSubmit={handleSubmit((data) => onSubmit(buildPayload(data, user)))}
onSubmit={(e) => e.preventDefault()}
className="flex flex-col gap-4"
>
<FieldGroup className="gap-4">
@@ -262,35 +324,120 @@ export default function DjiboutiAgentForm({
</div>
</>
)}
{step === "documents" && (
<>
{loadingDocuments ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : !uploadSetting ? (
<p className="text-sm text-muted-foreground text-center py-4">
No document requirements found for your account type.
</p>
) : (
<div className="flex flex-col gap-6">
<SmartFileInput
file={uploadSetting}
value={documentFiles}
onChange={setDocumentFiles}
/>
</div>
)}
</>
)}
{step === "confirm" && (
<div className="space-y-4 rounded-xl border border-border bg-card p-4">
<div>
<h3 className="text-base font-semibold text-foreground">
Review your registration
</h3>
<p className="text-sm text-muted-foreground mt-1">
Confirm the company details below before saving.
</p>
</div>
<div className="grid gap-3 md:grid-cols-2">
<ReviewRow label="Company name" value={formValues.companyName} />
<ReviewRow label="Company email" value={formValues.companyEmail} />
<ReviewRow label="Company phone" value={formValues.companyPhone} />
<ReviewRow label="Location" value={formValues.companyLocation} />
<ReviewRow label="Address" value={formValues.companyAddress} />
<ReviewRow label="Rep. name" value={formValues.repName} />
<ReviewRow label="Rep. email" value={formValues.repEmail} />
<ReviewRow
label="Rep. phone"
value={`${formValues.repPhoneCountryCode}${formValues.repPhone}`}
/>
</div>
</div>
)}
</FieldGroup>
<div className="flex items-center justify-between pt-2">
<Button type="button" variant="outline" onClick={prevStep}>
<ArrowLeft />
{step === "company" ? "Change Type" : "Back"}
{step === "company"
? "Change Type"
: step === "confirm"
? "Back to Documents"
: "Back"}
</Button>
<Button type="button" onClick={nextStep} disabled={isPending}>
{isPending ? (
<>
<Loader2 className="animate-spin" />
Submitting...
</>
) : step === "representative" ? (
"Complete Registration"
) : (
<>
Next Step
<ArrowRight />
</>
<div className="flex items-center gap-3">
{step === "documents" && (
<Button
type="button"
variant="outline"
onClick={skipDocuments}
disabled={isPending}
>
Skip for now
</Button>
)}
</Button>
<Button
type="button"
onClick={step === "confirm" ? handleSubmit((data) => onSubmit(buildPayload(data, user))) : nextStep}
disabled={isPending || (step === "documents" && !hasDocuments && loadingDocuments)}
>
{isPending ? (
<>
<Loader2 className="animate-spin" />
Submitting...
</>
) : step === "documents" ? (
"Continue"
) : step === "confirm" ? (
"Submit Registration"
) : (
<>
Next Step
<ArrowRight />
</>
)}
</Button>
</div>
</div>
</form>
</>
);
}
function ReviewRow({ label, value }: { label: string; value?: string | null }) {
return (
<div className="rounded-lg border border-border bg-muted/20 p-3">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{label}
</div>
<div className="mt-1 text-sm font-medium text-foreground">
{value?.trim() ? value : "Not provided"}
</div>
</div>
);
}
function StepIcon({
icon,
active,

View File

@@ -1,6 +1,6 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useForm } from "react-hook-form";
import { useQuery } from "@tanstack/react-query";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
@@ -11,11 +11,11 @@ import {
FileText,
CheckCircle2,
Loader2,
ChevronLeft,
UploadCloud,
} from "lucide-react";
import useAuth from "@/hooks/useAuth";
import { api } from "@/services/api";
import type { CreateCustomerDto } from "@/types/customers";
import AuthLayout from "@/components/auth/AuthLayout";
import type { AuthUser } from "@/types/auth";
import type { CreateCompanyPayload } from "@/services/companies.service";
import PhoneInput from "@/components/auth/PhoneInput";
import {
Button,
@@ -24,14 +24,13 @@ import {
FieldLabel,
FieldError,
FieldGroup,
SmartFileInput,
} from "@edr/ui-common";
import TransporterOnboarding from "./TransportrOnBoarding";
import DjiboutiForwardingAgentForm from "./DjiboutiFreightForwardingAgent";
import ImportExportOnBoarding from "./ImportExportOnBoarding";
import { api } from "@/services/api";
type OnboardingStep = "company" | "personnel" | "poa";
type ForwarderStep = "company" | "personnel" | "poa" | "documents" | "confirm";
const onboardingSchema = z.object({
const forwarderSchema = z.object({
companyName: z.string().min(1, "Company name is required"),
companyEmail: z.string().email("Invalid email address"),
companyPhone: z.string().min(1, "Company phone is required"),
@@ -59,9 +58,9 @@ const onboardingSchema = z.object({
poaLocation: z.string().optional(),
});
type FormData = z.infer<typeof onboardingSchema>;
type FormData = z.infer<typeof forwarderSchema>;
const stepFields: Record<OnboardingStep, (keyof FormData)[]> = {
const stepFields: Record<ForwarderStep, (keyof FormData)[]> = {
company: [
"companyName",
"companyEmail",
@@ -83,20 +82,79 @@ const stepFields: Record<OnboardingStep, (keyof FormData)[]> = {
"generalManagerPhoneCountryCode",
],
poa: [],
documents: [],
confirm: [],
};
export default function CustomerOnboardingPage() {
const queryClient = useQueryClient();
const { user } = useAuth();
const [step, setStep] = useState<OnboardingStep>("company");
function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
return {
companyName: data.companyName,
companyEmail: data.companyEmail,
companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`,
companyLocation: data.companyLocation,
companyAddress: data.companyAddress,
tin: data.tinNumber,
vatNumber: data.vatNumber,
fanNumber: data.fanNumber,
attributes: {
contactPersonName: data.contactPersonName,
contactPersonPhone: `${data.contactPersonPhoneCountryCode}${data.contactPersonPhone}`,
generalManagerName: data.generalManagerName,
generalManagerEmail: data.generalManagerEmail,
generalManagerPhone: `${data.generalManagerPhoneCountryCode}${data.generalManagerPhone}`,
poaName: data.poaName || undefined,
poaPhone:
data.poaPhone && data.poaPhoneCountryCode
? `${data.poaPhoneCountryCode}${data.poaPhone}`
: undefined,
poaAddress: data.poaAddress || undefined,
poaEmail: data.poaEmail || undefined,
poaLocation: data.poaLocation || undefined,
},
};
}
export default function ForwarderForm({
documentSettingCode,
documentFiles: controlledFiles,
onDocumentFilesChange,
user,
onSubmit,
isPending,
onBack,
}: {
documentSettingCode: string;
documentFiles?: Record<string, File | File[] | null>;
onDocumentFilesChange?: (
files: Record<string, File | File[] | null>,
) => void;
user: AuthUser;
onSubmit: (data: CreateCompanyPayload) => void;
isPending: boolean;
onBack: () => void;
}) {
const [step, setStep] = useState<ForwarderStep>("company");
const [internalFiles, setInternalFiles] = useState<
Record<string, File | File[] | null>
>({});
const documentFiles = controlledFiles ?? internalFiles;
const setDocumentFiles = onDocumentFilesChange ?? setInternalFiles;
const { data: uploadSetting, isLoading: loadingDocuments } = useQuery(
api.fileUploadSettings.getByCode.queryOptions({
input: { code: documentSettingCode },
refetchOnMount: false,
}),
);
const {
register,
handleSubmit,
trigger,
watch,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(onboardingSchema),
resolver: zodResolver(forwarderSchema),
defaultValues: {
companyName: "",
companyEmail: "",
@@ -123,20 +181,21 @@ export default function CustomerOnboardingPage() {
},
});
const createCustomerMutation = useMutation({
mutationFn: (payload: CreateCustomerDto) =>
api.customers.create.call(payload),
onSuccess: () => {
if (user)
queryClient.invalidateQueries({
queryKey: api.customers.getByUserId.queryKey({ id: user.id }),
});
},
});
const formValues = watch();
const hasDocuments = Boolean(uploadSetting?.fields?.length);
const totalSteps = 5;
const nextStep = async () => {
if (step === "poa") {
handleSubmit(onSubmit)();
setStep("documents");
return;
}
if (step === "documents") {
setStep("confirm");
return;
}
if (step === "confirm") {
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
return;
}
const fields = stepFields[step];
@@ -145,69 +204,36 @@ export default function CustomerOnboardingPage() {
setStep(step === "company" ? "personnel" : "poa");
};
const prevStep = () => {
if (step === "personnel") setStep("company");
else if (step === "poa") setStep("personnel");
const skipDocuments = () => {
setStep("confirm");
};
const onSubmit = async (data: FormData) => {
const nameParts = (user?.name?.en ?? "").split(" ");
const payload: CreateCustomerDto = {
userId: user!.id,
firstName: nameParts[0] || "",
lastName: nameParts.slice(-1)[0] || "",
email: user!.email,
phone: user!.phoneNumber,
companyName: data.companyName,
companyEmail: data.companyEmail,
companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`,
companyLocation: data.companyLocation,
companyAddress: data.companyAddress,
contactPersonName: data.contactPersonName,
contactPersonPhone: `${data.contactPersonPhoneCountryCode}${data.contactPersonPhone}`,
tinNumber: data.tinNumber,
vatNumber: data.vatNumber,
fanNumber: data.fanNumber,
generalManagerName: data.generalManagerName,
generalManagerEmail: data.generalManagerEmail,
generalManagerPhone: `${data.generalManagerPhoneCountryCode}${data.generalManagerPhone}`,
poaName: data.poaName || undefined,
poaPhone:
data.poaPhone && data.poaPhoneCountryCode
? `${data.poaPhoneCountryCode}${data.poaPhone}`
: undefined,
poaAddress: data.poaAddress || undefined,
poaEmail: data.poaEmail || undefined,
poaLocation: data.poaLocation || undefined,
};
createCustomerMutation.mutate(payload);
const prevStep = () => {
if (step === "company") {
onBack();
} else if (step === "personnel") {
setStep("company");
} else if (step === "poa") {
setStep("personnel");
} else if (step === "documents") {
setStep("poa");
} else {
setStep("documents");
}
};
return (
<AuthLayout
left={{
badge: "Complete Your Profile",
title: "Set up your company profile",
description:
"Provide your business details to start using EDR Freight for managing shipments, tracking consignments, and streamlining logistics operations across Ethiopia and Djibouti.",
features: [
"Company registration details",
"Contact and management personnel",
"Power of Attorney (optional)",
],
stats: {
label: "Active Customers",
value: "500+",
footer: "And growing",
progress: "w-[95%]",
},
}}
>
<>
<div className="mb-8">
<button
type="button"
onClick={prevStep}
className="mb-4 flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronLeft className="size-4" />
Change account type
</button>
<TransporterOnboarding />
{/* <DjiboutiForwardingAgentForm /> */}
{/* <ImportExportOnBoarding /> */}
{/* <div className="mb-8 lg:col-span-2">
<div className="flex items-center justify-between max-w-lg mx-auto relative px-2">
<div className="absolute top-1/2 left-0 w-full h-0.5 bg-border -translate-y-1/2 z-0" />
<StepIcon
@@ -218,22 +244,41 @@ export default function CustomerOnboardingPage() {
<StepIcon
icon={<User className="size-5" />}
active={step === "personnel"}
completed={step === "poa"}
completed={step === "poa" || step === "documents" || step === "confirm"}
/>
<StepIcon
icon={<FileText className="size-5" />}
active={step === "poa"}
completed={step === "documents" || step === "confirm"}
/>
<StepIcon
icon={<UploadCloud className="size-5" />}
active={step === "documents"}
completed={step === "confirm"}
/>
<StepIcon
icon={<CheckCircle2 className="size-5" />}
active={step === "confirm"}
completed={false}
/>
</div>
<p className="text-center text-sm text-muted-foreground mt-3">
{step === "company" && "Step 1 of 3 — Company Information"}
{step === "personnel" && "Step 2 of 3 — Personnel Details"}
{step === "poa" && "Step 3 of 3 — Power of Attorney (Optional)"}
{step === "company" &&
`Step 1 of ${totalSteps} — Company Information`}
{step === "personnel" &&
`Step 2 of ${totalSteps} — Personnel Details`}
{step === "poa" &&
`Step 3 of ${totalSteps} — Power of Attorney (Optional)`}
{step === "documents" &&
`Step 4 of ${totalSteps} — Upload Documents (Optional)`}
{step === "confirm" && `Step 5 of ${totalSteps} — Review & Confirm`}
</p>
</div> */}
</div>
{/* <form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
<form
onSubmit={(e) => e.preventDefault()}
className="flex flex-col gap-4"
>
<FieldGroup className="gap-4">
{step === "company" && (
<>
@@ -332,11 +377,6 @@ export default function CustomerOnboardingPage() {
{step === "personnel" && (
<>
<p className="text-sm text-muted-foreground">
Personal details are pulled from your account. Contact and
management info is collected below.
</p>
<div>
<h3 className="text-sm font-semibold text-foreground mb-3">
Contact Person
@@ -418,88 +458,212 @@ export default function CustomerOnboardingPage() {
{step === "poa" && (
<>
<p className="text-sm text-muted-foreground">
Power of Attorney details are optional. Skip if not applicable.
Power of Attorney details are optional. Fill them in if you have
them, or skip to continue.
</p>
<Field>
<Field data-invalid={Boolean(errors.poaName)}>
<FieldLabel>PoA Name</FieldLabel>
<Input
placeholder="Authorized Representative Name"
aria-invalid={Boolean(errors.poaName)}
{...register("poaName")}
/>
<FieldError errors={[errors.poaName]} />
</Field>
<div className="grid grid-cols-2 gap-4">
<Field>
<Field data-invalid={Boolean(errors.poaEmail)}>
<FieldLabel>PoA Email</FieldLabel>
<Input
type="email"
placeholder="poa@company.com"
aria-invalid={Boolean(errors.poaEmail)}
{...register("poaEmail")}
/>
<FieldError errors={[errors.poaEmail]} />
</Field>
<PhoneInput
countryCode={{ ...register("poaPhoneCountryCode") }}
phone={{ ...register("poaPhone"), placeholder: "912345678" }}
label="PoA Phone"
countryCodeError={errors.poaPhoneCountryCode}
phoneError={errors.poaPhone}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<Field>
<Field data-invalid={Boolean(errors.poaLocation)}>
<FieldLabel>PoA Location</FieldLabel>
<Input
placeholder="City, Country"
aria-invalid={Boolean(errors.poaLocation)}
{...register("poaLocation")}
/>
<FieldError errors={[errors.poaLocation]} />
</Field>
<Field>
<Field data-invalid={Boolean(errors.poaAddress)}>
<FieldLabel>PoA Address</FieldLabel>
<Input
placeholder="Full Address"
aria-invalid={Boolean(errors.poaAddress)}
{...register("poaAddress")}
/>
<FieldError errors={[errors.poaAddress]} />
</Field>
</div>
</>
)}
{step === "documents" && (
<>
{loadingDocuments ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : !uploadSetting ? (
<p className="text-sm text-muted-foreground text-center py-4">
No document requirements found for your account type.
</p>
) : (
<div className="flex flex-col gap-6">
<SmartFileInput
file={uploadSetting}
value={documentFiles}
onChange={setDocumentFiles}
/>
</div>
)}
</>
)}
{step === "confirm" && (
<div className="space-y-4 rounded-xl border border-border bg-card p-4">
<div>
<h3 className="text-base font-semibold text-foreground">
Review your registration
</h3>
<p className="text-sm text-muted-foreground mt-1">
Confirm the company details below before saving.
</p>
</div>
<div className="grid gap-3 md:grid-cols-2">
<ReviewRow label="Company name" value={formValues.companyName} />
<ReviewRow label="Company email" value={formValues.companyEmail} />
<ReviewRow label="Company phone" value={formValues.companyPhone} />
<ReviewRow label="Location" value={formValues.companyLocation} />
<ReviewRow label="Address" value={formValues.companyAddress} />
<ReviewRow label="TIN" value={formValues.tinNumber} />
<ReviewRow label="VAT" value={formValues.vatNumber} />
<ReviewRow label="FAN" value={formValues.fanNumber} />
<ReviewRow
label="Contact person"
value={formValues.contactPersonName}
/>
<ReviewRow
label="Contact phone"
value={`${formValues.contactPersonPhoneCountryCode}${formValues.contactPersonPhone}`}
/>
<ReviewRow
label="General manager"
value={formValues.generalManagerName}
/>
<ReviewRow
label="GM email"
value={formValues.generalManagerEmail}
/>
<ReviewRow
label="GM phone"
value={`${formValues.generalManagerPhoneCountryCode}${formValues.generalManagerPhone}`}
/>
<ReviewRow
label="PoA name"
value={formValues.poaName || undefined}
/>
<ReviewRow
label="PoA phone"
value={
formValues.poaPhone && formValues.poaPhoneCountryCode
? `${formValues.poaPhoneCountryCode}${formValues.poaPhone}`
: undefined
}
/>
<ReviewRow
label="PoA email"
value={formValues.poaEmail || undefined}
/>
<ReviewRow
label="PoA location"
value={formValues.poaLocation || undefined}
/>
</div>
</div>
)}
</FieldGroup>
<div className="flex items-center justify-between pt-2">
<Button
type="button"
variant="outline"
onClick={prevStep}
disabled={step === "company"}
>
<Button type="button" variant="outline" onClick={prevStep}>
<ArrowLeft />
Back
{step === "company"
? "Change Type"
: step === "confirm"
? "Back to Documents"
: "Back"}
</Button>
<Button
type="button"
onClick={nextStep}
disabled={createCustomerMutation.isPending}
>
{createCustomerMutation.isPending ? (
<>
<Loader2 className="animate-spin" />
Submitting...
</>
) : step === "poa" ? (
"Complete Registration"
) : (
<>
Next Step
<ArrowRight />
</>
<div className="flex items-center gap-3">
{step === "documents" && (
<Button
type="button"
variant="outline"
onClick={skipDocuments}
disabled={isPending}
>
Skip for now
</Button>
)}
</Button>
<Button
type="button"
onClick={step === "confirm" ? handleSubmit((data) => onSubmit(buildPayload(data, user))) : nextStep}
disabled={isPending || (step === "documents" && !hasDocuments && loadingDocuments)}
>
{isPending ? (
<>
<Loader2 className="animate-spin" />
Submitting...
</>
) : step === "documents" ? (
"Continue"
) : step === "confirm" ? (
"Submit Registration"
) : (
<>
Next Step
<ArrowRight />
</>
)}
</Button>
</div>
</div>
</form> */}
</AuthLayout>
</form>
</>
);
}
function ReviewRow({ label, value }: { label: string; value?: string | null }) {
return (
<div className="rounded-lg border border-border bg-muted/20 p-3">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{label}
</div>
<div className="mt-1 text-sm font-medium text-foreground">
{value?.trim() ? value : "Not provided"}
</div>
</div>
);
}

View File

@@ -1,5 +1,5 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
ArrowDownToLine,
ArrowUpFromLine,
@@ -10,9 +10,11 @@ import {
} from "lucide-react";
import useAuth from "@/hooks/useAuth";
import { api } from "@/services/api";
import { companiesService } from "@/services/companies.service";
import type { CreateCompanyPayload } from "@/services/companies.service";
import AuthLayout from "@/components/auth/AuthLayout";
import CompanyProfileForm from "./CompanyProfileForm";
import ForwarderForm from "./ForwarderForm";
import DjiboutiAgentForm from "./DjiboutiAgentForm";
import TransporterForm from "./TransporterForm";
import type { OnboardingUserType } from "./types";
@@ -113,17 +115,21 @@ const PREFLIGHT_LEFT = {
},
};
const DOCUMENT_SETTING_CODE_MAP: Record<OnboardingUserType, string> = {
importer: "company_onboarding_documents_customer",
exporter: "company_onboarding_documents_customer",
"freight-forwarder-et": "company_onboarding_documents_forwarder",
"freight-forwarder-dj": "company_onboarding_documents_forwarder_dj",
transporter: "company_onboarding_documents_transporter",
};
export default function OnboardingPage() {
const queryClient = useQueryClient();
const { user } = useAuth();
const [userType, setUserType] = useState<OnboardingUserType | null>(null);
useQuery(
api.fileUploadSettings.getByEntity.queryOptions({
input: { entity: "customer" },
refetchOnMount: false,
}),
);
const [documentFiles, setDocumentFiles] = useState<
Record<string, File | File[] | null>
>({});
const COMPANY_TYPE_MAP: Record<OnboardingUserType, string> = {
importer: "customer",
@@ -136,8 +142,14 @@ export default function OnboardingPage() {
const createCompanyMutation = useMutation({
mutationFn: (payload: CreateCompanyPayload) =>
api.companies.create.call(payload),
onSuccess: () => {
queryClient.invalidateQueries({
onSuccess: async (data) => {
const hasFiles = Object.values(documentFiles).some(
(f) => f !== null && (Array.isArray(f) ? f.length > 0 : true),
);
if (hasFiles) {
await companiesService.uploadDocuments(data.company.id, documentFiles);
}
await queryClient.invalidateQueries({
queryKey: api.companies.getInfo.queryKey(),
});
},
@@ -237,6 +249,9 @@ export default function OnboardingPage() {
<AuthLayout left={leftProps}>
{userType === "transporter" ? (
<TransporterForm
documentSettingCode={DOCUMENT_SETTING_CODE_MAP[userType]}
documentFiles={documentFiles}
onDocumentFilesChange={setDocumentFiles}
user={user}
onSubmit={handleSubmit}
isPending={createCompanyMutation.isPending}
@@ -244,6 +259,19 @@ export default function OnboardingPage() {
/>
) : userType === "freight-forwarder-dj" ? (
<DjiboutiAgentForm
documentSettingCode={DOCUMENT_SETTING_CODE_MAP[userType]}
documentFiles={documentFiles}
onDocumentFilesChange={setDocumentFiles}
user={user}
onSubmit={handleSubmit}
isPending={createCompanyMutation.isPending}
onBack={handleBack}
/>
) : userType === "freight-forwarder-et" ? (
<ForwarderForm
documentSettingCode={DOCUMENT_SETTING_CODE_MAP[userType]}
documentFiles={documentFiles}
onDocumentFilesChange={setDocumentFiles}
user={user}
onSubmit={handleSubmit}
isPending={createCompanyMutation.isPending}
@@ -251,7 +279,9 @@ export default function OnboardingPage() {
/>
) : (
<CompanyProfileForm
userType={userType}
documentSettingCode={DOCUMENT_SETTING_CODE_MAP[userType]}
documentFiles={documentFiles}
onDocumentFilesChange={setDocumentFiles}
user={user}
onSubmit={handleSubmit}
isPending={createCompanyMutation.isPending}

View File

@@ -1,11 +1,16 @@
import { useState } from "react";
import { useForm, Controller } from "react-hook-form";
import { useQuery } from "@tanstack/react-query";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Loader2,
ArrowRight,
ArrowLeft,
ChevronLeft,
Truck,
Info,
CheckCircle2,
Loader2,
UploadCloud,
} from "lucide-react";
import type { AuthUser } from "@/types/auth";
import type { CreateCompanyPayload } from "@/services/companies.service";
@@ -20,8 +25,10 @@ import {
SelectItem,
SelectTrigger,
SelectValue,
SmartFileInput,
} from "@edr/ui-common";
import { cn } from "@/lib/utils";
import { api } from "@/services/api";
const TRUCK_TYPES = [
"Casoni",
@@ -31,6 +38,8 @@ const TRUCK_TYPES = [
"Others",
] as const;
type TransporterStep = "vehicle" | "documents" | "confirm";
const transporterSchema = z
.object({
tinNumber: z.string().length(10, "TIN must be exactly 10 digits"),
@@ -45,7 +54,10 @@ const transporterSchema = z
.regex(/^\d{4}$/, "Enter a valid year (e.g. 2023)"),
})
.superRefine((data, ctx) => {
if (data.truckType === "Casoni" && (!data.plateNumber2 || data.plateNumber2.trim().length === 0)) {
if (
data.truckType === "Casoni" &&
(!data.plateNumber2 || data.plateNumber2.trim().length === 0)
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["plateNumber2"],
@@ -77,19 +89,42 @@ function buildPayload(data: FormData, user: AuthUser): CreateCompanyPayload {
}
export default function TransporterForm({
documentSettingCode,
documentFiles: controlledFiles,
onDocumentFilesChange,
user,
onSubmit,
isPending,
onBack,
}: {
documentSettingCode: string;
documentFiles?: Record<string, File | File[] | null>;
onDocumentFilesChange?: (
files: Record<string, File | File[] | null>,
) => void;
user: AuthUser;
onSubmit: (data: CreateCompanyPayload) => void;
isPending: boolean;
onBack: () => void;
}) {
const [step, setStep] = useState<TransporterStep>("vehicle");
const [internalFiles, setInternalFiles] = useState<
Record<string, File | File[] | null>
>({});
const documentFiles = controlledFiles ?? internalFiles;
const setDocumentFiles = onDocumentFilesChange ?? setInternalFiles;
const { data: uploadSetting, isLoading: loadingDocuments } = useQuery(
api.fileUploadSettings.getByCode.queryOptions({
input: { code: documentSettingCode },
refetchOnMount: false,
}),
);
const {
register,
handleSubmit,
trigger,
watch,
control,
formState: { errors },
@@ -108,187 +143,341 @@ export default function TransporterForm({
const truckType = watch("truckType");
const isCasoni = truckType === "Casoni";
const formValues = watch();
const hasDocuments = Boolean(uploadSetting?.fields?.length);
const totalSteps = 3;
const nextStep = async () => {
if (step === "documents") {
setStep("confirm");
return;
}
if (step === "confirm") {
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
return;
}
const fields: (keyof FormData)[] = [
"tinNumber",
"fanNumber",
"truckType",
"plateNumber",
"vehicleModel",
"yearOfManufacturing",
];
const isValid = await trigger(fields);
if (!isValid) return;
setStep("documents");
};
const skipDocuments = () => {
setStep("confirm");
};
const prevStep = () => {
if (step === "vehicle") {
onBack();
} else if (step === "documents") {
setStep("vehicle");
} else {
setStep("documents");
}
};
return (
<>
<div className="mb-8">
<button
type="button"
onClick={onBack}
onClick={prevStep}
className="mb-4 flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronLeft className="size-4" />
Change account type
</button>
<div className="flex items-center justify-center relative px-2">
<div className="flex h-10 w-10 items-center justify-center rounded-full border-2 border-primary bg-background text-primary shadow-md">
<Truck className="size-5" />
</div>
<div className="flex items-center justify-between max-w-lg mx-auto relative px-2">
<div className="absolute top-1/2 left-0 w-full h-0.5 bg-border -translate-y-1/2 z-0" />
<StepIcon
icon={<Truck className="size-5" />}
active={step === "vehicle"}
completed={step !== "vehicle"}
/>
<StepIcon
icon={<UploadCloud className="size-5" />}
active={step === "documents"}
completed={step === "confirm"}
/>
<StepIcon
icon={<CheckCircle2 className="size-5" />}
active={step === "confirm"}
completed={false}
/>
</div>
<p className="text-center text-sm text-muted-foreground mt-3">
Transporter Registration
{step === "vehicle" && `Step 1 of ${totalSteps} — Vehicle Information`}
{step === "documents" && `Step 2 of ${totalSteps} — Upload Documents (Optional)`}
{step === "confirm" && `Step 3 of ${totalSteps} — Review & Confirm`}
</p>
</div>
<form
onSubmit={handleSubmit((data) => onSubmit(buildPayload(data, user)))}
onSubmit={(e) => e.preventDefault()}
className="flex flex-col gap-4"
>
{/* Personal Info (read-only) */}
<div className="rounded-lg bg-muted/30 p-4 text-sm text-muted-foreground">
<div className="flex items-center gap-2 mb-2">
<Info className="size-4" />
<span className="font-medium text-foreground">Account Holder</span>
{step === "vehicle" && (
<>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={Boolean(errors.tinNumber)}>
<FieldLabel>TIN Number (10 digits)</FieldLabel>
<Input
placeholder="1234567890"
maxLength={10}
aria-invalid={Boolean(errors.tinNumber)}
{...register("tinNumber")}
/>
<FieldError errors={[errors.tinNumber]} />
</Field>
<Field data-invalid={Boolean(errors.fanNumber)}>
<FieldLabel>FAN Number (16 digits)</FieldLabel>
<Input
placeholder="1234567890123456"
maxLength={16}
aria-invalid={Boolean(errors.fanNumber)}
{...register("fanNumber")}
/>
<FieldError errors={[errors.fanNumber]} />
</Field>
</div>
<hr className="border-border" />
<h3 className="text-sm font-semibold text-foreground">
Vehicle / Truck Information
</h3>
<Controller
name="truckType"
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={Boolean(fieldState.error)}>
<FieldLabel>Truck Type</FieldLabel>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger
className={cn(
"w-full",
fieldState.error ? "border-destructive!" : "",
)}
aria-invalid={Boolean(fieldState.error)}
>
<SelectValue placeholder="Select truck type..." />
</SelectTrigger>
<SelectContent>
{TRUCK_TYPES.map((type) => (
<SelectItem key={type} value={type}>
{type}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={Boolean(errors.plateNumber)}>
<FieldLabel>Plate Number{isCasoni ? " (Front)" : ""}</FieldLabel>
<Input
placeholder={isCasoni ? "AA-12345" : "AA-12345"}
aria-invalid={Boolean(errors.plateNumber)}
{...register("plateNumber")}
/>
<FieldError errors={[errors.plateNumber]} />
</Field>
{isCasoni && (
<Field data-invalid={Boolean(errors.plateNumber2)}>
<FieldLabel>Plate Number (Trailer)</FieldLabel>
<Input
placeholder="AA-67890"
aria-invalid={Boolean(errors.plateNumber2)}
{...register("plateNumber2")}
/>
<FieldError errors={[errors.plateNumber2]} />
</Field>
)}
{!isCasoni && (
<Field data-invalid={Boolean(errors.vehicleModel)}>
<FieldLabel>Vehicle Model</FieldLabel>
<Input
placeholder="Isuzu FVR 2024"
aria-invalid={Boolean(errors.vehicleModel)}
{...register("vehicleModel")}
/>
<FieldError errors={[errors.vehicleModel]} />
</Field>
)}
</div>
<div className="grid grid-cols-2 gap-4">
{isCasoni && (
<Field data-invalid={Boolean(errors.vehicleModel)}>
<FieldLabel>Vehicle Model</FieldLabel>
<Input
placeholder="Isuzu FVR 2024"
aria-invalid={Boolean(errors.vehicleModel)}
{...register("vehicleModel")}
/>
<FieldError errors={[errors.vehicleModel]} />
</Field>
)}
<Field data-invalid={Boolean(errors.yearOfManufacturing)}>
<FieldLabel>Year of Manufacturing</FieldLabel>
<Input
placeholder="2023"
maxLength={4}
aria-invalid={Boolean(errors.yearOfManufacturing)}
{...register("yearOfManufacturing")}
/>
<FieldError errors={[errors.yearOfManufacturing]} />
</Field>
</div>
</>
)}
{step === "documents" && (
<>
{loadingDocuments ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : !uploadSetting ? (
<p className="text-sm text-muted-foreground text-center py-4">
No document requirements found for your account type.
</p>
) : (
<div className="flex flex-col gap-6">
<SmartFileInput
file={uploadSetting}
value={documentFiles}
onChange={setDocumentFiles}
/>
</div>
)}
</>
)}
{step === "confirm" && (
<div className="space-y-4 rounded-xl border border-border bg-card p-4">
<div>
<h3 className="text-base font-semibold text-foreground">
Review your registration
</h3>
<p className="text-sm text-muted-foreground mt-1">
Confirm the details below before saving.
</p>
</div>
<div className="grid gap-3 md:grid-cols-2">
<ReviewRow label="TIN Number" value={formValues.tinNumber} />
<ReviewRow label="FAN Number" value={formValues.fanNumber} />
<ReviewRow label="Truck Type" value={formValues.truckType} />
<ReviewRow label="Plate Number" value={formValues.plateNumber} />
{formValues.plateNumber2 && (
<ReviewRow label="Plate (Trailer)" value={formValues.plateNumber2} />
)}
<ReviewRow label="Vehicle Model" value={formValues.vehicleModel} />
<ReviewRow label="Year" value={formValues.yearOfManufacturing} />
</div>
</div>
<p>
{user.name?.en} {user.email} {user.phoneNumber}
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={Boolean(errors.tinNumber)}>
<FieldLabel>TIN Number (10 digits)</FieldLabel>
<Input
placeholder="1234567890"
maxLength={10}
aria-invalid={Boolean(errors.tinNumber)}
{...register("tinNumber")}
/>
<FieldError errors={[errors.tinNumber]} />
</Field>
<Field data-invalid={Boolean(errors.fanNumber)}>
<FieldLabel>FAN Number (16 digits)</FieldLabel>
<Input
placeholder="1234567890123456"
maxLength={16}
aria-invalid={Boolean(errors.fanNumber)}
{...register("fanNumber")}
/>
<FieldError errors={[errors.fanNumber]} />
</Field>
</div>
<hr className="border-border" />
<h3 className="text-sm font-semibold text-foreground">
Vehicle / Truck Information
</h3>
<Controller
name="truckType"
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={Boolean(fieldState.error)}>
<FieldLabel>Truck Type</FieldLabel>
<Select
value={field.value}
onValueChange={field.onChange}
>
<SelectTrigger
className={cn(
"w-full",
fieldState.error ? "border-destructive!" : "",
)}
aria-invalid={Boolean(fieldState.error)}
>
<SelectValue placeholder="Select truck type..." />
</SelectTrigger>
<SelectContent>
{TRUCK_TYPES.map((type) => (
<SelectItem key={type} value={type}>
{type}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={Boolean(errors.plateNumber)}>
<FieldLabel>
Plate Number{isCasoni ? " (Front)" : ""}
</FieldLabel>
<Input
placeholder={isCasoni ? "AA-12345" : "AA-12345"}
aria-invalid={Boolean(errors.plateNumber)}
{...register("plateNumber")}
/>
<FieldError errors={[errors.plateNumber]} />
</Field>
{isCasoni && (
<Field data-invalid={Boolean(errors.plateNumber2)}>
<FieldLabel>Plate Number (Trailer)</FieldLabel>
<Input
placeholder="AA-67890"
aria-invalid={Boolean(errors.plateNumber2)}
{...register("plateNumber2")}
/>
<FieldError errors={[errors.plateNumber2]} />
</Field>
)}
{!isCasoni && (
<Field data-invalid={Boolean(errors.vehicleModel)}>
<FieldLabel>Vehicle Model</FieldLabel>
<Input
placeholder="Isuzu FVR 2024"
aria-invalid={Boolean(errors.vehicleModel)}
{...register("vehicleModel")}
/>
<FieldError errors={[errors.vehicleModel]} />
</Field>
)}
</div>
<div className="grid grid-cols-2 gap-4">
{isCasoni && (
<Field data-invalid={Boolean(errors.vehicleModel)}>
<FieldLabel>Vehicle Model</FieldLabel>
<Input
placeholder="Isuzu FVR 2024"
aria-invalid={Boolean(errors.vehicleModel)}
{...register("vehicleModel")}
/>
<FieldError errors={[errors.vehicleModel]} />
</Field>
)}
<Field data-invalid={Boolean(errors.yearOfManufacturing)}>
<FieldLabel>Year of Manufacturing</FieldLabel>
<Input
placeholder="2023"
maxLength={4}
aria-invalid={Boolean(errors.yearOfManufacturing)}
{...register("yearOfManufacturing")}
/>
<FieldError errors={[errors.yearOfManufacturing]} />
</Field>
</div>
)}
<div className="flex items-center justify-between pt-2">
<Button type="button" variant="outline" onClick={onBack}>
<ChevronLeft />
Change Type
<Button type="button" variant="outline" onClick={prevStep}>
<ArrowLeft />
{step === "vehicle"
? "Change Type"
: step === "confirm"
? "Back to Documents"
: "Back"}
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? (
<>
<Loader2 className="animate-spin" />
Submitting...
</>
) : (
"Complete Registration"
<div className="flex items-center gap-3">
{step === "documents" && (
<Button
type="button"
variant="outline"
onClick={skipDocuments}
disabled={isPending}
>
Skip for now
</Button>
)}
</Button>
<Button
type="button"
onClick={step === "confirm" ? handleSubmit((data) => onSubmit(buildPayload(data, user))) : nextStep}
disabled={isPending || (step === "documents" && !hasDocuments && loadingDocuments)}
>
{isPending ? (
<>
<Loader2 className="animate-spin" />
Submitting...
</>
) : step === "documents" ? (
"Continue"
) : step === "confirm" ? (
"Submit Registration"
) : (
<>
Next Step
<ArrowRight />
</>
)}
</Button>
</div>
</div>
</form>
</>
);
}
function ReviewRow({ label, value }: { label: string; value?: string | null }) {
return (
<div className="rounded-lg border border-border bg-muted/20 p-3">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{label}
</div>
<div className="mt-1 text-sm font-medium text-foreground">
{value?.trim() ? value : "Not provided"}
</div>
</div>
);
}
function StepIcon({
icon,
active,
completed,
}: {
icon: React.ReactNode;
active: boolean;
completed: boolean;
}) {
return (
<div
className={`relative z-10 flex h-10 w-10 items-center justify-center rounded-full border-2 transition-all ${
completed
? "bg-primary border-primary text-primary-foreground"
: active
? "bg-background border-primary text-primary shadow-md"
: "bg-background border-border text-muted-foreground"
}`}
>
{completed ? <CheckCircle2 className="size-5" /> : icon}
</div>
);
}

View File

@@ -6,14 +6,12 @@ import { useNavigate } from "react-router-dom";
import {
AlertCircle,
Check,
CheckCircle2,
ChevronLeft,
ChevronRight,
LoaderCircle,
} from "lucide-react";
import { Button } from "@edr/ui-common";
import { api } from "@/services/api";
import { Freight } from "@edr/types";
import type { CreateBookingPayload } from "@/services/bookings.service";
import {
BookingFormInputValues,
@@ -32,13 +30,11 @@ import {
Step5CargoDetails,
Step8Review,
} from "./new-booking-form/steps";
import useAuth from "@/hooks/useAuth";
export default function NewBookingPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [step, setStep] = useState(1);
const { customer } = useAuth();
const { data: referenceData, isLoading: refDataLoading } = useQuery(
api.bookings.referenceData.queryOptions(),
);
@@ -48,7 +44,7 @@ export default function NewBookingPage() {
api.bookings.create.call(payload),
onSuccess: (booking) => {
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
setTimeout(() => navigate(`/bookings/${booking.id}`), 2500);
navigate(`/bookings/${booking.id}`);
},
});
@@ -132,23 +128,27 @@ export default function NewBookingPage() {
return "";
};
const selectedChild =
data.cargoType !== "container" && data.bulkCommoditytype
? cargoTree
.find((g) => g.code.toLowerCase() === data.freightType)
?.children?.find((c) => c.name === data.bulkCommoditytype)
: undefined;
const cargoTypeId =
data.cargoType === "container"
? findContainerCargoTypeId()
: (findCargoTypeId(
data.freightType === "bulk"
? data.bulkCommodity
: data.breakBulkType,
) ?? "");
: (findCargoTypeId(data.bulkCommoditytype) ??
cargoTree.find((g) => g.code.toLowerCase() === data.freightType)
?.id ??
"");
const cargoFreeText =
data.cargoType === "container"
? undefined
: data.freightType === "bulk" && data.bulkCommodity === "Others"
? data.bulkCommodityOther
: data.freightType === "break_bulk" && data.breakBulkType === "Others"
? data.breakBulkTypeOther
: undefined;
: selectedChild?.show_free_text_box
? data.bulkCommoditytype
: undefined;
// ── Build API payload ───────────────────────────────────────────────
const apiPayload: CreateBookingPayload = {
@@ -168,15 +168,16 @@ export default function NewBookingPage() {
: direction === "domestic"
? "DOMESTIC"
: "IMPORT",
freightType:
data.cargoType === "container"
? Freight.FreightType.Container
: Freight.FreightType.Bulk,
cargoTypeId: data.cargoType === "container" ? undefined : cargoTypeId,
cargoTotalWeightVgm: totalWeight,
isHazardous: data.isHazardous,
paymentCurrency: "USD",
allowConsolidation: data.consolidationEnabled,
// @ts-ignore
freightType:
data.cargoType === "container"
? ("CONTAINER" as const)
: ("BULK" as const),
containers:
data.cargoType === "container"
? data.containers.map((c) => ({
@@ -185,7 +186,6 @@ export default function NewBookingPage() {
vgmPerUnitTons: Number(c.vgm || 0),
}))
: [],
...(customer?.company?.id ? { companyId: customer.company.id } : {}),
...(data.previousContractRef
? { previousContractId: data.previousContractRef }
: {}),
@@ -207,26 +207,6 @@ export default function NewBookingPage() {
createMutation.mutate(apiPayload);
});
if (createMutation.isSuccess) {
return (
<div className="flex min-h-screen items-center justify-center p-6">
<div className="w-full max-w-sm rounded-2xl border border-border bg-card p-10 text-center shadow-sm">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-emerald-100">
<CheckCircle2 className="h-7 w-7 text-emerald-600" />
</div>
<h2 className="text-xl font-bold">Contract Submitted</h2>
<p className="mt-2 text-sm text-muted-foreground">
Your request is queued for review by EDR Line Staff. You will be
notified once approved.
</p>
<p className="mt-4 font-mono text-sm font-semibold text-primary">
{createMutation.data?.reference}
</p>
</div>
</div>
);
}
return (
<form
id="new-booking-form"
@@ -245,7 +225,7 @@ export default function NewBookingPage() {
<div className="mb-6 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">Submission failed</p>
<p className="font-semibold">Failed to save draft</p>
<p className="mt-1 text-red-600">
{createMutation.error instanceof Error
? createMutation.error.message
@@ -306,9 +286,7 @@ export default function NewBookingPage() {
) : (
<Check />
)}
{createMutation.isPending
? "Submitting..."
: "Submit Contract Request"}
{createMutation.isPending ? "Saving Draft..." : "Save as Draft"}
</Button>
)}
</div>

View File

@@ -1,19 +1,6 @@
import { DeepPartial, Path } from "react-hook-form";
import * as z from "zod";
export const STATIONS = [
"Addis Ababa",
"Adama",
"Mojo",
"Awash",
"Mieso",
"Dire Dawa",
"Aysha",
"Ali Sabieh",
"Holhol",
"Djibouti City",
] as const;
export const ETHIOPIA_STATIONS = new Set<string>([
"Addis Ababa",
"Adama",
@@ -23,19 +10,6 @@ export const ETHIOPIA_STATIONS = new Set<string>([
"Dire Dawa",
]);
export const BULK_COMMODITIES = [
"Coffee",
"Beans",
"Fertilizer",
"Sugar",
"Oil",
"Livestock",
"Steel",
"Others",
] as const;
export const BREAK_BULK_TYPES = ["Machinery", "Ro-Ro", "Others"] as const;
export const MOCK_VALID_CONTRACTS = [
"EDR-2024-10001",
"EDR-2024-10002",
@@ -43,31 +17,6 @@ export const MOCK_VALID_CONTRACTS = [
"EDR-2022-55442",
];
export const CONTAINER_TYPES = [
"Dry Container",
"High Cubic",
"Reefer Container",
"Open Top",
"Flat Rack",
"Tank Container",
"Open Side",
] as const;
export const SHIPPING_LINES = [
"MSC",
"CMA CGM",
"Evergreen",
"COSCO",
"Hapag-Lloyd",
"ONE",
"Yang Ming",
"ZIM",
"Messina Line",
"Safmarine",
"Wan Hai",
"Ethiopian Shipping Lines (ESLSE)",
] as const;
export const STEPS = [
{ id: 1, label: "Contract Type", short: "Contract" },
{ id: 2, label: "Service Type & Mile", short: "Service" },
@@ -108,11 +57,8 @@ export const bookingFormSchema = z
shippingLine: z.string(),
cargoType: z.enum(["container", "bulk"], "Select a cargo type."),
cargoWeight: z.string(),
freightType: z.enum(["bulk", "break_bulk"]).optional(),
bulkCommodity: z.string(),
bulkCommodityOther: z.string(),
breakBulkType: z.string(),
breakBulkTypeOther: z.string(),
freightType: z.string(), // parent group
bulkCommoditytype: z.string(),
isHazardous: z.boolean(),
isRefrigerated: z.boolean(),
containers: z.array(
@@ -171,42 +117,10 @@ export const bookingFormSchema = z
(data) =>
!(
data.cargoType === "bulk" &&
data.freightType === "bulk" &&
!data.bulkCommodity
data.freightType &&
!data.bulkCommoditytype
),
{ message: "Select a commodity.", path: ["bulkCommodity"] },
)
.refine(
(data) =>
!(
data.cargoType === "bulk" &&
data.freightType === "bulk" &&
data.bulkCommodity === "Others" &&
!data.bulkCommodityOther.trim()
),
{ message: "Specify the commodity.", path: ["bulkCommodityOther"] },
)
.refine(
(data) =>
!(
data.cargoType === "bulk" &&
data.freightType === "break_bulk" &&
!data.breakBulkType
),
{ message: "Select a break-bulk type.", path: ["breakBulkType"] },
)
.refine(
(data) =>
!(
data.cargoType === "bulk" &&
data.freightType === "break_bulk" &&
data.breakBulkType === "Others" &&
!data.breakBulkTypeOther.trim()
),
{
message: "Specify the break-bulk type.",
path: ["breakBulkTypeOther"],
},
{ message: "Select a commodity.", path: ["bulkCommoditytype"] },
)
.refine(
(data) => {
@@ -268,10 +182,7 @@ export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
destinationYard: "",
shippingLine: "",
cargoWeight: "",
bulkCommodity: "",
bulkCommodityOther: "",
breakBulkType: "",
breakBulkTypeOther: "",
bulkCommoditytype: "",
isHazardous: false,
isRefrigerated: false,
containers: [{ type: "20ft", containerType: "", qty: "1", vgm: "" }],
@@ -300,10 +211,7 @@ export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
"cargoType",
"cargoWeight",
"freightType",
"bulkCommodity",
"bulkCommodityOther",
"breakBulkType",
"breakBulkTypeOther",
"bulkCommoditytype",
"containers",
"consolidationEnabled",
],

View File

@@ -44,8 +44,7 @@ export function Step5CargoDetails({
}) {
const cargoType = form.watch("cargoType");
const freightType = form.watch("freightType");
const bulkCommodity = form.watch("bulkCommodity");
const breakBulkType = form.watch("breakBulkType");
const bulkCommoditytype = form.watch("bulkCommoditytype");
const containers = form.watch("containers");
const { fields, append, remove } = useFieldArray({
@@ -60,13 +59,21 @@ export function Step5CargoDetails({
);
}, [referenceData]);
const bulkCommodityOptions = useMemo(() => {
const freightTypeGroups = useMemo(() => {
if (!referenceData?.cargo_type) return [];
return referenceData.cargo_type.flatMap(
(group) => group.children?.map((c) => c.name) ?? [],
return referenceData.cargo_type.filter(
(g) => g.code !== "CONTAINER",
);
}, [referenceData]);
const commodityOptions = useMemo(() => {
if (!referenceData?.cargo_type || !freightType) return [];
const group = referenceData.cargo_type.find(
(g) => g.code.toLowerCase() === freightType,
);
return group?.children?.map((c) => c.name) ?? [];
}, [referenceData, freightType]);
function getOverweightAlert(
type: "20ft" | "40ft",
vgm: number,
@@ -122,7 +129,7 @@ export function Step5CargoDetails({
selected={cargoType === "container"}
onClick={() => {
field.onChange("container");
form.setValue("freightType", undefined, {
form.setValue("freightType", "", {
shouldDirty: true,
});
}}
@@ -194,82 +201,42 @@ export function Step5CargoDetails({
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<div className="grid gap-3 sm:grid-cols-2">
<OptionCard
selected={freightType === "bulk"}
onClick={() => field.onChange("bulk")}
>
<p className="font-semibold">Bulk</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Coffee, fertilizer, grain, ore, etc.
</p>
</OptionCard>
<OptionCard
selected={freightType === "break_bulk"}
onClick={() => field.onChange("break_bulk")}
>
<p className="font-semibold">Break-Bulk</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Machinery, vehicles, project cargo, etc.
</p>
</OptionCard>
{freightTypeGroups.map((group) => {
const val = group.code.toLowerCase();
return (
<OptionCard
key={group.code}
selected={freightType === val}
onClick={() => {
field.onChange(val);
form.setValue("bulkCommoditytype", "", {
shouldDirty: true,
});
}}
>
<p className="font-semibold">{group.name}</p>
</OptionCard>
);
})}
</div>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
{freightType === "bulk" && (
{freightType && commodityOptions.length > 0 && (
<div className="space-y-2">
<Controller
name="bulkCommodity"
name="bulkCommoditytype"
control={form.control}
render={({ field, fieldState }) => (
<SelectField
field={field}
error={fieldState.error}
label="Commodity *"
placeholder="Select commodity *"
>
{bulkCommodityOptions.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectField>
)}
/>
{bulkCommodity === "Others" && (
<Controller
name="bulkCommodityOther"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<Input
{...field}
aria-invalid={fieldState.invalid}
placeholder="Specify commodity *"
/>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
)}
</div>
)}
{freightType === "break_bulk" && (
<div className="space-y-2">
<Controller
name="breakBulkType"
control={form.control}
render={({ field, fieldState }) => (
<SelectField
field={field}
error={fieldState.error}
label="Break-bulk type *"
label="Cargo type *"
placeholder="Select type *"
>
{bulkCommodityOptions.map((option) => (
{commodityOptions.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
@@ -277,22 +244,6 @@ export function Step5CargoDetails({
</SelectField>
)}
/>
{breakBulkType === "Others" && (
<Controller
name="breakBulkTypeOther"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<Input
{...field}
aria-invalid={fieldState.invalid}
placeholder="Specify break-bulk type *"
/>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
)}
</div>
)}
</div>

View File

@@ -14,6 +14,7 @@ export interface Customer {
country: string;
address: string;
notes: string;
documentsComplete: boolean;
}
const seedCustomers: Customer[] = [
@@ -30,6 +31,7 @@ const seedCustomers: Customer[] = [
country: "Ethiopia",
address: "Bole Road, Sub-City 03, Building 17",
notes: "Top-tier importer. Prefers weekly invoicing.",
documentsComplete: false,
},
{
id: 2,
@@ -44,6 +46,7 @@ const seedCustomers: Customer[] = [
country: "Ethiopia",
address: "Industrial Park, Zone B, Warehouse 4",
notes: "Awaiting compliance documents.",
documentsComplete: false,
},
{
id: 3,
@@ -58,6 +61,7 @@ const seedCustomers: Customer[] = [
country: "Djibouti",
address: "Port Quarter, Avenue 26, Block 9",
notes: "Account paused since last quarter.",
documentsComplete: true,
},
];
@@ -99,6 +103,7 @@ const generated: Customer[] = extras.map((entry, i) => {
country: entry.country,
address: `Block ${i + 1}, Street ${10 + i}, Quarter ${(i % 5) + 1}`,
notes: `Mock customer #${id}.`,
documentsComplete: i % 3 === 0,
};
});

View File

@@ -8,13 +8,16 @@ import type {
UpdateFileUploadFieldDto,
UpdateFileUploadSettingDto,
} from "@/types/fileUploadSettings";
import { bookingsService, CreateBookingPayload } from "./bookings.service";
import {
bookingsService,
CreateBookingPayload,
GeneratePriceResponse,
} from "./bookings.service";
import { consignmentsService } from "./consignments.service";
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,
@@ -24,15 +27,11 @@ import {
UpdateDropdownOptionDto,
UpdateDropdownSettingDto,
} from "@/types/dropdownSettings";
import {
CreateCustomerDto,
Customer,
UpdateCustomerDto,
} from "@/types/customers";
import type {
CompanyInfoResponse,
CreateCompanyPayload,
} from "./companies.service";
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
import type {
AuthUser,
GenerateVerificationCodePayload,
@@ -89,40 +88,6 @@ export const api = {
logout: endpoint<void, void>("auth", "logout", authService.logout),
},
customers: {
list: endpoint<void, Customer[]>(
"customers",
"list",
customersService.list,
),
get: endpoint<{ id: string }, Customer>("customers", "get", ({ id }) =>
customersService.getById(id),
),
create: endpoint<CreateCustomerDto, Customer>(
"customers",
"create",
customersService.create,
),
update: endpoint<{ id: string; dto: UpdateCustomerDto }, Customer>(
"customers",
"update",
({ id, dto }) => customersService.update(id, dto),
),
remove: endpoint<{ id: string }, void>("customers", "remove", ({ id }) =>
customersService.remove(id),
),
getByUserId: endpoint<{ id: string }, Customer | null>(
"customers",
"getByUserId",
({ id }) => customersService.getByUserId(id),
),
},
companies: {
getInfo: endpoint<void, CompanyInfoResponse | null>(
"companies",
@@ -135,6 +100,18 @@ export const api = {
"create",
companiesService.create,
),
getProfile: endpoint<void, ProfileResponse>(
"companies",
"getProfile",
companiesService.getProfile,
),
updateProfile: endpoint<UpdateProfilePayload, ProfileResponse>(
"companies",
"updateProfile",
companiesService.updateProfile,
),
},
bookings: {
@@ -165,6 +142,31 @@ export const api = {
remove: endpoint<{ id: string }, void>("bookings", "remove", ({ id }) =>
bookingsService.remove(id),
),
cancel: endpoint<{ id: string; reason: string }, Freight.IBooking>(
"bookings",
"cancel",
({ id, reason }) => bookingsService.cancel(id, reason),
),
generatePrice: endpoint<{ id: string }, GeneratePriceResponse>(
"bookings",
"generatePrice",
({ id }) => bookingsService.generatePrice(id),
),
submit: endpoint<{ id: string }, Freight.IBooking>(
"bookings",
"submit",
({ id }) => bookingsService.submit(id),
),
uploadDocuments: endpoint<
{ id: string; files: Record<string, File | File[] | null> },
Freight.IBooking
>("bookings", "uploadDocuments", ({ id, files }) =>
bookingsService.uploadDocuments(id, files),
),
},
consignments: {

View File

@@ -25,6 +25,21 @@ export interface ContractView {
}>;
}
export interface PriceLineItem {
code: string;
description: string;
amount: number;
currency: string;
}
export interface GeneratePriceResponse {
bookingId: string;
totalAmount: number;
currency: string;
lineItems: PriceLineItem[];
warnings: string[];
}
export interface SignContractPayload {
role: "CUSTOMER" | "STAFF";
signatureImageBase64: string;
@@ -53,6 +68,42 @@ export const bookingsService = {
await client.delete(`/api/bookings/${id}`);
},
cancel: async (id: string, reason: string): Promise<Freight.IBooking> => {
const { data } = await client.post(`/api/bookings/${id}/cancel`, { reason });
return data.data;
},
generatePrice: async (id: string): Promise<GeneratePriceResponse> => {
const { data } = await client.post(`/api/bookings/${id}/generate-price`);
return data.data;
},
submit: async (id: string): Promise<Freight.IBooking> => {
const { data } = await client.post(`/api/bookings/${id}/submit`);
return data.data;
},
uploadDocuments: async (
id: string,
files: Record<string, File | File[] | null>,
): Promise<Freight.IBooking> => {
const formData = new FormData();
for (const [key, fileOrFiles] of Object.entries(files)) {
if (!fileOrFiles) continue;
if (Array.isArray(fileOrFiles)) {
for (const f of fileOrFiles) formData.append(key, f);
} else {
formData.append(key, fileOrFiles);
}
}
const { data } = await client.post(
`/api/bookings/${id}/documents`,
formData,
{ headers: { "Content-Type": "multipart/form-data" } },
);
return data.data;
},
getContractView: async (id: string): Promise<ContractView> => {
const { data } = await client.get(B.CONTRACT_VIEW(id));
return data.data ?? data;

View File

@@ -2,6 +2,7 @@ import { client } from "@/utils/api";
import { unwrap } from "@/utils/endpoint";
import { URL_CONSTANTS } from "@/constants/URLS";
import type { ApiResponse } from "@/types/apiResponse";
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
import { isAxiosError } from "axios";
export interface ExternalProfileResponse {
@@ -80,4 +81,37 @@ export const companiesService = {
);
return unwrap(response.data);
},
getProfile: async (): Promise<ProfileResponse> => {
const response = await client.get<ApiResponse<ProfileResponse>>(
URL_CONSTANTS.COMPANIES_API.PROFILE,
);
return unwrap(response.data);
},
updateProfile: async (payload: UpdateProfilePayload): Promise<ProfileResponse> => {
const response = await client.patch<ApiResponse<ProfileResponse>>(
URL_CONSTANTS.COMPANIES_API.PROFILE,
payload,
);
return unwrap(response.data);
},
uploadDocuments: async (
companyId: string,
files: Record<string, File | File[] | null>,
): Promise<void> => {
const formData = new FormData();
for (const [fieldName, fileOrFiles] of Object.entries(files)) {
if (!fileOrFiles) continue;
if (Array.isArray(fileOrFiles)) {
for (const f of fileOrFiles) {
formData.append(fieldName, f);
}
} else {
formData.append(fieldName, fileOrFiles);
}
}
await client.post(URL_CONSTANTS.COMPANIES_API.DOCUMENTS(companyId), formData);
},
};

View File

@@ -1,57 +0,0 @@
import { client } from "@/utils/api";
import { unwrap } from "@/utils/endpoint";
import { URL_CONSTANTS } from "@/constants/URLS";
import type { ApiResponse } from "@/types/apiResponse";
import type {
CreateCustomerDto,
Customer,
UpdateCustomerDto,
} from "@/types/customers";
import { isAxiosError } from "axios";
const BASE = URL_CONSTANTS.CUSTOMERS_API.BASE;
export const customersService = {
list: async (): Promise<Customer[]> => {
const response = await client.get<ApiResponse<Customer[]>>(BASE);
return unwrap(response.data);
},
getById: async (id: string): Promise<Customer> => {
const response = await client.get<ApiResponse<Customer>>(
URL_CONSTANTS.CUSTOMERS_API.BY_ID(id),
);
return unwrap(response.data);
},
getByUserId: async (userId: string): Promise<Customer | null> => {
try {
const response = await client.get<ApiResponse<Customer>>(
URL_CONSTANTS.CUSTOMERS_API.BY_USER_ID(userId),
);
return unwrap(response.data);
} catch (e) {
if (isAxiosError(e) && e.response?.status === 404) {
return null;
}
throw e;
}
},
create: async (payload: CreateCustomerDto): Promise<Customer> => {
const response = await client.post<ApiResponse<Customer>>(BASE, payload);
return unwrap(response.data);
},
update: async (id: string, payload: UpdateCustomerDto): Promise<Customer> => {
const response = await client.patch<ApiResponse<Customer>>(
URL_CONSTANTS.CUSTOMERS_API.BY_ID(id),
payload,
);
return unwrap(response.data);
},
remove: async (id: string): Promise<void> => {
await client.delete(URL_CONSTANTS.CUSTOMERS_API.BY_ID(id));
},
};

View File

@@ -1,3 +0,0 @@
import { api } from "../crud";
import { URL_CONSTANTS } from "../../constants/URLS"

View File

@@ -0,0 +1,43 @@
export interface ProfileResponse {
companyId: string;
companyName: string;
companyEmail: string | null;
companyPhone: string | null;
companyLocation: string;
companyAddress: string | null;
tinNumber: string;
vatNumber: string | null;
fanNumber: string | null;
contactPersonName: string | null;
contactPersonPhone: string | null;
generalManagerName: string | null;
generalManagerEmail: string | null;
generalManagerPhone: string | null;
poaName: string | null;
poaPhone: string | null;
poaEmail: string | null;
poaLocation: string | null;
poaAddress: string | null;
profileId: string;
}
export interface UpdateProfilePayload {
companyName?: string;
companyEmail?: string;
companyPhone?: string;
companyLocation?: string;
companyAddress?: string;
tin?: string;
vatNumber?: string;
fanNumber?: string;
contactPersonName?: string;
contactPersonPhone?: string;
generalManagerName?: string;
generalManagerEmail?: string;
generalManagerPhone?: string;
poaName?: string;
poaPhone?: string;
poaEmail?: string;
poaLocation?: string;
poaAddress?: string;
}

3
pnpm-lock.yaml generated
View File

@@ -307,6 +307,9 @@ importers:
react-hook-form:
specifier: ^7.76.0
version: 7.76.0(react@19.2.6)
react-hot-toast:
specifier: ^2.6.0
version: 2.6.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-router-dom:
specifier: ^6.27.0
version: 6.30.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)

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