mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 06:28:12 +00:00
booking flow,summtion, approval, contract, mock payemnt and integration to back office, and also add permissions
This commit is contained in:
@@ -13,6 +13,11 @@ import { BookingsModule } from "./modules/bookings/bookings.module";
|
|||||||
import { FilesModule } from "./modules/files/files.module";
|
import { FilesModule } from "./modules/files/files.module";
|
||||||
import { ConsignmentsModule } from "./modules/consignments/consignments.module";
|
import { ConsignmentsModule } from "./modules/consignments/consignments.module";
|
||||||
import { TrainsModule } from "./modules/trains/trains.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 { CustomersModule } from "./modules/customers/customers.module";
|
||||||
import { CompaniesModule } from "./modules/companies/companies.module";
|
import { CompaniesModule } from "./modules/companies/companies.module";
|
||||||
import { TrackingModule } from "./modules/tracking/tracking.module";
|
import { TrackingModule } from "./modules/tracking/tracking.module";
|
||||||
@@ -32,6 +37,9 @@ import {
|
|||||||
import { EdrOrgSeeder } from "./seed/edr-org.seeder";
|
import { EdrOrgSeeder } from "./seed/edr-org.seeder";
|
||||||
import { DemoUsersSeeder } from "./seed/demo-users.seeder";
|
import { DemoUsersSeeder } from "./seed/demo-users.seeder";
|
||||||
import { FreightStaffUsersSeeder } from "./seed/freight-staff-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({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -61,6 +69,11 @@ import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder";
|
|||||||
FilesModule,
|
FilesModule,
|
||||||
ConsignmentsModule,
|
ConsignmentsModule,
|
||||||
TrainsModule,
|
TrainsModule,
|
||||||
|
LocomotivesModule,
|
||||||
|
WagonTypesModule,
|
||||||
|
TrainSetsModule,
|
||||||
|
TrainSchedulesModule,
|
||||||
|
TrainSchedulingModule,
|
||||||
CustomersModule,
|
CustomersModule,
|
||||||
CompaniesModule,
|
CompaniesModule,
|
||||||
TrackingModule,
|
TrackingModule,
|
||||||
@@ -74,7 +87,7 @@ import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder";
|
|||||||
DemoPermissionsModule,
|
DemoPermissionsModule,
|
||||||
FreightAuthModule,
|
FreightAuthModule,
|
||||||
],
|
],
|
||||||
providers: [EdrOrgSeeder, DemoUsersSeeder, FreightStaffUsersSeeder],
|
providers: [EdrOrgSeeder, DemoUsersSeeder,FreightStaffUsersSeeder, DemoBookingsSeeder, PricingDataSeeder, FileUploadSettingsSeeder],
|
||||||
})
|
})
|
||||||
export class AppModule implements OnApplicationBootstrap {
|
export class AppModule implements OnApplicationBootstrap {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -82,6 +95,9 @@ export class AppModule implements OnApplicationBootstrap {
|
|||||||
private readonly edrOrgSeeder: EdrOrgSeeder,
|
private readonly edrOrgSeeder: EdrOrgSeeder,
|
||||||
private readonly demoUsersSeeder: DemoUsersSeeder,
|
private readonly demoUsersSeeder: DemoUsersSeeder,
|
||||||
private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder,
|
private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder,
|
||||||
|
private readonly demoBookingsSeeder: DemoBookingsSeeder,
|
||||||
|
private readonly pricingDataSeeder: PricingDataSeeder,
|
||||||
|
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
async onApplicationBootstrap() {
|
async onApplicationBootstrap() {
|
||||||
@@ -89,5 +105,8 @@ export class AppModule implements OnApplicationBootstrap {
|
|||||||
await this.edrOrgSeeder.run();
|
await this.edrOrgSeeder.run();
|
||||||
await this.demoUsersSeeder.run();
|
await this.demoUsersSeeder.run();
|
||||||
await this.freightStaffUsersSeeder.run();
|
await this.freightStaffUsersSeeder.run();
|
||||||
|
await this.demoBookingsSeeder.run();
|
||||||
|
await this.pricingDataSeeder.run();
|
||||||
|
await this.fileUploadSettingsSeeder.run();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -152,6 +152,18 @@ export class BookingsController {
|
|||||||
return this.bookingsService.remove(id);
|
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')
|
@Post(':id/generate-price')
|
||||||
@ApiOperation({ summary: 'Generate price preview (DRAFT only)' })
|
@ApiOperation({ summary: 'Generate price preview (DRAFT only)' })
|
||||||
@ApiOkResponse({ type: GeneratePriceResponseDto })
|
@ApiOkResponse({ type: GeneratePriceResponseDto })
|
||||||
|
|||||||
@@ -62,6 +62,6 @@ import { ContractViewModelBuilder } from '../../contracts/contract-view-model.bu
|
|||||||
ContractRendererService,
|
ContractRendererService,
|
||||||
ContractPdfService,
|
ContractPdfService,
|
||||||
],
|
],
|
||||||
exports: [BookingsService],
|
exports: [BookingsService, BookingsRepository],
|
||||||
})
|
})
|
||||||
export class BookingsModule {}
|
export class BookingsModule {}
|
||||||
|
|||||||
@@ -502,6 +502,21 @@ export class BookingsService {
|
|||||||
return this.findById(booking.id);
|
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> {
|
async remove(id: string): Promise<void> {
|
||||||
const booking = await this.findById(id);
|
const booking = await this.findById(id);
|
||||||
if (booking.status !== 'DRAFT') {
|
if (booking.status !== 'DRAFT') {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Controller, Get, Post, Patch, Delete, Body, Param, Query, ParseUUIDPipe, HttpCode, HttpStatus } from '@nestjs/common';
|
import { Controller, Get, Post, Patch, Delete, Body, Param, Query, ParseUUIDPipe, HttpCode, HttpStatus, UseInterceptors, UploadedFiles } from '@nestjs/common';
|
||||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { AnyFilesInterceptor } from '@nestjs/platform-express';
|
||||||
|
import { ApiOperation, ApiTags, ApiConsumes } from '@nestjs/swagger';
|
||||||
import { CurrentUser } from '@edr/api-common';
|
import { CurrentUser } from '@edr/api-common';
|
||||||
|
import { FilesService } from '../files/files.service';
|
||||||
import { CompaniesService } from './companies.service';
|
import { CompaniesService } from './companies.service';
|
||||||
import { CreateCompanyDto } from './dto/create-company.dto';
|
import { CreateCompanyDto } from './dto/create-company.dto';
|
||||||
import { UpdateCompanyDto } from './dto/update-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 { ResponseExternalProfileDto } from './dto/response-external-profile.dto';
|
||||||
import { ResponseFFClientDto } from './dto/response-ff-client.dto';
|
import { ResponseFFClientDto } from './dto/response-ff-client.dto';
|
||||||
import { CompanyInfoResponseDto } from './dto/company-info-response.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 {
|
interface CurrentIamUser {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -22,7 +26,10 @@ interface CurrentIamUser {
|
|||||||
@ApiTags('Companies')
|
@ApiTags('Companies')
|
||||||
@Controller('companies')
|
@Controller('companies')
|
||||||
export class CompaniesController {
|
export class CompaniesController {
|
||||||
constructor(private readonly companiesService: CompaniesService) {}
|
constructor(
|
||||||
|
private readonly companiesService: CompaniesService,
|
||||||
|
private readonly filesService: FilesService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get('getInfo')
|
@Get('getInfo')
|
||||||
@ApiOperation({ summary: 'Get company info for the current user' })
|
@ApiOperation({ summary: 'Get company info for the current user' })
|
||||||
@@ -31,6 +38,22 @@ export class CompaniesController {
|
|||||||
return new CompanyInfoResponseDto(profile, company);
|
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')
|
@Post('create')
|
||||||
@ApiOperation({ summary: 'Create a company with its associated external profile (onboarding)' })
|
@ApiOperation({ summary: 'Create a company with its associated external profile (onboarding)' })
|
||||||
async createWithProfile(
|
async createWithProfile(
|
||||||
@@ -105,6 +128,17 @@ export class CompaniesController {
|
|||||||
await this.companiesService.deleteCompany(id);
|
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')
|
@Post(':companyId/profiles')
|
||||||
@ApiOperation({ summary: 'Add a profile (employee) to a company' })
|
@ApiOperation({ summary: 'Add a profile (employee) to a company' })
|
||||||
async createProfile(
|
async createProfile(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { FilesModule } from '../files/files.module';
|
||||||
import { CompaniesController } from './companies.controller';
|
import { CompaniesController } from './companies.controller';
|
||||||
import { CompaniesService } from './companies.service';
|
import { CompaniesService } from './companies.service';
|
||||||
import { CompaniesRepository } from './companies.repository';
|
import { CompaniesRepository } from './companies.repository';
|
||||||
@@ -10,7 +11,7 @@ import { ExternalProfile } from './entities/external-profile.entity';
|
|||||||
import { FFClient } from './entities/ff-client.entity';
|
import { FFClient } from './entities/ff-client.entity';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Company, ExternalProfile, FFClient])],
|
imports: [TypeOrmModule.forFeature([Company, ExternalProfile, FFClient]), FilesModule],
|
||||||
controllers: [CompaniesController],
|
controllers: [CompaniesController],
|
||||||
providers: [CompaniesService, CompaniesRepository, ExternalProfileRepository, FFClientRepository],
|
providers: [CompaniesService, CompaniesRepository, ExternalProfileRepository, FFClientRepository],
|
||||||
exports: [CompaniesService],
|
exports: [CompaniesService],
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { UpdateCompanyDto } from './dto/update-company.dto';
|
|||||||
import { CreateExternalProfileDto } from './dto/create-external-profile.dto';
|
import { CreateExternalProfileDto } from './dto/create-external-profile.dto';
|
||||||
import { CreateFFClientDto } from './dto/create-ff-client.dto';
|
import { CreateFFClientDto } from './dto/create-ff-client.dto';
|
||||||
import { CreateCompanyWithProfileDto } from './dto/create-company-with-profile.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 { Company } from './entities/company.entity';
|
||||||
import { ExternalProfile } from './entities/external-profile.entity';
|
import { ExternalProfile } from './entities/external-profile.entity';
|
||||||
import { FFClient } from './entities/ff-client.entity';
|
import { FFClient } from './entities/ff-client.entity';
|
||||||
@@ -103,6 +105,42 @@ export class CompaniesService {
|
|||||||
return updated;
|
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> {
|
async deleteCompany(id: string): Promise<void> {
|
||||||
await this.findCompanyById(id);
|
await this.findCompanyById(id);
|
||||||
await this.companiesRepo.softDelete(id);
|
await this.companiesRepo.softDelete(id);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
302
apps/edr-freight-api/src/seed/demo-bookings.seeder.ts
Normal file
302
apps/edr-freight-api/src/seed/demo-bookings.seeder.ts
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
125
apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts
Normal file
125
apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts
Normal 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",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
800
apps/edr-freight-api/src/seed/pricing-data.seeder.ts
Normal file
800
apps/edr-freight-api/src/seed/pricing-data.seeder.ts
Normal file
@@ -0,0 +1,800 @@
|
|||||||
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
|
import { DataSource } from "typeorm";
|
||||||
|
|
||||||
|
import { BookingCargoModifier } from "../modules/bookings/entities/booking-cargo-modifier.entity";
|
||||||
|
import { CargoType } from "../modules/rule-engine/entities/cargo-type.entity";
|
||||||
|
import { ContainerType } from "../modules/rule-engine/entities/container-type.entity";
|
||||||
|
import { PriorityRule } from "../modules/rule-engine/entities/priority-rule.entity";
|
||||||
|
import { Rate } from "../modules/rule-engine/entities/rate.entity";
|
||||||
|
import { ServiceType } from "../modules/rule-engine/entities/service-type.entity";
|
||||||
|
import { ShippingLine } from "../modules/rule-engine/entities/shipping-line.entity";
|
||||||
|
import { SurchargeType } from "../modules/rule-engine/entities/surcharge-type.entity";
|
||||||
|
import { WeightLimitRule } from "../modules/rule-engine/entities/weight-limit-rule.entity";
|
||||||
|
import { Yard } from "../modules/rule-engine/entities/yard.entity";
|
||||||
|
|
||||||
|
const STAFF_USER_ID = "00000000-0000-0000-0000-000000000001";
|
||||||
|
const CEO_USER_ID = "00000000-0000-0000-0000-000000000002";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PricingDataSeeder {
|
||||||
|
private readonly logger = new Logger(PricingDataSeeder.name);
|
||||||
|
|
||||||
|
constructor(private readonly dataSource: DataSource) { }
|
||||||
|
|
||||||
|
async run(): Promise<void> {
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
const ctRepo = manager.getRepository(ContainerType);
|
||||||
|
const stRepo = manager.getRepository(ServiceType);
|
||||||
|
const yRepo = manager.getRepository(Yard);
|
||||||
|
const slRepo = manager.getRepository(ShippingLine);
|
||||||
|
const wlRepo = manager.getRepository(WeightLimitRule);
|
||||||
|
const prRepo = manager.getRepository(PriorityRule);
|
||||||
|
const rRepo = manager.getRepository(Rate);
|
||||||
|
|
||||||
|
await this.upsertReferenceData(manager, ctRepo, stRepo, yRepo, slRepo);
|
||||||
|
await this.seedWeightLimits(wlRepo, ctRepo);
|
||||||
|
await this.seedPriorityRules(prRepo);
|
||||||
|
const containerTypes = await ctRepo.find();
|
||||||
|
const ctByCode = new Map(containerTypes.map((ct) => [ct.code, ct]));
|
||||||
|
|
||||||
|
const rates = await this.seedRates(rRepo, ctByCode);
|
||||||
|
const ratesByType = new Map<string, Rate[]>();
|
||||||
|
for (const r of rates) {
|
||||||
|
const key = `${r.rateType}|${r.currency}|${r.containerTypeId ?? ""}`;
|
||||||
|
if (!ratesByType.has(key)) ratesByType.set(key, []);
|
||||||
|
ratesByType.get(key)!.push(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.seedSurchargeTypes(manager, ratesByType);
|
||||||
|
|
||||||
|
const yards = await yRepo.find();
|
||||||
|
const yardByCode = new Map(yards.map((y) => [y.code, y]));
|
||||||
|
const serviceTypes = await stRepo.find();
|
||||||
|
const stByCode = new Map(serviceTypes.map((st) => [st.code, st]));
|
||||||
|
const shippingLines = await slRepo.find();
|
||||||
|
const slByCode = new Map(shippingLines.map((sl) => [sl.code, sl]));
|
||||||
|
const cargoTypes = await manager.getRepository(CargoType).find();
|
||||||
|
const cargoByCode = new Map(cargoTypes.map((c) => [c.code, c]));
|
||||||
|
|
||||||
|
await this.seedDraftBookings(
|
||||||
|
ctByCode,
|
||||||
|
yardByCode,
|
||||||
|
stByCode,
|
||||||
|
slByCode,
|
||||||
|
cargoByCode,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log("Seeded pricing data");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async upsertReferenceData(
|
||||||
|
manager: any,
|
||||||
|
ctRepo: any,
|
||||||
|
stRepo: any,
|
||||||
|
yRepo: any,
|
||||||
|
slRepo: any,
|
||||||
|
): Promise<void> {
|
||||||
|
await yRepo.upsert(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
code: "DJIBOUTI",
|
||||||
|
label: "Djibouti",
|
||||||
|
country: "Djibouti",
|
||||||
|
displayOrder: 1,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "ADDIS_ABABA",
|
||||||
|
label: "Addis Ababa",
|
||||||
|
country: "Ethiopia",
|
||||||
|
displayOrder: 2,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "DIRE_DAWA",
|
||||||
|
label: "Dire Dawa",
|
||||||
|
country: "Ethiopia",
|
||||||
|
displayOrder: 3,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "MODJO",
|
||||||
|
label: "Modjo",
|
||||||
|
country: "Ethiopia",
|
||||||
|
displayOrder: 4,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ conflictPaths: { code: true } },
|
||||||
|
);
|
||||||
|
|
||||||
|
await ctRepo.upsert(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
code: "20FT",
|
||||||
|
label: "20FT Standard",
|
||||||
|
sizeFt: 20,
|
||||||
|
wagonsPerUnit: 1,
|
||||||
|
isReefer: false,
|
||||||
|
isOpenTop: false,
|
||||||
|
isActive: true,
|
||||||
|
displayOrder: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "40FT",
|
||||||
|
label: "40FT Standard",
|
||||||
|
sizeFt: 40,
|
||||||
|
wagonsPerUnit: 1,
|
||||||
|
isReefer: false,
|
||||||
|
isOpenTop: false,
|
||||||
|
isActive: true,
|
||||||
|
displayOrder: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "20FT_REEFER",
|
||||||
|
label: "20FT Reefer",
|
||||||
|
sizeFt: 20,
|
||||||
|
wagonsPerUnit: 1,
|
||||||
|
isReefer: true,
|
||||||
|
isOpenTop: false,
|
||||||
|
isActive: true,
|
||||||
|
displayOrder: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "40FT_REEFER",
|
||||||
|
label: "40FT Reefer",
|
||||||
|
sizeFt: 40,
|
||||||
|
wagonsPerUnit: 1,
|
||||||
|
isReefer: true,
|
||||||
|
isOpenTop: false,
|
||||||
|
isActive: true,
|
||||||
|
displayOrder: 4,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ conflictPaths: { code: true } },
|
||||||
|
);
|
||||||
|
|
||||||
|
await stRepo.upsert(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
code: "RAIL_CONTAINER",
|
||||||
|
serviceName: "Rail Container Service",
|
||||||
|
description: "Standard rail container transport",
|
||||||
|
canBeBookedAlone: true,
|
||||||
|
includesFirstMile: false,
|
||||||
|
includesLastMile: false,
|
||||||
|
includesCustoms: false,
|
||||||
|
priorityBonusPoints: 0,
|
||||||
|
isActive: true,
|
||||||
|
displayOrder: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "RAIL_FORWARDING",
|
||||||
|
serviceName: "Rail Forwarding Service",
|
||||||
|
description: "Rail transport with first/last mile and customs",
|
||||||
|
canBeBookedAlone: true,
|
||||||
|
includesFirstMile: true,
|
||||||
|
includesLastMile: true,
|
||||||
|
includesCustoms: true,
|
||||||
|
priorityBonusPoints: 100,
|
||||||
|
isActive: true,
|
||||||
|
displayOrder: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "RAIL_BULK",
|
||||||
|
serviceName: "Rail Bulk Transport",
|
||||||
|
description: "Bulk commodity rail transport",
|
||||||
|
canBeBookedAlone: true,
|
||||||
|
includesFirstMile: false,
|
||||||
|
includesLastMile: false,
|
||||||
|
includesCustoms: false,
|
||||||
|
priorityBonusPoints: 50,
|
||||||
|
isActive: true,
|
||||||
|
displayOrder: 3,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ conflictPaths: { code: true } },
|
||||||
|
);
|
||||||
|
|
||||||
|
await slRepo.upsert(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
code: "MAERSK",
|
||||||
|
label: "Maersk Line",
|
||||||
|
mappedToCode: "MAERSK",
|
||||||
|
showExtraFeeNotice: true,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "MSC",
|
||||||
|
label: "MSC",
|
||||||
|
mappedToCode: "MSC",
|
||||||
|
showExtraFeeNotice: true,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "CMA_CGM",
|
||||||
|
label: "CMA CGM",
|
||||||
|
mappedToCode: "CMA_CGM",
|
||||||
|
showExtraFeeNotice: true,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "COSCO",
|
||||||
|
label: "COSCO Shipping",
|
||||||
|
mappedToCode: "COSCO",
|
||||||
|
showExtraFeeNotice: true,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "OTHER",
|
||||||
|
label: "Other Line",
|
||||||
|
mappedToCode: null,
|
||||||
|
showExtraFeeNotice: false,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ conflictPaths: { code: true } },
|
||||||
|
);
|
||||||
|
|
||||||
|
await manager.getRepository(CargoType).upsert(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
code: "GRAIN",
|
||||||
|
cargoTypeName: "Grain / Cereals",
|
||||||
|
requiresDirectorApproval: false,
|
||||||
|
isActive: true,
|
||||||
|
displayOrder: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "FERTILIZER",
|
||||||
|
cargoTypeName: "Fertilizer",
|
||||||
|
requiresDirectorApproval: false,
|
||||||
|
isActive: true,
|
||||||
|
displayOrder: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "CEMENT",
|
||||||
|
cargoTypeName: "Cement / Clinker",
|
||||||
|
requiresDirectorApproval: false,
|
||||||
|
isActive: true,
|
||||||
|
displayOrder: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "STEEL",
|
||||||
|
cargoTypeName: "Steel / Rebar",
|
||||||
|
requiresDirectorApproval: true,
|
||||||
|
isActive: true,
|
||||||
|
displayOrder: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "MACHINERY",
|
||||||
|
cargoTypeName: "Heavy Machinery",
|
||||||
|
requiresDirectorApproval: true,
|
||||||
|
isActive: true,
|
||||||
|
displayOrder: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "OTHER_BULK",
|
||||||
|
cargoTypeName: "Other Bulk Cargo",
|
||||||
|
requiresDirectorApproval: false,
|
||||||
|
isActive: true,
|
||||||
|
displayOrder: 6,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ conflictPaths: { code: true } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
|
||||||
|
await wlRepo.createQueryBuilder().delete().execute();
|
||||||
|
const twenty = await ctRepo.findOneByOrFail({ code: "20FT" });
|
||||||
|
const forty = await ctRepo.findOneByOrFail({ code: "40FT" });
|
||||||
|
const base = new Date("2026-01-01");
|
||||||
|
await wlRepo.insert([
|
||||||
|
{
|
||||||
|
containerTypeId: twenty.id,
|
||||||
|
tradeDirection: "IMPORT",
|
||||||
|
maxVgmTons: 26,
|
||||||
|
effectiveFrom: base,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
containerTypeId: twenty.id,
|
||||||
|
tradeDirection: "EXPORT",
|
||||||
|
maxVgmTons: 26,
|
||||||
|
effectiveFrom: base,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
containerTypeId: forty.id,
|
||||||
|
tradeDirection: "IMPORT",
|
||||||
|
maxVgmTons: 28,
|
||||||
|
effectiveFrom: base,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
containerTypeId: forty.id,
|
||||||
|
tradeDirection: "EXPORT",
|
||||||
|
maxVgmTons: 28,
|
||||||
|
effectiveFrom: base,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
this.logger.log("Seeded weight limit rules");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async seedPriorityRules(prRepo: any): Promise<void> {
|
||||||
|
const existing = await prRepo.find({
|
||||||
|
where: [{ code: "USD_PRIORITY" }, { code: "STANDARD_PRIORITY" }],
|
||||||
|
});
|
||||||
|
for (const r of existing) {
|
||||||
|
await prRepo.remove(r);
|
||||||
|
}
|
||||||
|
await prRepo.save([
|
||||||
|
prRepo.create({
|
||||||
|
code: "USD_PRIORITY",
|
||||||
|
label: "USD Payment Priority",
|
||||||
|
score: 200,
|
||||||
|
conditionCurrency: "USD",
|
||||||
|
isActive: true,
|
||||||
|
}),
|
||||||
|
prRepo.create({
|
||||||
|
code: "STANDARD_PRIORITY",
|
||||||
|
label: "Standard Priority",
|
||||||
|
score: 50,
|
||||||
|
conditionCurrency: null,
|
||||||
|
isActive: true,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
this.logger.log("Seeded priority rules");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async seedRates(
|
||||||
|
rRepo: any,
|
||||||
|
ctByCode: Map<string, any>,
|
||||||
|
): Promise<Rate[]> {
|
||||||
|
const effectiveFrom = new Date("2026-01-01");
|
||||||
|
const now = new Date();
|
||||||
|
const rateData = [
|
||||||
|
{
|
||||||
|
rateType: "CONTAINER_IMPORT",
|
||||||
|
containerTypeId: ctByCode.get("20FT")!.id,
|
||||||
|
currency: "USD",
|
||||||
|
rateValue: 800,
|
||||||
|
rateUnit: "PER_CONTAINER",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rateType: "CONTAINER_IMPORT",
|
||||||
|
containerTypeId: ctByCode.get("40FT")!.id,
|
||||||
|
currency: "USD",
|
||||||
|
rateValue: 1200,
|
||||||
|
rateUnit: "PER_CONTAINER",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rateType: "CONTAINER_IMPORT",
|
||||||
|
containerTypeId: ctByCode.get("20FT")!.id,
|
||||||
|
currency: "ETB",
|
||||||
|
rateValue: 45000,
|
||||||
|
rateUnit: "PER_CONTAINER",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rateType: "CONTAINER_IMPORT",
|
||||||
|
containerTypeId: ctByCode.get("40FT")!.id,
|
||||||
|
currency: "ETB",
|
||||||
|
rateValue: 67000,
|
||||||
|
rateUnit: "PER_CONTAINER",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rateType: "CONTAINER_EXPORT",
|
||||||
|
containerTypeId: ctByCode.get("20FT")!.id,
|
||||||
|
currency: "USD",
|
||||||
|
rateValue: 600,
|
||||||
|
rateUnit: "PER_CONTAINER",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rateType: "CONTAINER_EXPORT",
|
||||||
|
containerTypeId: ctByCode.get("40FT")!.id,
|
||||||
|
currency: "USD",
|
||||||
|
rateValue: 900,
|
||||||
|
rateUnit: "PER_CONTAINER",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rateType: "CONTAINER_EXPORT",
|
||||||
|
containerTypeId: ctByCode.get("20FT")!.id,
|
||||||
|
currency: "ETB",
|
||||||
|
rateValue: 34000,
|
||||||
|
rateUnit: "PER_CONTAINER",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rateType: "CONTAINER_EXPORT",
|
||||||
|
containerTypeId: ctByCode.get("40FT")!.id,
|
||||||
|
currency: "ETB",
|
||||||
|
rateValue: 50000,
|
||||||
|
rateUnit: "PER_CONTAINER",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rateType: "INTERCITY_CONTAINER",
|
||||||
|
containerTypeId: ctByCode.get("20FT")!.id,
|
||||||
|
currency: "ETB",
|
||||||
|
rateValue: 20000,
|
||||||
|
rateUnit: "PER_CONTAINER",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rateType: "INTERCITY_CONTAINER",
|
||||||
|
containerTypeId: ctByCode.get("40FT")!.id,
|
||||||
|
currency: "ETB",
|
||||||
|
rateValue: 30000,
|
||||||
|
rateUnit: "PER_CONTAINER",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rateType: "CONTAINER_IMPORT",
|
||||||
|
containerTypeId: null,
|
||||||
|
currency: "USD",
|
||||||
|
rateValue: 1000,
|
||||||
|
rateUnit: "PER_CONTAINER",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rateType: "CONTAINER_IMPORT",
|
||||||
|
containerTypeId: null,
|
||||||
|
currency: "ETB",
|
||||||
|
rateValue: 56000,
|
||||||
|
rateUnit: "PER_CONTAINER",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rateType: "CONTAINER_EXPORT",
|
||||||
|
containerTypeId: null,
|
||||||
|
currency: "USD",
|
||||||
|
rateValue: 750,
|
||||||
|
rateUnit: "PER_CONTAINER",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rateType: "CONTAINER_EXPORT",
|
||||||
|
containerTypeId: null,
|
||||||
|
currency: "ETB",
|
||||||
|
rateValue: 42000,
|
||||||
|
rateUnit: "PER_CONTAINER",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rateType: "INTERCITY_CONTAINER",
|
||||||
|
containerTypeId: null,
|
||||||
|
currency: "ETB",
|
||||||
|
rateValue: 25000,
|
||||||
|
rateUnit: "PER_CONTAINER",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rateType: "BULK_IMPORT",
|
||||||
|
containerTypeId: null,
|
||||||
|
currency: "USD",
|
||||||
|
rateValue: 50,
|
||||||
|
rateUnit: "PER_TON",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rateType: "BULK_IMPORT",
|
||||||
|
containerTypeId: null,
|
||||||
|
currency: "ETB",
|
||||||
|
rateValue: 2800,
|
||||||
|
rateUnit: "PER_TON",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rateType: "BULK_EXPORT",
|
||||||
|
containerTypeId: null,
|
||||||
|
currency: "USD",
|
||||||
|
rateValue: 40,
|
||||||
|
rateUnit: "PER_TON",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rateType: "BULK_EXPORT",
|
||||||
|
containerTypeId: null,
|
||||||
|
currency: "ETB",
|
||||||
|
rateValue: 2200,
|
||||||
|
rateUnit: "PER_TON",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rateType: "OVERWEIGHT_PER_TON",
|
||||||
|
containerTypeId: null,
|
||||||
|
currency: "USD",
|
||||||
|
rateValue: 25,
|
||||||
|
rateUnit: "PER_TON",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rateType: "OVERWEIGHT_PER_TON",
|
||||||
|
containerTypeId: null,
|
||||||
|
currency: "ETB",
|
||||||
|
rateValue: 1400,
|
||||||
|
rateUnit: "PER_TON",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rateType: "HAZARD_SURCHARGE",
|
||||||
|
containerTypeId: null,
|
||||||
|
currency: "USD",
|
||||||
|
rateValue: 150,
|
||||||
|
rateUnit: "FLAT",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rateType: "HAZARD_SURCHARGE",
|
||||||
|
containerTypeId: null,
|
||||||
|
currency: "ETB",
|
||||||
|
rateValue: 8500,
|
||||||
|
rateUnit: "FLAT",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rateType: "REEFER_SURCHARGE",
|
||||||
|
containerTypeId: null,
|
||||||
|
currency: "USD",
|
||||||
|
rateValue: 200,
|
||||||
|
rateUnit: "FLAT",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rateType: "REEFER_SURCHARGE",
|
||||||
|
containerTypeId: null,
|
||||||
|
currency: "ETB",
|
||||||
|
rateValue: 11000,
|
||||||
|
rateUnit: "FLAT",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rateType: "DOUBLE_HANDLING",
|
||||||
|
containerTypeId: null,
|
||||||
|
currency: "USD",
|
||||||
|
rateValue: 100,
|
||||||
|
rateUnit: "PER_CONTAINER",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rateType: "DOUBLE_HANDLING",
|
||||||
|
containerTypeId: null,
|
||||||
|
currency: "ETB",
|
||||||
|
rateValue: 5500,
|
||||||
|
rateUnit: "PER_CONTAINER",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rateType: "LASHING",
|
||||||
|
containerTypeId: null,
|
||||||
|
currency: "USD",
|
||||||
|
rateValue: 50,
|
||||||
|
rateUnit: "PER_CONTAINER",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rateType: "LASHING",
|
||||||
|
containerTypeId: null,
|
||||||
|
currency: "ETB",
|
||||||
|
rateValue: 2800,
|
||||||
|
rateUnit: "PER_CONTAINER",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const entities = rateData.map((d) =>
|
||||||
|
rRepo.create({
|
||||||
|
...d,
|
||||||
|
status: "LIVE",
|
||||||
|
proposedByStaffId: STAFF_USER_ID,
|
||||||
|
approvedByCeoId: CEO_USER_ID,
|
||||||
|
approvedAt: now,
|
||||||
|
effectiveFrom,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return rRepo.save(entities);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async seedSurchargeTypes(
|
||||||
|
manager: any,
|
||||||
|
ratesByType: Map<string, Rate[]>,
|
||||||
|
): Promise<void> {
|
||||||
|
const surRepo = manager.getRepository(SurchargeType);
|
||||||
|
const bcmRepo = manager.getRepository(BookingCargoModifier);
|
||||||
|
await bcmRepo.createQueryBuilder().delete().execute();
|
||||||
|
const findRate = (rateType: string, currency: string) => {
|
||||||
|
const key = `${rateType}|${currency}|`;
|
||||||
|
const rates = ratesByType.get(key);
|
||||||
|
return rates?.[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
const hazardRateUsd = findRate("HAZARD_SURCHARGE", "USD");
|
||||||
|
const hazardRateEtb = findRate("HAZARD_SURCHARGE", "ETB");
|
||||||
|
const reeferRateUsd = findRate("REEFER_SURCHARGE", "USD");
|
||||||
|
const reeferRateEtb = findRate("REEFER_SURCHARGE", "ETB");
|
||||||
|
const overweightRateUsd = findRate("OVERWEIGHT_PER_TON", "USD");
|
||||||
|
const overweightRateEtb = findRate("OVERWEIGHT_PER_TON", "ETB");
|
||||||
|
const shipLineRateUsd = findRate("DOUBLE_HANDLING", "USD");
|
||||||
|
const shipLineRateEtb = findRate("DOUBLE_HANDLING", "ETB");
|
||||||
|
const consolidRateUsd = findRate("LASHING", "USD");
|
||||||
|
const consolidRateEtb = findRate("LASHING", "ETB");
|
||||||
|
|
||||||
|
await surRepo.createQueryBuilder().delete().execute();
|
||||||
|
await surRepo.save([
|
||||||
|
surRepo.create({
|
||||||
|
code: "HAZARDOUS_CARGO",
|
||||||
|
label: "Hazardous Cargo",
|
||||||
|
triggerCondition: "CARGO_FLAG_HAZARDOUS",
|
||||||
|
rateId: hazardRateUsd?.id ?? hazardRateEtb?.id,
|
||||||
|
isActive: true,
|
||||||
|
}),
|
||||||
|
surRepo.create({
|
||||||
|
code: "REEFER_CARGO",
|
||||||
|
label: "Reefer Cargo",
|
||||||
|
triggerCondition: "CARGO_FLAG_REEFER",
|
||||||
|
rateId: reeferRateUsd?.id ?? reeferRateEtb?.id,
|
||||||
|
isActive: true,
|
||||||
|
}),
|
||||||
|
surRepo.create({
|
||||||
|
code: "OVERWEIGHT_CARGO",
|
||||||
|
label: "Overweight Cargo",
|
||||||
|
triggerCondition: "VGM_EXCEEDS_LIMIT",
|
||||||
|
rateId: overweightRateUsd?.id ?? overweightRateEtb?.id,
|
||||||
|
isActive: true,
|
||||||
|
}),
|
||||||
|
surRepo.create({
|
||||||
|
code: "SHIPPING_LINE_FEE",
|
||||||
|
label: "Shipping Line Fee",
|
||||||
|
triggerCondition: "SHIPPING_LINE_MAPPED",
|
||||||
|
rateId: shipLineRateUsd?.id ?? shipLineRateEtb?.id,
|
||||||
|
isActive: true,
|
||||||
|
}),
|
||||||
|
surRepo.create({
|
||||||
|
code: "CONSOLIDATION_FEE",
|
||||||
|
label: "Consolidation Fee",
|
||||||
|
triggerCondition: "CONSOLIDATION_ENABLED",
|
||||||
|
rateId: consolidRateUsd?.id ?? consolidRateEtb?.id,
|
||||||
|
isActive: true,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
this.logger.log("Seeded surcharge types");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async seedDraftBookings(
|
||||||
|
ctByCode: Map<string, any>,
|
||||||
|
yardByCode: Map<string, any>,
|
||||||
|
stByCode: Map<string, any>,
|
||||||
|
slByCode: Map<string, any>,
|
||||||
|
cargoByCode: Map<string, any>,
|
||||||
|
): Promise<void> {
|
||||||
|
const djibouti = yardByCode.get("DJIBOUTI")!;
|
||||||
|
const addis = yardByCode.get("ADDIS_ABABA")!;
|
||||||
|
const railContainer = stByCode.get("RAIL_CONTAINER")!;
|
||||||
|
const railBulk = stByCode.get("RAIL_BULK")!;
|
||||||
|
const maersk = slByCode.get("MAERSK")!;
|
||||||
|
const grain = cargoByCode.get("GRAIN")!;
|
||||||
|
const twenty = ctByCode.get("20FT")!;
|
||||||
|
const forty = ctByCode.get("40FT")!;
|
||||||
|
const twentyReefer = ctByCode.get("20FT_REEFER")!;
|
||||||
|
|
||||||
|
const drafts = [
|
||||||
|
{
|
||||||
|
reference: "BKG-PRICE-001",
|
||||||
|
description: "Standard 20FT container import — base rail only",
|
||||||
|
freightType: "CONTAINER" as const,
|
||||||
|
tradeDirection: "IMPORT",
|
||||||
|
paymentCurrency: "USD",
|
||||||
|
serviceTypeId: railContainer.id,
|
||||||
|
originYardId: djibouti.id,
|
||||||
|
destinationYardId: addis.id,
|
||||||
|
isHazardous: false,
|
||||||
|
allowConsolidation: false,
|
||||||
|
shippingLineId: null,
|
||||||
|
cargoTypeId: null,
|
||||||
|
cargoTotalWeightVgm: 250,
|
||||||
|
containers: [
|
||||||
|
{ containerTypeId: twenty.id, quantity: 10, vgmPerUnitTons: 25 },
|
||||||
|
],
|
||||||
|
expectedBaseRate: 800,
|
||||||
|
expectedSurcharges: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reference: "BKG-PRICE-002",
|
||||||
|
description: "40FT container import + hazardous surcharge",
|
||||||
|
freightType: "CONTAINER" as const,
|
||||||
|
tradeDirection: "IMPORT",
|
||||||
|
paymentCurrency: "USD",
|
||||||
|
serviceTypeId: railContainer.id,
|
||||||
|
originYardId: djibouti.id,
|
||||||
|
destinationYardId: addis.id,
|
||||||
|
isHazardous: true,
|
||||||
|
allowConsolidation: false,
|
||||||
|
shippingLineId: null,
|
||||||
|
cargoTypeId: null,
|
||||||
|
cargoTotalWeightVgm: 135,
|
||||||
|
containers: [
|
||||||
|
{ containerTypeId: forty.id, quantity: 5, vgmPerUnitTons: 27 },
|
||||||
|
],
|
||||||
|
expectedBaseRate: 1200,
|
||||||
|
expectedSurcharges: ["HAZARDOUS_CARGO"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reference: "BKG-PRICE-003",
|
||||||
|
description: "20FT container import + shipping line (ETB)",
|
||||||
|
freightType: "CONTAINER" as const,
|
||||||
|
tradeDirection: "IMPORT",
|
||||||
|
paymentCurrency: "ETB",
|
||||||
|
serviceTypeId: railContainer.id,
|
||||||
|
originYardId: djibouti.id,
|
||||||
|
destinationYardId: addis.id,
|
||||||
|
isHazardous: false,
|
||||||
|
allowConsolidation: false,
|
||||||
|
shippingLineId: maersk.id,
|
||||||
|
cargoTypeId: null,
|
||||||
|
cargoTotalWeightVgm: 480,
|
||||||
|
containers: [
|
||||||
|
{ containerTypeId: twenty.id, quantity: 20, vgmPerUnitTons: 24 },
|
||||||
|
],
|
||||||
|
expectedBaseRate: 45000,
|
||||||
|
expectedSurcharges: ["SHIPPING_LINE_FEE"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reference: "BKG-PRICE-004",
|
||||||
|
description: "40FT container import + consolidation (USD)",
|
||||||
|
freightType: "CONTAINER" as const,
|
||||||
|
tradeDirection: "IMPORT",
|
||||||
|
paymentCurrency: "USD",
|
||||||
|
serviceTypeId: railContainer.id,
|
||||||
|
originYardId: djibouti.id,
|
||||||
|
destinationYardId: addis.id,
|
||||||
|
isHazardous: false,
|
||||||
|
allowConsolidation: true,
|
||||||
|
shippingLineId: null,
|
||||||
|
cargoTypeId: null,
|
||||||
|
cargoTotalWeightVgm: 224,
|
||||||
|
containers: [
|
||||||
|
{ containerTypeId: forty.id, quantity: 8, vgmPerUnitTons: 28 },
|
||||||
|
],
|
||||||
|
expectedBaseRate: 1200,
|
||||||
|
expectedSurcharges: ["CONSOLIDATION_FEE"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reference: "BKG-PRICE-005",
|
||||||
|
description: "Bulk import — grain",
|
||||||
|
freightType: "BULK" as const,
|
||||||
|
tradeDirection: "IMPORT",
|
||||||
|
paymentCurrency: "USD",
|
||||||
|
serviceTypeId: railBulk.id,
|
||||||
|
originYardId: djibouti.id,
|
||||||
|
destinationYardId: addis.id,
|
||||||
|
isHazardous: false,
|
||||||
|
allowConsolidation: false,
|
||||||
|
shippingLineId: null,
|
||||||
|
cargoTypeId: grain.id,
|
||||||
|
cargoTotalWeightVgm: 500,
|
||||||
|
containers: [],
|
||||||
|
expectedBaseRate: 50,
|
||||||
|
expectedSurcharges: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reference: "BKG-PRICE-006",
|
||||||
|
description: "20FT reefer container import + reefer surcharge",
|
||||||
|
freightType: "CONTAINER" as const,
|
||||||
|
tradeDirection: "IMPORT",
|
||||||
|
paymentCurrency: "USD",
|
||||||
|
serviceTypeId: railContainer.id,
|
||||||
|
originYardId: djibouti.id,
|
||||||
|
destinationYardId: addis.id,
|
||||||
|
isHazardous: false,
|
||||||
|
allowConsolidation: false,
|
||||||
|
shippingLineId: null,
|
||||||
|
cargoTypeId: null,
|
||||||
|
cargoTotalWeightVgm: 75,
|
||||||
|
containers: [
|
||||||
|
{ containerTypeId: twentyReefer.id, quantity: 3, vgmPerUnitTons: 25 },
|
||||||
|
],
|
||||||
|
expectedBaseRate: 800,
|
||||||
|
expectedSurcharges: ["REEFER_CARGO"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reference: "BKG-PRICE-007",
|
||||||
|
description: "20FT container import + overweight (30t > 26t limit)",
|
||||||
|
freightType: "CONTAINER" as const,
|
||||||
|
tradeDirection: "IMPORT",
|
||||||
|
paymentCurrency: "USD",
|
||||||
|
serviceTypeId: railContainer.id,
|
||||||
|
originYardId: djibouti.id,
|
||||||
|
destinationYardId: addis.id,
|
||||||
|
isHazardous: false,
|
||||||
|
allowConsolidation: false,
|
||||||
|
shippingLineId: null,
|
||||||
|
cargoTypeId: null,
|
||||||
|
cargoTotalWeightVgm: 300,
|
||||||
|
containers: [
|
||||||
|
{ containerTypeId: twenty.id, quantity: 10, vgmPerUnitTons: 30 },
|
||||||
|
],
|
||||||
|
expectedBaseRate: 800,
|
||||||
|
expectedSurcharges: ["OVERWEIGHT_CARGO"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
this.logger.log(`Seeded ${drafts.length} DRAFT bookings for pricing`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Paperclip,
|
Paperclip,
|
||||||
Settings,
|
Settings,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
|
TrainTrack,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@/components/layout";
|
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 FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
|
||||||
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
|
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
|
||||||
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
|
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
|
||||||
|
<<<<<<< HEAD
|
||||||
import {
|
import {
|
||||||
getCategorySidebarChildren,
|
getCategorySidebarChildren,
|
||||||
RULE_ENGINE_RESOURCES,
|
RULE_ENGINE_RESOURCES,
|
||||||
@@ -42,6 +44,97 @@ import {
|
|||||||
import type { RuleEngineResourceSlug } from "./types/rule-engine";
|
import type { RuleEngineResourceSlug } from "./types/rule-engine";
|
||||||
|
|
||||||
const filterRuleEngineChildren = (
|
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"],
|
user: ReturnType<typeof useAuth>["user"],
|
||||||
category: RuleEngineNavCategory,
|
category: RuleEngineNavCategory,
|
||||||
): SidebarItem[] =>
|
): SidebarItem[] =>
|
||||||
@@ -202,6 +295,7 @@ const App = () => {
|
|||||||
<Route path="/dashboard" element={<DashboardShell />}>
|
<Route path="/dashboard" element={<DashboardShell />}>
|
||||||
<Route path="overview" element={<OverviewPage />} />
|
<Route path="overview" element={<OverviewPage />} />
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
<Route
|
<Route
|
||||||
path="booking-requests"
|
path="booking-requests"
|
||||||
element={
|
element={
|
||||||
@@ -226,6 +320,15 @@ const App = () => {
|
|||||||
</PermissionRoute>
|
</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" element={<UserManagementPage />} />
|
||||||
<Route path="user-management/users" element={<UsersPage />} />
|
<Route path="user-management/users" element={<UsersPage />} />
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { BookingListFilter } from "@/services/bookings.service";
|
import type { BookingListFilter } from "@/services/bookings.service";
|
||||||
import type { RuleEngineListParams } from "@/services/ruleEngine/ruleEngine.service";
|
import type { RuleEngineListParams } from "@/services/ruleEngine/ruleEngine.service";
|
||||||
|
import type { TrainScheduleFilters } from "@/types/trainScheduling";
|
||||||
import type { RuleEngineResourceSlug } from "@/types/rule-engine";
|
import type { RuleEngineResourceSlug } from "@/types/rule-engine";
|
||||||
|
|
||||||
export const QUERY_KEYS = {
|
export const QUERY_KEYS = {
|
||||||
@@ -37,6 +38,16 @@ export const QUERY_KEYS = {
|
|||||||
byId: (id: string) => ["bookings", "detail", id] as const,
|
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: {
|
RULE_ENGINE: {
|
||||||
ROOT: ["rule-engine"] as const,
|
ROOT: ["rule-engine"] as const,
|
||||||
list: (resource: RuleEngineResourceSlug | string, params?: RuleEngineListParams) =>
|
list: (resource: RuleEngineResourceSlug | string, params?: RuleEngineListParams) =>
|
||||||
|
|||||||
@@ -108,6 +108,19 @@ export const URL_CONSTANTS = {
|
|||||||
VERIFY: "/api/otp/verify",
|
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: {
|
RULE_ENGINE: {
|
||||||
CARGO_TYPES: "/cargo-types",
|
CARGO_TYPES: "/cargo-types",
|
||||||
CARGO_TYPE_BY_ID: (id: string) => `/cargo-types/${id}`,
|
CARGO_TYPE_BY_ID: (id: string) => `/cargo-types/${id}`,
|
||||||
|
|||||||
@@ -76,7 +76,9 @@ export const useContainerTypeOptions = (
|
|||||||
enabled = true,
|
enabled = true,
|
||||||
) =>
|
) =>
|
||||||
useQuery({
|
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,
|
includeNone,
|
||||||
}),
|
}),
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
|
|||||||
@@ -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 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 (
|
return (
|
||||||
<FeaturePlaceholder
|
<div className="space-y-6 p-6">
|
||||||
title="Trains"
|
<Breadcrumbs items={[{ label: 'Operations' }, { label: 'Train scheduling' }]} />
|
||||||
description="Coordinate train assignments, scheduling visibility, and operational readiness."
|
|
||||||
/>
|
<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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 ?? [];
|
||||||
|
},
|
||||||
|
};
|
||||||
154
apps/edr-freight-web/backoffice/src/types/trainScheduling.ts
Normal file
154
apps/edr-freight-web/backoffice/src/types/trainScheduling.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -50,9 +50,6 @@ export function endpoint<TInput, TResponse>(
|
|||||||
if (queryKeyBuilder && input !== undefined) {
|
if (queryKeyBuilder && input !== undefined) {
|
||||||
return queryKeyBuilder(input as TInput);
|
return queryKeyBuilder(input as TInput);
|
||||||
}
|
}
|
||||||
if (queryKeyBuilder && input === undefined) {
|
|
||||||
return queryKeyBuilder(undefined as TInput);
|
|
||||||
}
|
|
||||||
return input === undefined
|
return input === undefined
|
||||||
? [service, action]
|
? [service, action]
|
||||||
: [service, action, input];
|
: [service, action, input];
|
||||||
@@ -124,4 +121,4 @@ export function unwrap<T>(response: { data: T } | T): T {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return response as T;
|
return response as T;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
"react": "19.2.6",
|
"react": "19.2.6",
|
||||||
"react-dom": "19.2.6",
|
"react-dom": "19.2.6",
|
||||||
"react-hook-form": "^7.76.0",
|
"react-hook-form": "^7.76.0",
|
||||||
|
"react-hot-toast": "^2.6.0",
|
||||||
"react-router-dom": "^6.27.0",
|
"react-router-dom": "^6.27.0",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
|
|||||||
@@ -14,11 +14,13 @@ import {
|
|||||||
Home,
|
Home,
|
||||||
Loader2,
|
Loader2,
|
||||||
User,
|
User,
|
||||||
|
Settings,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import useAuth from "./hooks/useAuth";
|
import useAuth from "./hooks/useAuth";
|
||||||
|
|
||||||
import ProfilePage from "./pages/ProfilePage";
|
import ProfilePage from "./pages/ProfilePage";
|
||||||
|
import SettingsPage from "./pages/SettingsPage";
|
||||||
import MyPortalPage from "./pages/MyPortalPage";
|
import MyPortalPage from "./pages/MyPortalPage";
|
||||||
import EDRFreightLandingPage from "./pages/EDRFreightLandingPage";
|
import EDRFreightLandingPage from "./pages/EDRFreightLandingPage";
|
||||||
import SignupPage from "./pages/accounts/SignupPage";
|
import SignupPage from "./pages/accounts/SignupPage";
|
||||||
@@ -40,12 +42,14 @@ const sidebarItems: SidebarItem[] = [
|
|||||||
{ label: "Tracking", href: "/tracking", icon: <MapPin /> },
|
{ label: "Tracking", href: "/tracking", icon: <MapPin /> },
|
||||||
{ label: "Billing", href: "/billing", icon: <Receipt /> },
|
{ label: "Billing", href: "/billing", icon: <Receipt /> },
|
||||||
{ label: "Profile", href: "/profile", icon: <User /> },
|
{ label: "Profile", href: "/profile", icon: <User /> },
|
||||||
|
{ label: "Settings", href: "/settings", icon: <Settings /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { user, isPending, logout, customer, customerQuery } = useAuth();
|
const { user, isPending, logout, customer, customerQuery } = useAuth();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isPending) return;
|
if (isPending) return;
|
||||||
const isInProtectedRoutes = sidebarItems.find((item) =>
|
const isInProtectedRoutes = sidebarItems.find((item) =>
|
||||||
@@ -107,6 +111,7 @@ const App = () => {
|
|||||||
<Route path="/tracking" element={<TrackingPage />} />
|
<Route path="/tracking" element={<TrackingPage />} />
|
||||||
<Route path="/billing" element={<BillingPage />} />
|
<Route path="/billing" element={<BillingPage />} />
|
||||||
<Route path="/profile" element={<ProfilePage />} />
|
<Route path="/profile" element={<ProfilePage />} />
|
||||||
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -82,6 +82,8 @@ export const URL_CONSTANTS = {
|
|||||||
COMPANIES_API: {
|
COMPANIES_API: {
|
||||||
GET_INFO: "/api/companies/getInfo",
|
GET_INFO: "/api/companies/getInfo",
|
||||||
CREATE: "/api/companies/create",
|
CREATE: "/api/companies/create",
|
||||||
|
PROFILE: "/api/companies/profile",
|
||||||
|
DOCUMENTS: (id: string) => `/api/companies/${id}/documents`,
|
||||||
},
|
},
|
||||||
|
|
||||||
BOOKINGS: {
|
BOOKINGS: {
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ const useAuth = () => {
|
|||||||
|
|
||||||
const authQuery = useQuery(
|
const authQuery = useQuery(
|
||||||
api.auth.getMyInfo.queryOptions({
|
api.auth.getMyInfo.queryOptions({
|
||||||
enabled: !!getCookie("auth-token"),
|
|
||||||
retry: false,
|
retry: false,
|
||||||
staleTime: 10 * 60 * 1000,
|
staleTime: 10 * 60 * 1000,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
Plus,
|
Plus,
|
||||||
Receipt,
|
Receipt,
|
||||||
Truck,
|
Truck,
|
||||||
|
UploadCloud,
|
||||||
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -48,6 +50,7 @@ export default function MyPortalPage() {
|
|||||||
const outstandingInvoices = myInvoices.filter(
|
const outstandingInvoices = myInvoices.filter(
|
||||||
(inv) => inv.status === "Sent" || inv.status === "Overdue",
|
(inv) => inv.status === "Sent" || inv.status === "Overdue",
|
||||||
);
|
);
|
||||||
|
const [dismissed, setDismissed] = useState(false);
|
||||||
const totalOutstanding = outstandingInvoices
|
const totalOutstanding = outstandingInvoices
|
||||||
.filter((inv) => inv.currency === "USD")
|
.filter((inv) => inv.currency === "USD")
|
||||||
.reduce((sum, inv) => sum + inv.amount, 0);
|
.reduce((sum, inv) => sum + inv.amount, 0);
|
||||||
@@ -61,6 +64,34 @@ export default function MyPortalPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background p-6">
|
<div className="min-h-screen bg-background p-6">
|
||||||
<div className="mx-auto max-w-7xl space-y-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 */}
|
{/* Welcome banner */}
|
||||||
<div className="overflow-hidden rounded-3xl bg-gradient-to-r from-[#059669] to-[#10B981] p-6 text-white shadow-sm">
|
<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">
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
|
|||||||
@@ -1,135 +1,46 @@
|
|||||||
import { useMemo } from "react";
|
import { User, Building2, Phone, Mail, MapPin, ShieldCheck, Briefcase, UserCheck, Fingerprint, FileCheck, Globe, Building } from "lucide-react";
|
||||||
import {
|
import { useQuery } from "@tanstack/react-query";
|
||||||
User,
|
import { api } from "@/services/api";
|
||||||
Building2,
|
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Badge, Separator } from "@edr/ui-common";
|
||||||
Phone,
|
|
||||||
Mail,
|
function InfoItem({ icon, label, value }: { icon?: React.ReactNode; label: string; value?: string | null }) {
|
||||||
MapPin,
|
return (
|
||||||
ShieldCheck,
|
<div className="flex items-start gap-3">
|
||||||
Briefcase,
|
{icon && <div className="mt-1 text-muted-foreground [&_svg]:size-4">{icon}</div>}
|
||||||
UserCheck,
|
<div className="flex flex-col gap-0.5">
|
||||||
Building,
|
<p className="text-[10px] font-bold uppercase tracking-tight text-muted-foreground">{label}</p>
|
||||||
Globe,
|
<p className="text-sm font-bold text-foreground">{value || "—"}</p>
|
||||||
Fingerprint,
|
</div>
|
||||||
FileCheck,
|
</div>
|
||||||
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";
|
|
||||||
|
|
||||||
export default function ProfilePage() {
|
export default function ProfilePage() {
|
||||||
const { user, customer, isPending } = useAuth();
|
const { data: profile, isPending } = useQuery(
|
||||||
|
api.companies.getProfile.queryOptions(),
|
||||||
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(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}), []);
|
|
||||||
|
|
||||||
if (isPending) {
|
if (isPending) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center">
|
<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>
|
</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 (
|
return (
|
||||||
<div className="container mx-auto max-w-7xl px-4 py-8">
|
<div className="px-4 py-8">
|
||||||
<div className="flex flex-col gap-8">
|
<div className="mx-auto max-w-7xl">
|
||||||
{/* Header Section */}
|
<div className="flex flex-col gap-8">
|
||||||
<div className="flex flex-col gap-6 md:flex-row md:items-center md:justify-between">
|
{/* Header */}
|
||||||
<div className="flex items-center gap-6">
|
<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">
|
<div className="flex size-24 items-center justify-center rounded-2xl bg-primary/10 text-primary shadow-inner">
|
||||||
<User className="size-12" />
|
<User className="size-12" />
|
||||||
@@ -137,7 +48,7 @@ export default function ProfilePage() {
|
|||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-3xl font-black tracking-tight text-foreground">
|
<h1 className="text-3xl font-black tracking-tight text-foreground">
|
||||||
{displayName}
|
{profile.companyName}
|
||||||
</h1>
|
</h1>
|
||||||
<Badge variant="secondary" className="font-bold uppercase tracking-wider">
|
<Badge variant="secondary" className="font-bold uppercase tracking-wider">
|
||||||
Verified
|
Verified
|
||||||
@@ -145,182 +56,129 @@ export default function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
<p className="flex items-center gap-2 font-medium text-muted-foreground">
|
<p className="flex items-center gap-2 font-medium text-muted-foreground">
|
||||||
<Building className="size-4" />
|
<Building className="size-4" />
|
||||||
{customer?.companyName || "No Company Linked"}
|
{profile.companyName}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
|
||||||
{/* Left Column - Personal & Company Info */}
|
{/* Left Column */}
|
||||||
<div className="flex flex-col gap-8 lg:col-span-2">
|
<div className="flex flex-col gap-8 lg:col-span-2">
|
||||||
<div className="grid grid-cols-1 gap-8 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-8 md:grid-cols-2">
|
||||||
{/* Personal Details Card */}
|
{/* 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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Fingerprint className="size-5 text-primary" />
|
<Briefcase className="size-5 text-primary" />
|
||||||
Personal Details
|
Key Personnel
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>Your account contact information</CardDescription>
|
<CardDescription>Management and contact persons</CardDescription>
|
||||||
<CardAction>
|
|
||||||
<Button variant="ghost" size="icon">
|
|
||||||
<ExternalLink />
|
|
||||||
</Button>
|
|
||||||
</CardAction>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-4">
|
<CardContent className="grid grid-cols-1 gap-8 md:grid-cols-2">
|
||||||
<InfoItem icon={<Mail />} label="Email Address" value={user?.email} />
|
<div className="flex flex-col gap-4">
|
||||||
<InfoItem icon={<Phone />} label="Phone Number" value={user?.phoneNumber} />
|
<h3 className="border-l-4 border-primary pl-3 text-sm font-bold uppercase tracking-wide text-foreground">
|
||||||
<InfoItem icon={<UserCheck />} label="Username" value={user?.username} />
|
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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Company Details Card */}
|
{/* Power of Attorney */}
|
||||||
<Card>
|
{profile.poaName && (
|
||||||
<CardHeader>
|
<Card className="border-dashed">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardHeader>
|
||||||
<Building2 className="size-5 text-primary" />
|
<CardTitle className="flex items-center gap-2">
|
||||||
Company Details
|
<UserCheck className="size-5 text-accent" />
|
||||||
</CardTitle>
|
Power of Attorney
|
||||||
<CardDescription>Business registration information</CardDescription>
|
</CardTitle>
|
||||||
</CardHeader>
|
<CardDescription>Authorized representative details</CardDescription>
|
||||||
<CardContent className="flex flex-col gap-4">
|
</CardHeader>
|
||||||
<InfoItem icon={<Globe />} label="Location" value={customer?.companyLocation} />
|
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<InfoItem icon={<MapPin />} label="Address" value={customer?.companyAddress} />
|
<InfoItem label="PoA Name" value={profile.poaName} />
|
||||||
<InfoItem icon={<FileCheck />} label="TIN Number" value={customer?.tinNumber} />
|
<InfoItem label="PoA Email" value={profile.poaEmail} />
|
||||||
<InfoItem icon={<ShieldCheck />} label="FAN Number" value={customer?.fanNumber} />
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</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>
|
||||||
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
617
apps/edr-freight-web/portal/src/pages/SettingsPage.tsx
Normal file
617
apps/edr-freight-web/portal/src/pages/SettingsPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,10 +14,8 @@ import {
|
|||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
UploadCloud,
|
UploadCloud,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { OnboardingUserType } from "./types";
|
|
||||||
import type { AuthUser } from "@/types/auth";
|
import type { AuthUser } from "@/types/auth";
|
||||||
import type { CreateCompanyPayload } from "@/services/companies.service";
|
import type { CreateCompanyPayload } from "@/services/companies.service";
|
||||||
import type { FileUploadSetting } from "@/types/fileUploadSettings";
|
|
||||||
import PhoneInput from "@/components/auth/PhoneInput";
|
import PhoneInput from "@/components/auth/PhoneInput";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -30,7 +28,7 @@ import {
|
|||||||
} from "@edr/ui-common";
|
} from "@edr/ui-common";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
|
|
||||||
type CompanyStep = "company" | "personnel" | "poa" | "documents";
|
type CompanyStep = "company" | "personnel" | "poa" | "documents" | "confirm";
|
||||||
|
|
||||||
const onboardingSchema = z.object({
|
const onboardingSchema = z.object({
|
||||||
companyName: z.string().min(1, "Company name is required"),
|
companyName: z.string().min(1, "Company name is required"),
|
||||||
@@ -85,6 +83,7 @@ const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
|
|||||||
],
|
],
|
||||||
poa: [],
|
poa: [],
|
||||||
documents: [],
|
documents: [],
|
||||||
|
confirm: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
|
function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
|
||||||
@@ -116,26 +115,34 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function CompanyProfileForm({
|
export default function CompanyProfileForm({
|
||||||
userType,
|
documentSettingCode,
|
||||||
|
documentFiles: controlledFiles,
|
||||||
|
onDocumentFilesChange,
|
||||||
user,
|
user,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
isPending,
|
isPending,
|
||||||
onBack,
|
onBack,
|
||||||
}: {
|
}: {
|
||||||
userType: OnboardingUserType;
|
documentSettingCode: string;
|
||||||
|
documentFiles?: Record<string, File | File[] | null>;
|
||||||
|
onDocumentFilesChange?: (
|
||||||
|
files: Record<string, File | File[] | null>,
|
||||||
|
) => void;
|
||||||
user: AuthUser;
|
user: AuthUser;
|
||||||
onSubmit: (data: CreateCompanyPayload) => void;
|
onSubmit: (data: CreateCompanyPayload) => void;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [step, setStep] = useState<CompanyStep>("company");
|
const [step, setStep] = useState<CompanyStep>("company");
|
||||||
const [documentFiles, setDocumentFiles] = useState<
|
const [internalFiles, setInternalFiles] = useState<
|
||||||
Record<string, File | File[] | null>
|
Record<string, File | File[] | null>
|
||||||
>({});
|
>({});
|
||||||
|
const documentFiles = controlledFiles ?? internalFiles;
|
||||||
|
const setDocumentFiles = onDocumentFilesChange ?? setInternalFiles;
|
||||||
|
|
||||||
const { data: uploadSettings = [], isLoading: loadingDocuments } = useQuery(
|
const { data: uploadSetting, isLoading: loadingDocuments } = useQuery(
|
||||||
api.fileUploadSettings.getByEntity.queryOptions({
|
api.fileUploadSettings.getByCode.queryOptions({
|
||||||
input: { entity: "customer" },
|
input: { code: documentSettingCode },
|
||||||
refetchOnMount: false,
|
refetchOnMount: false,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -144,6 +151,7 @@ export default function CompanyProfileForm({
|
|||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
trigger,
|
trigger,
|
||||||
|
watch,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm<FormData>({
|
} = useForm<FormData>({
|
||||||
resolver: zodResolver(onboardingSchema),
|
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 () => {
|
const nextStep = async () => {
|
||||||
if (step === "poa") {
|
if (step === "poa") {
|
||||||
if (hasDocuments) {
|
setStep("documents");
|
||||||
setStep("documents");
|
|
||||||
} else {
|
|
||||||
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (step === "documents") {
|
if (step === "documents") {
|
||||||
|
setStep("confirm");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (step === "confirm") {
|
||||||
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
|
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -201,8 +211,10 @@ export default function CompanyProfileForm({
|
|||||||
setStep("company");
|
setStep("company");
|
||||||
} else if (step === "poa") {
|
} else if (step === "poa") {
|
||||||
setStep("personnel");
|
setStep("personnel");
|
||||||
} else {
|
} else if (step === "documents") {
|
||||||
setStep("poa");
|
setStep("poa");
|
||||||
|
} else {
|
||||||
|
setStep("documents");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -228,31 +240,41 @@ export default function CompanyProfileForm({
|
|||||||
<StepIcon
|
<StepIcon
|
||||||
icon={<User className="size-5" />}
|
icon={<User className="size-5" />}
|
||||||
active={step === "personnel"}
|
active={step === "personnel"}
|
||||||
completed={step === "poa"}
|
completed={
|
||||||
|
step === "poa" || step === "documents" || step === "confirm"
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<StepIcon
|
<StepIcon
|
||||||
icon={<FileText className="size-5" />}
|
icon={<FileText className="size-5" />}
|
||||||
active={step === "poa"}
|
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>
|
</div>
|
||||||
<p className="text-center text-sm text-muted-foreground mt-3">
|
<p className="text-center text-sm text-muted-foreground mt-3">
|
||||||
{step === "company" && `Step 1 of ${hasDocuments ? 4 : 3} — Company Information`}
|
{step === "company" &&
|
||||||
{step === "personnel" && `Step 2 of ${hasDocuments ? 4 : 3} — Personnel Details`}
|
`Step 1 of ${totalSteps} — Company Information`}
|
||||||
{step === "poa" && `Step 3 of ${hasDocuments ? 4 : 3} — Power of Attorney (Optional)`}
|
{step === "personnel" &&
|
||||||
{step === "documents" && "Step 4 of 4 — Upload Documents (Optional)"}
|
`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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit((data) => onSubmit(buildPayload(data, user)))}
|
onSubmit={(e) => e.preventDefault()}
|
||||||
className="flex flex-col gap-4"
|
className="flex flex-col gap-4"
|
||||||
>
|
>
|
||||||
<FieldGroup className="gap-4">
|
<FieldGroup className="gap-4">
|
||||||
@@ -353,11 +375,6 @@ export default function CompanyProfileForm({
|
|||||||
|
|
||||||
{step === "personnel" && (
|
{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>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-foreground mb-3">
|
<h3 className="text-sm font-semibold text-foreground mb-3">
|
||||||
Contact Person
|
Contact Person
|
||||||
@@ -500,62 +517,155 @@ export default function CompanyProfileForm({
|
|||||||
|
|
||||||
{step === "documents" && (
|
{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 ? (
|
{loadingDocuments ? (
|
||||||
<div className="flex items-center justify-center py-8">
|
<div className="flex items-center justify-center py-8">
|
||||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
) : uploadSettings.length === 0 ? (
|
) : !uploadSetting ? (
|
||||||
<p className="text-sm text-muted-foreground text-center py-4">
|
<p className="text-sm text-muted-foreground text-center py-4">
|
||||||
No document requirements found for your account type.
|
No document requirements found for your account type.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
{uploadSettings.map((setting) => (
|
<SmartFileInput
|
||||||
<SmartFileInput
|
file={uploadSetting}
|
||||||
key={setting.id}
|
value={documentFiles}
|
||||||
file={setting}
|
onChange={setDocumentFiles}
|
||||||
value={documentFiles}
|
/>
|
||||||
onChange={setDocumentFiles}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
</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>
|
</FieldGroup>
|
||||||
|
|
||||||
<div className="flex items-center justify-between pt-2">
|
<div className="flex items-center justify-between pt-2">
|
||||||
<Button type="button" variant="outline" onClick={prevStep}>
|
<Button type="button" variant="outline" onClick={prevStep}>
|
||||||
<ArrowLeft />
|
<ArrowLeft />
|
||||||
{step === "company" ? "Change Type" : "Back"}
|
{step === "company"
|
||||||
|
? "Change Type"
|
||||||
|
: step === "confirm"
|
||||||
|
? "Back to Documents"
|
||||||
|
: "Back"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button type="button" onClick={nextStep} disabled={isPending}>
|
<div className="flex items-center gap-3">
|
||||||
{isPending ? (
|
<Button
|
||||||
<>
|
type="button"
|
||||||
<Loader2 className="animate-spin" />
|
onClick={step === "confirm" ? handleSubmit((data) => onSubmit(buildPayload(data, user))) : nextStep}
|
||||||
Submitting...
|
disabled={isPending || (step === "documents" && !hasDocuments && loadingDocuments)}
|
||||||
</>
|
>
|
||||||
) : step === "documents" ? (
|
{isPending ? (
|
||||||
"Complete Registration"
|
<>
|
||||||
) : (
|
<Loader2 className="animate-spin" />
|
||||||
<>
|
Submitting...
|
||||||
Next Step
|
</>
|
||||||
<ArrowRight />
|
) : step === "documents" ? (
|
||||||
</>
|
"Continue"
|
||||||
)}
|
) : step === "confirm" ? (
|
||||||
</Button>
|
"Submit Registration"
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Next Step
|
||||||
|
<ArrowRight />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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({
|
function StepIcon({
|
||||||
icon,
|
icon,
|
||||||
active,
|
active,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Loader2,
|
Loader2,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
|
UploadCloud,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { AuthUser } from "@/types/auth";
|
import type { AuthUser } from "@/types/auth";
|
||||||
import type { CreateCompanyPayload } from "@/services/companies.service";
|
import type { CreateCompanyPayload } from "@/services/companies.service";
|
||||||
@@ -21,9 +23,11 @@ import {
|
|||||||
FieldLabel,
|
FieldLabel,
|
||||||
FieldError,
|
FieldError,
|
||||||
FieldGroup,
|
FieldGroup,
|
||||||
|
SmartFileInput,
|
||||||
} from "@edr/ui-common";
|
} from "@edr/ui-common";
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
|
||||||
type DjiboutiStep = "company" | "representative";
|
type DjiboutiStep = "company" | "representative" | "documents" | "confirm";
|
||||||
|
|
||||||
const djiboutiSchema = z.object({
|
const djiboutiSchema = z.object({
|
||||||
companyName: z.string().min(1, "Company name is required"),
|
companyName: z.string().min(1, "Company name is required"),
|
||||||
@@ -40,9 +44,23 @@ const djiboutiSchema = z.object({
|
|||||||
|
|
||||||
type FormData = z.infer<typeof djiboutiSchema>;
|
type FormData = z.infer<typeof djiboutiSchema>;
|
||||||
|
|
||||||
const stepLabels: Record<DjiboutiStep, string> = {
|
const stepFields: Record<DjiboutiStep, (keyof FormData)[]> = {
|
||||||
company: "Step 1 of 2 — Company Information",
|
company: [
|
||||||
representative: "Step 2 of 2 — Representative Details",
|
"companyName",
|
||||||
|
"companyEmail",
|
||||||
|
"companyPhone",
|
||||||
|
"companyPhoneCountryCode",
|
||||||
|
"companyLocation",
|
||||||
|
"companyAddress",
|
||||||
|
],
|
||||||
|
representative: [
|
||||||
|
"repName",
|
||||||
|
"repEmail",
|
||||||
|
"repPhone",
|
||||||
|
"repPhoneCountryCode",
|
||||||
|
],
|
||||||
|
documents: [],
|
||||||
|
confirm: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
|
function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
|
||||||
@@ -64,22 +82,43 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function DjiboutiAgentForm({
|
export default function DjiboutiAgentForm({
|
||||||
|
documentSettingCode,
|
||||||
|
documentFiles: controlledFiles,
|
||||||
|
onDocumentFilesChange,
|
||||||
user,
|
user,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
isPending,
|
isPending,
|
||||||
onBack,
|
onBack,
|
||||||
}: {
|
}: {
|
||||||
|
documentSettingCode: string;
|
||||||
|
documentFiles?: Record<string, File | File[] | null>;
|
||||||
|
onDocumentFilesChange?: (
|
||||||
|
files: Record<string, File | File[] | null>,
|
||||||
|
) => void;
|
||||||
user: AuthUser;
|
user: AuthUser;
|
||||||
onSubmit: (data: CreateCompanyPayload) => void;
|
onSubmit: (data: CreateCompanyPayload) => void;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [step, setStep] = useState<DjiboutiStep>("company");
|
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 {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
trigger,
|
trigger,
|
||||||
|
watch,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm<FormData>({
|
} = useForm<FormData>({
|
||||||
resolver: zodResolver(djiboutiSchema),
|
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 () => {
|
const nextStep = async () => {
|
||||||
if (step === "representative") {
|
if (step === "representative") {
|
||||||
|
setStep("documents");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (step === "documents") {
|
||||||
|
setStep("confirm");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (step === "confirm") {
|
||||||
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
|
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const fields: (keyof FormData)[] =
|
const fields = stepFields[step];
|
||||||
step === "company"
|
|
||||||
? [
|
|
||||||
"companyName",
|
|
||||||
"companyEmail",
|
|
||||||
"companyPhone",
|
|
||||||
"companyPhoneCountryCode",
|
|
||||||
"companyLocation",
|
|
||||||
"companyAddress",
|
|
||||||
]
|
|
||||||
: ["repName", "repEmail", "repPhone", "repPhoneCountryCode"];
|
|
||||||
const isValid = await trigger(fields);
|
const isValid = await trigger(fields);
|
||||||
if (!isValid) return;
|
if (!isValid) return;
|
||||||
setStep("representative");
|
setStep("representative");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const skipDocuments = () => {
|
||||||
|
setStep("confirm");
|
||||||
|
};
|
||||||
|
|
||||||
const prevStep = () => {
|
const prevStep = () => {
|
||||||
if (step === "company") {
|
if (step === "company") {
|
||||||
onBack();
|
onBack();
|
||||||
} else {
|
} else if (step === "representative") {
|
||||||
setStep("company");
|
setStep("company");
|
||||||
|
} else if (step === "documents") {
|
||||||
|
setStep("representative");
|
||||||
|
} else {
|
||||||
|
setStep("documents");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -143,21 +192,34 @@ export default function DjiboutiAgentForm({
|
|||||||
<StepIcon
|
<StepIcon
|
||||||
icon={<Building2 className="size-5" />}
|
icon={<Building2 className="size-5" />}
|
||||||
active={step === "company"}
|
active={step === "company"}
|
||||||
completed={step === "representative"}
|
completed={step !== "company"}
|
||||||
/>
|
/>
|
||||||
<StepIcon
|
<StepIcon
|
||||||
icon={<UserRound className="size-5" />}
|
icon={<UserRound className="size-5" />}
|
||||||
active={step === "representative"}
|
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}
|
completed={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-center text-sm text-muted-foreground mt-3">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit((data) => onSubmit(buildPayload(data, user)))}
|
onSubmit={(e) => e.preventDefault()}
|
||||||
className="flex flex-col gap-4"
|
className="flex flex-col gap-4"
|
||||||
>
|
>
|
||||||
<FieldGroup className="gap-4">
|
<FieldGroup className="gap-4">
|
||||||
@@ -262,35 +324,120 @@ export default function DjiboutiAgentForm({
|
|||||||
</div>
|
</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>
|
</FieldGroup>
|
||||||
|
|
||||||
<div className="flex items-center justify-between pt-2">
|
<div className="flex items-center justify-between pt-2">
|
||||||
<Button type="button" variant="outline" onClick={prevStep}>
|
<Button type="button" variant="outline" onClick={prevStep}>
|
||||||
<ArrowLeft />
|
<ArrowLeft />
|
||||||
{step === "company" ? "Change Type" : "Back"}
|
{step === "company"
|
||||||
|
? "Change Type"
|
||||||
|
: step === "confirm"
|
||||||
|
? "Back to Documents"
|
||||||
|
: "Back"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button type="button" onClick={nextStep} disabled={isPending}>
|
<div className="flex items-center gap-3">
|
||||||
{isPending ? (
|
{step === "documents" && (
|
||||||
<>
|
<Button
|
||||||
<Loader2 className="animate-spin" />
|
type="button"
|
||||||
Submitting...
|
variant="outline"
|
||||||
</>
|
onClick={skipDocuments}
|
||||||
) : step === "representative" ? (
|
disabled={isPending}
|
||||||
"Complete Registration"
|
>
|
||||||
) : (
|
Skip for now
|
||||||
<>
|
</Button>
|
||||||
Next Step
|
|
||||||
<ArrowRight />
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
</form>
|
</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({
|
function StepIcon({
|
||||||
icon,
|
icon,
|
||||||
active,
|
active,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
@@ -11,11 +11,11 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
ChevronLeft,
|
||||||
|
UploadCloud,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import useAuth from "@/hooks/useAuth";
|
import type { AuthUser } from "@/types/auth";
|
||||||
import { api } from "@/services/api";
|
import type { CreateCompanyPayload } from "@/services/companies.service";
|
||||||
import type { CreateCustomerDto } from "@/types/customers";
|
|
||||||
import AuthLayout from "@/components/auth/AuthLayout";
|
|
||||||
import PhoneInput from "@/components/auth/PhoneInput";
|
import PhoneInput from "@/components/auth/PhoneInput";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -24,14 +24,13 @@ import {
|
|||||||
FieldLabel,
|
FieldLabel,
|
||||||
FieldError,
|
FieldError,
|
||||||
FieldGroup,
|
FieldGroup,
|
||||||
|
SmartFileInput,
|
||||||
} from "@edr/ui-common";
|
} from "@edr/ui-common";
|
||||||
import TransporterOnboarding from "./TransportrOnBoarding";
|
import { api } from "@/services/api";
|
||||||
import DjiboutiForwardingAgentForm from "./DjiboutiFreightForwardingAgent";
|
|
||||||
import ImportExportOnBoarding from "./ImportExportOnBoarding";
|
|
||||||
|
|
||||||
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"),
|
companyName: z.string().min(1, "Company name is required"),
|
||||||
companyEmail: z.string().email("Invalid email address"),
|
companyEmail: z.string().email("Invalid email address"),
|
||||||
companyPhone: z.string().min(1, "Company phone is required"),
|
companyPhone: z.string().min(1, "Company phone is required"),
|
||||||
@@ -59,9 +58,9 @@ const onboardingSchema = z.object({
|
|||||||
poaLocation: z.string().optional(),
|
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: [
|
company: [
|
||||||
"companyName",
|
"companyName",
|
||||||
"companyEmail",
|
"companyEmail",
|
||||||
@@ -83,20 +82,79 @@ const stepFields: Record<OnboardingStep, (keyof FormData)[]> = {
|
|||||||
"generalManagerPhoneCountryCode",
|
"generalManagerPhoneCountryCode",
|
||||||
],
|
],
|
||||||
poa: [],
|
poa: [],
|
||||||
|
documents: [],
|
||||||
|
confirm: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function CustomerOnboardingPage() {
|
function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
|
||||||
const queryClient = useQueryClient();
|
return {
|
||||||
const { user } = useAuth();
|
companyName: data.companyName,
|
||||||
const [step, setStep] = useState<OnboardingStep>("company");
|
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 {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
trigger,
|
trigger,
|
||||||
|
watch,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm<FormData>({
|
} = useForm<FormData>({
|
||||||
resolver: zodResolver(onboardingSchema),
|
resolver: zodResolver(forwarderSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
companyName: "",
|
companyName: "",
|
||||||
companyEmail: "",
|
companyEmail: "",
|
||||||
@@ -123,20 +181,21 @@ export default function CustomerOnboardingPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const createCustomerMutation = useMutation({
|
const formValues = watch();
|
||||||
mutationFn: (payload: CreateCustomerDto) =>
|
const hasDocuments = Boolean(uploadSetting?.fields?.length);
|
||||||
api.customers.create.call(payload),
|
const totalSteps = 5;
|
||||||
onSuccess: () => {
|
|
||||||
if (user)
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: api.customers.getByUserId.queryKey({ id: user.id }),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const nextStep = async () => {
|
const nextStep = async () => {
|
||||||
if (step === "poa") {
|
if (step === "poa") {
|
||||||
handleSubmit(onSubmit)();
|
setStep("documents");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (step === "documents") {
|
||||||
|
setStep("confirm");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (step === "confirm") {
|
||||||
|
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const fields = stepFields[step];
|
const fields = stepFields[step];
|
||||||
@@ -145,69 +204,36 @@ export default function CustomerOnboardingPage() {
|
|||||||
setStep(step === "company" ? "personnel" : "poa");
|
setStep(step === "company" ? "personnel" : "poa");
|
||||||
};
|
};
|
||||||
|
|
||||||
const prevStep = () => {
|
const skipDocuments = () => {
|
||||||
if (step === "personnel") setStep("company");
|
setStep("confirm");
|
||||||
else if (step === "poa") setStep("personnel");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSubmit = async (data: FormData) => {
|
const prevStep = () => {
|
||||||
const nameParts = (user?.name?.en ?? "").split(" ");
|
if (step === "company") {
|
||||||
const payload: CreateCustomerDto = {
|
onBack();
|
||||||
userId: user!.id,
|
} else if (step === "personnel") {
|
||||||
firstName: nameParts[0] || "",
|
setStep("company");
|
||||||
lastName: nameParts.slice(-1)[0] || "",
|
} else if (step === "poa") {
|
||||||
email: user!.email,
|
setStep("personnel");
|
||||||
phone: user!.phoneNumber,
|
} else if (step === "documents") {
|
||||||
companyName: data.companyName,
|
setStep("poa");
|
||||||
companyEmail: data.companyEmail,
|
} else {
|
||||||
companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`,
|
setStep("documents");
|
||||||
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);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthLayout
|
<>
|
||||||
left={{
|
<div className="mb-8">
|
||||||
badge: "Complete Your Profile",
|
<button
|
||||||
title: "Set up your company profile",
|
type="button"
|
||||||
description:
|
onClick={prevStep}
|
||||||
"Provide your business details to start using EDR Freight for managing shipments, tracking consignments, and streamlining logistics operations across Ethiopia and Djibouti.",
|
className="mb-4 flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
features: [
|
>
|
||||||
"Company registration details",
|
<ChevronLeft className="size-4" />
|
||||||
"Contact and management personnel",
|
Change account type
|
||||||
"Power of Attorney (optional)",
|
</button>
|
||||||
],
|
|
||||||
stats: {
|
|
||||||
label: "Active Customers",
|
|
||||||
value: "500+",
|
|
||||||
footer: "And growing",
|
|
||||||
progress: "w-[95%]",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
|
|
||||||
<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="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" />
|
<div className="absolute top-1/2 left-0 w-full h-0.5 bg-border -translate-y-1/2 z-0" />
|
||||||
<StepIcon
|
<StepIcon
|
||||||
@@ -218,22 +244,41 @@ export default function CustomerOnboardingPage() {
|
|||||||
<StepIcon
|
<StepIcon
|
||||||
icon={<User className="size-5" />}
|
icon={<User className="size-5" />}
|
||||||
active={step === "personnel"}
|
active={step === "personnel"}
|
||||||
completed={step === "poa"}
|
completed={step === "poa" || step === "documents" || step === "confirm"}
|
||||||
/>
|
/>
|
||||||
<StepIcon
|
<StepIcon
|
||||||
icon={<FileText className="size-5" />}
|
icon={<FileText className="size-5" />}
|
||||||
active={step === "poa"}
|
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}
|
completed={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-center text-sm text-muted-foreground mt-3">
|
<p className="text-center text-sm text-muted-foreground mt-3">
|
||||||
{step === "company" && "Step 1 of 3 — Company Information"}
|
{step === "company" &&
|
||||||
{step === "personnel" && "Step 2 of 3 — Personnel Details"}
|
`Step 1 of ${totalSteps} — Company Information`}
|
||||||
{step === "poa" && "Step 3 of 3 — Power of Attorney (Optional)"}
|
{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>
|
</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">
|
<FieldGroup className="gap-4">
|
||||||
{step === "company" && (
|
{step === "company" && (
|
||||||
<>
|
<>
|
||||||
@@ -332,11 +377,6 @@ export default function CustomerOnboardingPage() {
|
|||||||
|
|
||||||
{step === "personnel" && (
|
{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>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-foreground mb-3">
|
<h3 className="text-sm font-semibold text-foreground mb-3">
|
||||||
Contact Person
|
Contact Person
|
||||||
@@ -418,88 +458,212 @@ export default function CustomerOnboardingPage() {
|
|||||||
{step === "poa" && (
|
{step === "poa" && (
|
||||||
<>
|
<>
|
||||||
<p className="text-sm text-muted-foreground">
|
<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>
|
</p>
|
||||||
|
|
||||||
<Field>
|
<Field data-invalid={Boolean(errors.poaName)}>
|
||||||
<FieldLabel>PoA Name</FieldLabel>
|
<FieldLabel>PoA Name</FieldLabel>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Authorized Representative Name"
|
placeholder="Authorized Representative Name"
|
||||||
|
aria-invalid={Boolean(errors.poaName)}
|
||||||
{...register("poaName")}
|
{...register("poaName")}
|
||||||
/>
|
/>
|
||||||
|
<FieldError errors={[errors.poaName]} />
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<Field>
|
<Field data-invalid={Boolean(errors.poaEmail)}>
|
||||||
<FieldLabel>PoA Email</FieldLabel>
|
<FieldLabel>PoA Email</FieldLabel>
|
||||||
<Input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="poa@company.com"
|
placeholder="poa@company.com"
|
||||||
|
aria-invalid={Boolean(errors.poaEmail)}
|
||||||
{...register("poaEmail")}
|
{...register("poaEmail")}
|
||||||
/>
|
/>
|
||||||
|
<FieldError errors={[errors.poaEmail]} />
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<PhoneInput
|
<PhoneInput
|
||||||
countryCode={{ ...register("poaPhoneCountryCode") }}
|
countryCode={{ ...register("poaPhoneCountryCode") }}
|
||||||
phone={{ ...register("poaPhone"), placeholder: "912345678" }}
|
phone={{ ...register("poaPhone"), placeholder: "912345678" }}
|
||||||
label="PoA Phone"
|
label="PoA Phone"
|
||||||
|
countryCodeError={errors.poaPhoneCountryCode}
|
||||||
|
phoneError={errors.poaPhone}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<Field>
|
<Field data-invalid={Boolean(errors.poaLocation)}>
|
||||||
<FieldLabel>PoA Location</FieldLabel>
|
<FieldLabel>PoA Location</FieldLabel>
|
||||||
<Input
|
<Input
|
||||||
placeholder="City, Country"
|
placeholder="City, Country"
|
||||||
|
aria-invalid={Boolean(errors.poaLocation)}
|
||||||
{...register("poaLocation")}
|
{...register("poaLocation")}
|
||||||
/>
|
/>
|
||||||
|
<FieldError errors={[errors.poaLocation]} />
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field>
|
<Field data-invalid={Boolean(errors.poaAddress)}>
|
||||||
<FieldLabel>PoA Address</FieldLabel>
|
<FieldLabel>PoA Address</FieldLabel>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Full Address"
|
placeholder="Full Address"
|
||||||
|
aria-invalid={Boolean(errors.poaAddress)}
|
||||||
{...register("poaAddress")}
|
{...register("poaAddress")}
|
||||||
/>
|
/>
|
||||||
|
<FieldError errors={[errors.poaAddress]} />
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</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>
|
</FieldGroup>
|
||||||
|
|
||||||
<div className="flex items-center justify-between pt-2">
|
<div className="flex items-center justify-between pt-2">
|
||||||
<Button
|
<Button type="button" variant="outline" onClick={prevStep}>
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={prevStep}
|
|
||||||
disabled={step === "company"}
|
|
||||||
>
|
|
||||||
<ArrowLeft />
|
<ArrowLeft />
|
||||||
Back
|
{step === "company"
|
||||||
|
? "Change Type"
|
||||||
|
: step === "confirm"
|
||||||
|
? "Back to Documents"
|
||||||
|
: "Back"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<div className="flex items-center gap-3">
|
||||||
type="button"
|
{step === "documents" && (
|
||||||
onClick={nextStep}
|
<Button
|
||||||
disabled={createCustomerMutation.isPending}
|
type="button"
|
||||||
>
|
variant="outline"
|
||||||
{createCustomerMutation.isPending ? (
|
onClick={skipDocuments}
|
||||||
<>
|
disabled={isPending}
|
||||||
<Loader2 className="animate-spin" />
|
>
|
||||||
Submitting...
|
Skip for now
|
||||||
</>
|
</Button>
|
||||||
) : step === "poa" ? (
|
|
||||||
"Complete Registration"
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
Next Step
|
|
||||||
<ArrowRight />
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
</form> */}
|
</form>
|
||||||
</AuthLayout>
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
ArrowDownToLine,
|
ArrowDownToLine,
|
||||||
ArrowUpFromLine,
|
ArrowUpFromLine,
|
||||||
@@ -10,9 +10,11 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import useAuth from "@/hooks/useAuth";
|
import useAuth from "@/hooks/useAuth";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
|
import { companiesService } from "@/services/companies.service";
|
||||||
import type { CreateCompanyPayload } from "@/services/companies.service";
|
import type { CreateCompanyPayload } from "@/services/companies.service";
|
||||||
import AuthLayout from "@/components/auth/AuthLayout";
|
import AuthLayout from "@/components/auth/AuthLayout";
|
||||||
import CompanyProfileForm from "./CompanyProfileForm";
|
import CompanyProfileForm from "./CompanyProfileForm";
|
||||||
|
import ForwarderForm from "./ForwarderForm";
|
||||||
import DjiboutiAgentForm from "./DjiboutiAgentForm";
|
import DjiboutiAgentForm from "./DjiboutiAgentForm";
|
||||||
import TransporterForm from "./TransporterForm";
|
import TransporterForm from "./TransporterForm";
|
||||||
import type { OnboardingUserType } from "./types";
|
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() {
|
export default function OnboardingPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [userType, setUserType] = useState<OnboardingUserType | null>(null);
|
const [userType, setUserType] = useState<OnboardingUserType | null>(null);
|
||||||
|
const [documentFiles, setDocumentFiles] = useState<
|
||||||
useQuery(
|
Record<string, File | File[] | null>
|
||||||
api.fileUploadSettings.getByEntity.queryOptions({
|
>({});
|
||||||
input: { entity: "customer" },
|
|
||||||
refetchOnMount: false,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const COMPANY_TYPE_MAP: Record<OnboardingUserType, string> = {
|
const COMPANY_TYPE_MAP: Record<OnboardingUserType, string> = {
|
||||||
importer: "customer",
|
importer: "customer",
|
||||||
@@ -136,8 +142,14 @@ export default function OnboardingPage() {
|
|||||||
const createCompanyMutation = useMutation({
|
const createCompanyMutation = useMutation({
|
||||||
mutationFn: (payload: CreateCompanyPayload) =>
|
mutationFn: (payload: CreateCompanyPayload) =>
|
||||||
api.companies.create.call(payload),
|
api.companies.create.call(payload),
|
||||||
onSuccess: () => {
|
onSuccess: async (data) => {
|
||||||
queryClient.invalidateQueries({
|
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(),
|
queryKey: api.companies.getInfo.queryKey(),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -237,6 +249,9 @@ export default function OnboardingPage() {
|
|||||||
<AuthLayout left={leftProps}>
|
<AuthLayout left={leftProps}>
|
||||||
{userType === "transporter" ? (
|
{userType === "transporter" ? (
|
||||||
<TransporterForm
|
<TransporterForm
|
||||||
|
documentSettingCode={DOCUMENT_SETTING_CODE_MAP[userType]}
|
||||||
|
documentFiles={documentFiles}
|
||||||
|
onDocumentFilesChange={setDocumentFiles}
|
||||||
user={user}
|
user={user}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
isPending={createCompanyMutation.isPending}
|
isPending={createCompanyMutation.isPending}
|
||||||
@@ -244,6 +259,19 @@ export default function OnboardingPage() {
|
|||||||
/>
|
/>
|
||||||
) : userType === "freight-forwarder-dj" ? (
|
) : userType === "freight-forwarder-dj" ? (
|
||||||
<DjiboutiAgentForm
|
<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}
|
user={user}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
isPending={createCompanyMutation.isPending}
|
isPending={createCompanyMutation.isPending}
|
||||||
@@ -251,7 +279,9 @@ export default function OnboardingPage() {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<CompanyProfileForm
|
<CompanyProfileForm
|
||||||
userType={userType}
|
documentSettingCode={DOCUMENT_SETTING_CODE_MAP[userType]}
|
||||||
|
documentFiles={documentFiles}
|
||||||
|
onDocumentFilesChange={setDocumentFiles}
|
||||||
user={user}
|
user={user}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
isPending={createCompanyMutation.isPending}
|
isPending={createCompanyMutation.isPending}
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { useForm, Controller } from "react-hook-form";
|
import { useForm, Controller } from "react-hook-form";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
Loader2,
|
ArrowRight,
|
||||||
|
ArrowLeft,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
Truck,
|
Truck,
|
||||||
Info,
|
CheckCircle2,
|
||||||
|
Loader2,
|
||||||
|
UploadCloud,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { AuthUser } from "@/types/auth";
|
import type { AuthUser } from "@/types/auth";
|
||||||
import type { CreateCompanyPayload } from "@/services/companies.service";
|
import type { CreateCompanyPayload } from "@/services/companies.service";
|
||||||
@@ -20,8 +25,10 @@ import {
|
|||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
|
SmartFileInput,
|
||||||
} from "@edr/ui-common";
|
} from "@edr/ui-common";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
|
||||||
const TRUCK_TYPES = [
|
const TRUCK_TYPES = [
|
||||||
"Casoni",
|
"Casoni",
|
||||||
@@ -31,6 +38,8 @@ const TRUCK_TYPES = [
|
|||||||
"Others",
|
"Others",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
type TransporterStep = "vehicle" | "documents" | "confirm";
|
||||||
|
|
||||||
const transporterSchema = z
|
const transporterSchema = z
|
||||||
.object({
|
.object({
|
||||||
tinNumber: z.string().length(10, "TIN must be exactly 10 digits"),
|
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)"),
|
.regex(/^\d{4}$/, "Enter a valid year (e.g. 2023)"),
|
||||||
})
|
})
|
||||||
.superRefine((data, ctx) => {
|
.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({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
path: ["plateNumber2"],
|
path: ["plateNumber2"],
|
||||||
@@ -77,19 +89,42 @@ function buildPayload(data: FormData, user: AuthUser): CreateCompanyPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function TransporterForm({
|
export default function TransporterForm({
|
||||||
|
documentSettingCode,
|
||||||
|
documentFiles: controlledFiles,
|
||||||
|
onDocumentFilesChange,
|
||||||
user,
|
user,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
isPending,
|
isPending,
|
||||||
onBack,
|
onBack,
|
||||||
}: {
|
}: {
|
||||||
|
documentSettingCode: string;
|
||||||
|
documentFiles?: Record<string, File | File[] | null>;
|
||||||
|
onDocumentFilesChange?: (
|
||||||
|
files: Record<string, File | File[] | null>,
|
||||||
|
) => void;
|
||||||
user: AuthUser;
|
user: AuthUser;
|
||||||
onSubmit: (data: CreateCompanyPayload) => void;
|
onSubmit: (data: CreateCompanyPayload) => void;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
onBack: () => void;
|
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 {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
|
trigger,
|
||||||
watch,
|
watch,
|
||||||
control,
|
control,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
@@ -108,187 +143,341 @@ export default function TransporterForm({
|
|||||||
|
|
||||||
const truckType = watch("truckType");
|
const truckType = watch("truckType");
|
||||||
const isCasoni = truckType === "Casoni";
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<button
|
<button
|
||||||
type="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"
|
className="mb-4 flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="size-4" />
|
<ChevronLeft className="size-4" />
|
||||||
Change account type
|
Change account type
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="flex items-center justify-center relative px-2">
|
<div className="flex items-center justify-between max-w-lg mx-auto 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">
|
<div className="absolute top-1/2 left-0 w-full h-0.5 bg-border -translate-y-1/2 z-0" />
|
||||||
<Truck className="size-5" />
|
<StepIcon
|
||||||
</div>
|
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>
|
</div>
|
||||||
<p className="text-center text-sm text-muted-foreground mt-3">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit((data) => onSubmit(buildPayload(data, user)))}
|
onSubmit={(e) => e.preventDefault()}
|
||||||
className="flex flex-col gap-4"
|
className="flex flex-col gap-4"
|
||||||
>
|
>
|
||||||
{/* Personal Info (read-only) */}
|
{step === "vehicle" && (
|
||||||
<div className="rounded-lg bg-muted/30 p-4 text-sm text-muted-foreground">
|
<>
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<Info className="size-4" />
|
<Field data-invalid={Boolean(errors.tinNumber)}>
|
||||||
<span className="font-medium text-foreground">Account Holder</span>
|
<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>
|
</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">
|
<div className="flex items-center justify-between pt-2">
|
||||||
<Button type="button" variant="outline" onClick={onBack}>
|
<Button type="button" variant="outline" onClick={prevStep}>
|
||||||
<ChevronLeft />
|
<ArrowLeft />
|
||||||
Change Type
|
{step === "vehicle"
|
||||||
|
? "Change Type"
|
||||||
|
: step === "confirm"
|
||||||
|
? "Back to Documents"
|
||||||
|
: "Back"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button type="submit" disabled={isPending}>
|
<div className="flex items-center gap-3">
|
||||||
{isPending ? (
|
{step === "documents" && (
|
||||||
<>
|
<Button
|
||||||
<Loader2 className="animate-spin" />
|
type="button"
|
||||||
Submitting...
|
variant="outline"
|
||||||
</>
|
onClick={skipDocuments}
|
||||||
) : (
|
disabled={isPending}
|
||||||
"Complete Registration"
|
>
|
||||||
|
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>
|
</div>
|
||||||
</form>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -6,14 +6,12 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Check,
|
Check,
|
||||||
CheckCircle2,
|
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
LoaderCircle,
|
LoaderCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@edr/ui-common";
|
import { Button } from "@edr/ui-common";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { Freight } from "@edr/types";
|
|
||||||
import type { CreateBookingPayload } from "@/services/bookings.service";
|
import type { CreateBookingPayload } from "@/services/bookings.service";
|
||||||
import {
|
import {
|
||||||
BookingFormInputValues,
|
BookingFormInputValues,
|
||||||
@@ -32,13 +30,11 @@ import {
|
|||||||
Step5CargoDetails,
|
Step5CargoDetails,
|
||||||
Step8Review,
|
Step8Review,
|
||||||
} from "./new-booking-form/steps";
|
} from "./new-booking-form/steps";
|
||||||
import useAuth from "@/hooks/useAuth";
|
|
||||||
|
|
||||||
export default function NewBookingPage() {
|
export default function NewBookingPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [step, setStep] = useState(1);
|
const [step, setStep] = useState(1);
|
||||||
const { customer } = useAuth();
|
|
||||||
const { data: referenceData, isLoading: refDataLoading } = useQuery(
|
const { data: referenceData, isLoading: refDataLoading } = useQuery(
|
||||||
api.bookings.referenceData.queryOptions(),
|
api.bookings.referenceData.queryOptions(),
|
||||||
);
|
);
|
||||||
@@ -48,7 +44,7 @@ export default function NewBookingPage() {
|
|||||||
api.bookings.create.call(payload),
|
api.bookings.create.call(payload),
|
||||||
onSuccess: (booking) => {
|
onSuccess: (booking) => {
|
||||||
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
|
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 "";
|
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 =
|
const cargoTypeId =
|
||||||
data.cargoType === "container"
|
data.cargoType === "container"
|
||||||
? findContainerCargoTypeId()
|
? findContainerCargoTypeId()
|
||||||
: (findCargoTypeId(
|
: (findCargoTypeId(data.bulkCommoditytype) ??
|
||||||
data.freightType === "bulk"
|
cargoTree.find((g) => g.code.toLowerCase() === data.freightType)
|
||||||
? data.bulkCommodity
|
?.id ??
|
||||||
: data.breakBulkType,
|
"");
|
||||||
) ?? "");
|
|
||||||
|
|
||||||
const cargoFreeText =
|
const cargoFreeText =
|
||||||
data.cargoType === "container"
|
data.cargoType === "container"
|
||||||
? undefined
|
? undefined
|
||||||
: data.freightType === "bulk" && data.bulkCommodity === "Others"
|
: selectedChild?.show_free_text_box
|
||||||
? data.bulkCommodityOther
|
? data.bulkCommoditytype
|
||||||
: data.freightType === "break_bulk" && data.breakBulkType === "Others"
|
: undefined;
|
||||||
? data.breakBulkTypeOther
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
// ── Build API payload ───────────────────────────────────────────────
|
// ── Build API payload ───────────────────────────────────────────────
|
||||||
const apiPayload: CreateBookingPayload = {
|
const apiPayload: CreateBookingPayload = {
|
||||||
@@ -168,15 +168,16 @@ export default function NewBookingPage() {
|
|||||||
: direction === "domestic"
|
: direction === "domestic"
|
||||||
? "DOMESTIC"
|
? "DOMESTIC"
|
||||||
: "IMPORT",
|
: "IMPORT",
|
||||||
freightType:
|
|
||||||
data.cargoType === "container"
|
|
||||||
? Freight.FreightType.Container
|
|
||||||
: Freight.FreightType.Bulk,
|
|
||||||
cargoTypeId: data.cargoType === "container" ? undefined : cargoTypeId,
|
cargoTypeId: data.cargoType === "container" ? undefined : cargoTypeId,
|
||||||
cargoTotalWeightVgm: totalWeight,
|
cargoTotalWeightVgm: totalWeight,
|
||||||
isHazardous: data.isHazardous,
|
isHazardous: data.isHazardous,
|
||||||
paymentCurrency: "USD",
|
paymentCurrency: "USD",
|
||||||
allowConsolidation: data.consolidationEnabled,
|
allowConsolidation: data.consolidationEnabled,
|
||||||
|
// @ts-ignore
|
||||||
|
freightType:
|
||||||
|
data.cargoType === "container"
|
||||||
|
? ("CONTAINER" as const)
|
||||||
|
: ("BULK" as const),
|
||||||
containers:
|
containers:
|
||||||
data.cargoType === "container"
|
data.cargoType === "container"
|
||||||
? data.containers.map((c) => ({
|
? data.containers.map((c) => ({
|
||||||
@@ -185,7 +186,6 @@ export default function NewBookingPage() {
|
|||||||
vgmPerUnitTons: Number(c.vgm || 0),
|
vgmPerUnitTons: Number(c.vgm || 0),
|
||||||
}))
|
}))
|
||||||
: [],
|
: [],
|
||||||
...(customer?.company?.id ? { companyId: customer.company.id } : {}),
|
|
||||||
...(data.previousContractRef
|
...(data.previousContractRef
|
||||||
? { previousContractId: data.previousContractRef }
|
? { previousContractId: data.previousContractRef }
|
||||||
: {}),
|
: {}),
|
||||||
@@ -207,26 +207,6 @@ export default function NewBookingPage() {
|
|||||||
createMutation.mutate(apiPayload);
|
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 (
|
return (
|
||||||
<form
|
<form
|
||||||
id="new-booking-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">
|
<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" />
|
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-red-500" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold">Submission failed</p>
|
<p className="font-semibold">Failed to save draft</p>
|
||||||
<p className="mt-1 text-red-600">
|
<p className="mt-1 text-red-600">
|
||||||
{createMutation.error instanceof Error
|
{createMutation.error instanceof Error
|
||||||
? createMutation.error.message
|
? createMutation.error.message
|
||||||
@@ -306,9 +286,7 @@ export default function NewBookingPage() {
|
|||||||
) : (
|
) : (
|
||||||
<Check />
|
<Check />
|
||||||
)}
|
)}
|
||||||
{createMutation.isPending
|
{createMutation.isPending ? "Saving Draft..." : "Save as Draft"}
|
||||||
? "Submitting..."
|
|
||||||
: "Submit Contract Request"}
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,19 +1,6 @@
|
|||||||
import { DeepPartial, Path } from "react-hook-form";
|
import { DeepPartial, Path } from "react-hook-form";
|
||||||
import * as z from "zod";
|
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>([
|
export const ETHIOPIA_STATIONS = new Set<string>([
|
||||||
"Addis Ababa",
|
"Addis Ababa",
|
||||||
"Adama",
|
"Adama",
|
||||||
@@ -23,19 +10,6 @@ export const ETHIOPIA_STATIONS = new Set<string>([
|
|||||||
"Dire Dawa",
|
"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 = [
|
export const MOCK_VALID_CONTRACTS = [
|
||||||
"EDR-2024-10001",
|
"EDR-2024-10001",
|
||||||
"EDR-2024-10002",
|
"EDR-2024-10002",
|
||||||
@@ -43,31 +17,6 @@ export const MOCK_VALID_CONTRACTS = [
|
|||||||
"EDR-2022-55442",
|
"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 = [
|
export const STEPS = [
|
||||||
{ id: 1, label: "Contract Type", short: "Contract" },
|
{ id: 1, label: "Contract Type", short: "Contract" },
|
||||||
{ id: 2, label: "Service Type & Mile", short: "Service" },
|
{ id: 2, label: "Service Type & Mile", short: "Service" },
|
||||||
@@ -108,11 +57,8 @@ export const bookingFormSchema = z
|
|||||||
shippingLine: z.string(),
|
shippingLine: z.string(),
|
||||||
cargoType: z.enum(["container", "bulk"], "Select a cargo type."),
|
cargoType: z.enum(["container", "bulk"], "Select a cargo type."),
|
||||||
cargoWeight: z.string(),
|
cargoWeight: z.string(),
|
||||||
freightType: z.enum(["bulk", "break_bulk"]).optional(),
|
freightType: z.string(), // parent group
|
||||||
bulkCommodity: z.string(),
|
bulkCommoditytype: z.string(),
|
||||||
bulkCommodityOther: z.string(),
|
|
||||||
breakBulkType: z.string(),
|
|
||||||
breakBulkTypeOther: z.string(),
|
|
||||||
isHazardous: z.boolean(),
|
isHazardous: z.boolean(),
|
||||||
isRefrigerated: z.boolean(),
|
isRefrigerated: z.boolean(),
|
||||||
containers: z.array(
|
containers: z.array(
|
||||||
@@ -171,42 +117,10 @@ export const bookingFormSchema = z
|
|||||||
(data) =>
|
(data) =>
|
||||||
!(
|
!(
|
||||||
data.cargoType === "bulk" &&
|
data.cargoType === "bulk" &&
|
||||||
data.freightType === "bulk" &&
|
data.freightType &&
|
||||||
!data.bulkCommodity
|
!data.bulkCommoditytype
|
||||||
),
|
),
|
||||||
{ message: "Select a commodity.", path: ["bulkCommodity"] },
|
{ message: "Select a commodity.", path: ["bulkCommoditytype"] },
|
||||||
)
|
|
||||||
.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"],
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
@@ -268,10 +182,7 @@ export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
|
|||||||
destinationYard: "",
|
destinationYard: "",
|
||||||
shippingLine: "",
|
shippingLine: "",
|
||||||
cargoWeight: "",
|
cargoWeight: "",
|
||||||
bulkCommodity: "",
|
bulkCommoditytype: "",
|
||||||
bulkCommodityOther: "",
|
|
||||||
breakBulkType: "",
|
|
||||||
breakBulkTypeOther: "",
|
|
||||||
isHazardous: false,
|
isHazardous: false,
|
||||||
isRefrigerated: false,
|
isRefrigerated: false,
|
||||||
containers: [{ type: "20ft", containerType: "", qty: "1", vgm: "" }],
|
containers: [{ type: "20ft", containerType: "", qty: "1", vgm: "" }],
|
||||||
@@ -300,10 +211,7 @@ export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
|
|||||||
"cargoType",
|
"cargoType",
|
||||||
"cargoWeight",
|
"cargoWeight",
|
||||||
"freightType",
|
"freightType",
|
||||||
"bulkCommodity",
|
"bulkCommoditytype",
|
||||||
"bulkCommodityOther",
|
|
||||||
"breakBulkType",
|
|
||||||
"breakBulkTypeOther",
|
|
||||||
"containers",
|
"containers",
|
||||||
"consolidationEnabled",
|
"consolidationEnabled",
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -44,8 +44,7 @@ export function Step5CargoDetails({
|
|||||||
}) {
|
}) {
|
||||||
const cargoType = form.watch("cargoType");
|
const cargoType = form.watch("cargoType");
|
||||||
const freightType = form.watch("freightType");
|
const freightType = form.watch("freightType");
|
||||||
const bulkCommodity = form.watch("bulkCommodity");
|
const bulkCommoditytype = form.watch("bulkCommoditytype");
|
||||||
const breakBulkType = form.watch("breakBulkType");
|
|
||||||
const containers = form.watch("containers");
|
const containers = form.watch("containers");
|
||||||
|
|
||||||
const { fields, append, remove } = useFieldArray({
|
const { fields, append, remove } = useFieldArray({
|
||||||
@@ -60,13 +59,21 @@ export function Step5CargoDetails({
|
|||||||
);
|
);
|
||||||
}, [referenceData]);
|
}, [referenceData]);
|
||||||
|
|
||||||
const bulkCommodityOptions = useMemo(() => {
|
const freightTypeGroups = useMemo(() => {
|
||||||
if (!referenceData?.cargo_type) return [];
|
if (!referenceData?.cargo_type) return [];
|
||||||
return referenceData.cargo_type.flatMap(
|
return referenceData.cargo_type.filter(
|
||||||
(group) => group.children?.map((c) => c.name) ?? [],
|
(g) => g.code !== "CONTAINER",
|
||||||
);
|
);
|
||||||
}, [referenceData]);
|
}, [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(
|
function getOverweightAlert(
|
||||||
type: "20ft" | "40ft",
|
type: "20ft" | "40ft",
|
||||||
vgm: number,
|
vgm: number,
|
||||||
@@ -122,7 +129,7 @@ export function Step5CargoDetails({
|
|||||||
selected={cargoType === "container"}
|
selected={cargoType === "container"}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
field.onChange("container");
|
field.onChange("container");
|
||||||
form.setValue("freightType", undefined, {
|
form.setValue("freightType", "", {
|
||||||
shouldDirty: true,
|
shouldDirty: true,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
@@ -194,82 +201,42 @@ export function Step5CargoDetails({
|
|||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<Field data-invalid={fieldState.invalid}>
|
<Field data-invalid={fieldState.invalid}>
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<OptionCard
|
{freightTypeGroups.map((group) => {
|
||||||
selected={freightType === "bulk"}
|
const val = group.code.toLowerCase();
|
||||||
onClick={() => field.onChange("bulk")}
|
return (
|
||||||
>
|
<OptionCard
|
||||||
<p className="font-semibold">Bulk</p>
|
key={group.code}
|
||||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
selected={freightType === val}
|
||||||
Coffee, fertilizer, grain, ore, etc.
|
onClick={() => {
|
||||||
</p>
|
field.onChange(val);
|
||||||
</OptionCard>
|
form.setValue("bulkCommoditytype", "", {
|
||||||
<OptionCard
|
shouldDirty: true,
|
||||||
selected={freightType === "break_bulk"}
|
});
|
||||||
onClick={() => field.onChange("break_bulk")}
|
}}
|
||||||
>
|
>
|
||||||
<p className="font-semibold">Break-Bulk</p>
|
<p className="font-semibold">{group.name}</p>
|
||||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
</OptionCard>
|
||||||
Machinery, vehicles, project cargo, etc.
|
);
|
||||||
</p>
|
})}
|
||||||
</OptionCard>
|
|
||||||
</div>
|
</div>
|
||||||
<FieldError errors={[fieldState.error]} />
|
<FieldError errors={[fieldState.error]} />
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{freightType === "bulk" && (
|
{freightType && commodityOptions.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Controller
|
<Controller
|
||||||
name="bulkCommodity"
|
name="bulkCommoditytype"
|
||||||
control={form.control}
|
control={form.control}
|
||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<SelectField
|
<SelectField
|
||||||
field={field}
|
field={field}
|
||||||
error={fieldState.error}
|
error={fieldState.error}
|
||||||
label="Commodity *"
|
label="Cargo type *"
|
||||||
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 *"
|
|
||||||
placeholder="Select type *"
|
placeholder="Select type *"
|
||||||
>
|
>
|
||||||
{bulkCommodityOptions.map((option) => (
|
{commodityOptions.map((option) => (
|
||||||
<SelectItem key={option} value={option}>
|
<SelectItem key={option} value={option}>
|
||||||
{option}
|
{option}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
@@ -277,22 +244,6 @@ export function Step5CargoDetails({
|
|||||||
</SelectField>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export interface Customer {
|
|||||||
country: string;
|
country: string;
|
||||||
address: string;
|
address: string;
|
||||||
notes: string;
|
notes: string;
|
||||||
|
documentsComplete: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const seedCustomers: Customer[] = [
|
const seedCustomers: Customer[] = [
|
||||||
@@ -30,6 +31,7 @@ const seedCustomers: Customer[] = [
|
|||||||
country: "Ethiopia",
|
country: "Ethiopia",
|
||||||
address: "Bole Road, Sub-City 03, Building 17",
|
address: "Bole Road, Sub-City 03, Building 17",
|
||||||
notes: "Top-tier importer. Prefers weekly invoicing.",
|
notes: "Top-tier importer. Prefers weekly invoicing.",
|
||||||
|
documentsComplete: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
@@ -44,6 +46,7 @@ const seedCustomers: Customer[] = [
|
|||||||
country: "Ethiopia",
|
country: "Ethiopia",
|
||||||
address: "Industrial Park, Zone B, Warehouse 4",
|
address: "Industrial Park, Zone B, Warehouse 4",
|
||||||
notes: "Awaiting compliance documents.",
|
notes: "Awaiting compliance documents.",
|
||||||
|
documentsComplete: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 3,
|
id: 3,
|
||||||
@@ -58,6 +61,7 @@ const seedCustomers: Customer[] = [
|
|||||||
country: "Djibouti",
|
country: "Djibouti",
|
||||||
address: "Port Quarter, Avenue 26, Block 9",
|
address: "Port Quarter, Avenue 26, Block 9",
|
||||||
notes: "Account paused since last quarter.",
|
notes: "Account paused since last quarter.",
|
||||||
|
documentsComplete: true,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -99,6 +103,7 @@ const generated: Customer[] = extras.map((entry, i) => {
|
|||||||
country: entry.country,
|
country: entry.country,
|
||||||
address: `Block ${i + 1}, Street ${10 + i}, Quarter ${(i % 5) + 1}`,
|
address: `Block ${i + 1}, Street ${10 + i}, Quarter ${(i % 5) + 1}`,
|
||||||
notes: `Mock customer #${id}.`,
|
notes: `Mock customer #${id}.`,
|
||||||
|
documentsComplete: i % 3 === 0,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -8,13 +8,16 @@ import type {
|
|||||||
UpdateFileUploadFieldDto,
|
UpdateFileUploadFieldDto,
|
||||||
UpdateFileUploadSettingDto,
|
UpdateFileUploadSettingDto,
|
||||||
} from "@/types/fileUploadSettings";
|
} from "@/types/fileUploadSettings";
|
||||||
import { bookingsService, CreateBookingPayload } from "./bookings.service";
|
import {
|
||||||
|
bookingsService,
|
||||||
|
CreateBookingPayload,
|
||||||
|
GeneratePriceResponse,
|
||||||
|
} from "./bookings.service";
|
||||||
import { consignmentsService } from "./consignments.service";
|
import { consignmentsService } from "./consignments.service";
|
||||||
import { trackingService } from "./tracking.service";
|
import { trackingService } from "./tracking.service";
|
||||||
import { fileUploadSettingsService } from "./fileUploadSettings.service";
|
import { fileUploadSettingsService } from "./fileUploadSettings.service";
|
||||||
import { dropdownSettingsService } from "./dropdownSettings.service";
|
import { dropdownSettingsService } from "./dropdownSettings.service";
|
||||||
import { authService } from "./auth.service";
|
import { authService } from "./auth.service";
|
||||||
import { customersService } from "./customers.service";
|
|
||||||
import { companiesService } from "./companies.service";
|
import { companiesService } from "./companies.service";
|
||||||
import {
|
import {
|
||||||
CreateDropdownOptionDto,
|
CreateDropdownOptionDto,
|
||||||
@@ -24,15 +27,11 @@ import {
|
|||||||
UpdateDropdownOptionDto,
|
UpdateDropdownOptionDto,
|
||||||
UpdateDropdownSettingDto,
|
UpdateDropdownSettingDto,
|
||||||
} from "@/types/dropdownSettings";
|
} from "@/types/dropdownSettings";
|
||||||
import {
|
|
||||||
CreateCustomerDto,
|
|
||||||
Customer,
|
|
||||||
UpdateCustomerDto,
|
|
||||||
} from "@/types/customers";
|
|
||||||
import type {
|
import type {
|
||||||
CompanyInfoResponse,
|
CompanyInfoResponse,
|
||||||
CreateCompanyPayload,
|
CreateCompanyPayload,
|
||||||
} from "./companies.service";
|
} from "./companies.service";
|
||||||
|
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
|
||||||
import type {
|
import type {
|
||||||
AuthUser,
|
AuthUser,
|
||||||
GenerateVerificationCodePayload,
|
GenerateVerificationCodePayload,
|
||||||
@@ -89,40 +88,6 @@ export const api = {
|
|||||||
logout: endpoint<void, void>("auth", "logout", authService.logout),
|
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: {
|
companies: {
|
||||||
getInfo: endpoint<void, CompanyInfoResponse | null>(
|
getInfo: endpoint<void, CompanyInfoResponse | null>(
|
||||||
"companies",
|
"companies",
|
||||||
@@ -135,6 +100,18 @@ export const api = {
|
|||||||
"create",
|
"create",
|
||||||
companiesService.create,
|
companiesService.create,
|
||||||
),
|
),
|
||||||
|
|
||||||
|
getProfile: endpoint<void, ProfileResponse>(
|
||||||
|
"companies",
|
||||||
|
"getProfile",
|
||||||
|
companiesService.getProfile,
|
||||||
|
),
|
||||||
|
|
||||||
|
updateProfile: endpoint<UpdateProfilePayload, ProfileResponse>(
|
||||||
|
"companies",
|
||||||
|
"updateProfile",
|
||||||
|
companiesService.updateProfile,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
bookings: {
|
bookings: {
|
||||||
@@ -165,6 +142,31 @@ export const api = {
|
|||||||
remove: endpoint<{ id: string }, void>("bookings", "remove", ({ id }) =>
|
remove: endpoint<{ id: string }, void>("bookings", "remove", ({ id }) =>
|
||||||
bookingsService.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: {
|
consignments: {
|
||||||
|
|||||||
@@ -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 {
|
export interface SignContractPayload {
|
||||||
role: "CUSTOMER" | "STAFF";
|
role: "CUSTOMER" | "STAFF";
|
||||||
signatureImageBase64: string;
|
signatureImageBase64: string;
|
||||||
@@ -53,6 +68,42 @@ export const bookingsService = {
|
|||||||
await client.delete(`/api/bookings/${id}`);
|
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> => {
|
getContractView: async (id: string): Promise<ContractView> => {
|
||||||
const { data } = await client.get(B.CONTRACT_VIEW(id));
|
const { data } = await client.get(B.CONTRACT_VIEW(id));
|
||||||
return data.data ?? data;
|
return data.data ?? data;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { client } from "@/utils/api";
|
|||||||
import { unwrap } from "@/utils/endpoint";
|
import { unwrap } from "@/utils/endpoint";
|
||||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||||
import type { ApiResponse } from "@/types/apiResponse";
|
import type { ApiResponse } from "@/types/apiResponse";
|
||||||
|
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
|
||||||
import { isAxiosError } from "axios";
|
import { isAxiosError } from "axios";
|
||||||
|
|
||||||
export interface ExternalProfileResponse {
|
export interface ExternalProfileResponse {
|
||||||
@@ -80,4 +81,37 @@ export const companiesService = {
|
|||||||
);
|
);
|
||||||
return unwrap(response.data);
|
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);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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));
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { api } from "../crud";
|
|
||||||
|
|
||||||
import { URL_CONSTANTS } from "../../constants/URLS"
|
|
||||||
43
apps/edr-freight-web/portal/src/types/profile.ts
Normal file
43
apps/edr-freight-web/portal/src/types/profile.ts
Normal 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
3
pnpm-lock.yaml
generated
@@ -307,6 +307,9 @@ importers:
|
|||||||
react-hook-form:
|
react-hook-form:
|
||||||
specifier: ^7.76.0
|
specifier: ^7.76.0
|
||||||
version: 7.76.0(react@19.2.6)
|
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:
|
react-router-dom:
|
||||||
specifier: ^6.27.0
|
specifier: ^6.27.0
|
||||||
version: 6.30.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
version: 6.30.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
|
|||||||
5
tasks.md
5
tasks.md
@@ -1,5 +0,0 @@
|
|||||||
- In ./apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step1-contract-type.tsx the input field for the previous contract ref should be a select field with the list of contracts from the API
|
|
||||||
- the "Equipment Return" field in ./apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step3-first-last-mile.tsx should be a toggle like Last Mile - Delivery and should appear only if the last mile is toggled. and also add "( Door to Port)" to first mile description and vice versa to the last mile
|
|
||||||
- add Type of container- Dry container, high cubic containers , reefer containers, open top containers, flat rack, tank container, open side containers to ./apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx
|
|
||||||
- merge the "Unpaired 20ft Container" from ./apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step6-wagon-allocation.tsx to step 4 and fully remove the step 5
|
|
||||||
- add Type of Shipping lines - MSC, CMA CGM, Evergreen, COSCO, Hapag-Lloyd, ONE, Yang Ming, ZIM, Messina Line, Safmarine, Wan Hai, Ethiopian Shipping Lines (ESLSE) to ./apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx
|
|
||||||
Reference in New Issue
Block a user