mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 21:08:12 +00:00
generate contract by the system, add waggon type,fix ui
This commit is contained in:
@@ -47,6 +47,7 @@ import { TrainsModule } from "./modules/trains/trains.module";
|
||||
import { WagonsModule } from './modules/wagons/wagons.module';
|
||||
import { ContainersModule } from './modules/container-management/containers.module';
|
||||
import { CargoesModule } from './modules/cargoes/cargoes.module';
|
||||
import { RoutesModule } from './modules/routes/routes.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -98,6 +99,7 @@ import { CargoesModule } from './modules/cargoes/cargoes.module';
|
||||
WagonsModule,
|
||||
ContainersModule,
|
||||
CargoesModule,
|
||||
RoutesModule,
|
||||
],
|
||||
providers: [EdrOrgSeeder, DemoUsersSeeder,FreightStaffUsersSeeder, DemoBookingsSeeder, PricingDataSeeder, FileUploadSettingsSeeder],
|
||||
})
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
// apps/edr-freight-api/src/data-source.ts
|
||||
import 'dotenv/config';
|
||||
import { DataSource } from 'typeorm';
|
||||
//import { ensurePostgresSchemas } from './utils/ensure-postgres-schemas'; // adjust path if needed
|
||||
|
||||
export const AppDataSource = new DataSource({
|
||||
type: 'postgres',
|
||||
host: 'localhost',
|
||||
port: 5432,
|
||||
username: 'postgres',
|
||||
password: '', // Laragon default: empty
|
||||
database: 'edr_freight',
|
||||
host: process.env.DB_HOST ?? 'localhost',
|
||||
port: Number(process.env.DB_PORT ?? 5432),
|
||||
username: process.env.DB_USER ?? 'postgres',
|
||||
password: process.env.DB_PASSWORD ?? '',
|
||||
database: process.env.DB_NAME ?? 'edr_freight',
|
||||
schema: 'freight', // default schema for entities without an explicit schema
|
||||
entities: [__dirname + '/**/*.entity{.ts,.js}'],
|
||||
migrations: [__dirname + '/migrations/*{.ts,.js}'],
|
||||
@@ -17,4 +18,4 @@ export const AppDataSource = new DataSource({
|
||||
});
|
||||
|
||||
// Optional: call ensurePostgresSchemas before initializing
|
||||
// But you can also run it separately.
|
||||
// But you can also run it separately.
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddRoutesAndExtendLocomotives1750100000000 implements MigrationInterface {
|
||||
name = 'AddRoutesAndExtendLocomotives1750100000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.locomotives
|
||||
ADD COLUMN IF NOT EXISTS locomotive_type VARCHAR(20) NOT NULL DEFAULT 'DIESEL',
|
||||
ADD COLUMN IF NOT EXISTS max_train_length_meters NUMERIC(10,3) NOT NULL DEFAULT 760,
|
||||
ADD COLUMN IF NOT EXISTS power_kw NUMERIC(10,3) NULL,
|
||||
ADD COLUMN IF NOT EXISTS traction_force_kn NUMERIC(10,3) NULL,
|
||||
ADD COLUMN IF NOT EXISTS max_speed_kmh NUMERIC(10,3) NULL;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.locomotives
|
||||
SET status = 'OUT_OF_SERVICE'
|
||||
WHERE status = 'INACTIVE';
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.routes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(120) NOT NULL UNIQUE,
|
||||
origin_yard_id UUID NOT NULL REFERENCES freight.yards(id),
|
||||
destination_yard_id UUID NOT NULL REFERENCES freight.yards(id),
|
||||
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.route_milestones (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
route_id UUID NOT NULL REFERENCES freight.routes(id) ON DELETE CASCADE,
|
||||
yard_id UUID NOT NULL REFERENCES freight.yards(id),
|
||||
sequence_no INT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ NULL,
|
||||
CONSTRAINT uq_route_milestones_route_sequence UNIQUE (route_id, sequence_no)
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_routes_origin_yard_id
|
||||
ON freight.routes(origin_yard_id);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_routes_destination_yard_id
|
||||
ON freight.routes(destination_yard_id);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_routes_is_active
|
||||
ON freight.routes(is_active);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_route_milestones_route_id
|
||||
ON freight.route_milestones(route_id);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_route_milestones_yard_id
|
||||
ON freight.route_milestones(yard_id);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.route_milestones;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.routes;`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.locomotives
|
||||
DROP COLUMN IF EXISTS max_speed_kmh,
|
||||
DROP COLUMN IF EXISTS traction_force_kn,
|
||||
DROP COLUMN IF EXISTS power_kw,
|
||||
DROP COLUMN IF EXISTS max_train_length_meters,
|
||||
DROP COLUMN IF EXISTS locomotive_type;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.locomotives
|
||||
SET status = 'INACTIVE'
|
||||
WHERE status = 'OUT_OF_SERVICE';
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddRouteToTrainSchedules1750300000000 implements MigrationInterface {
|
||||
name = 'AddRouteToTrainSchedules1750300000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_schedules
|
||||
ADD COLUMN IF NOT EXISTS route_id UUID NULL;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
WHERE conname = 'fk_train_schedules_route'
|
||||
) THEN
|
||||
ALTER TABLE freight.train_schedules
|
||||
ADD CONSTRAINT fk_train_schedules_route
|
||||
FOREIGN KEY (route_id) REFERENCES freight.routes(id);
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_train_schedules_route_id
|
||||
ON freight.train_schedules(route_id);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_train_schedules_route_id;`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_schedules
|
||||
DROP CONSTRAINT IF EXISTS fk_train_schedules_route;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_schedules
|
||||
DROP COLUMN IF EXISTS route_id;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -25,10 +25,10 @@ export const BOOKING_LIST_TABS: ReadonlyArray<{
|
||||
key: 'approved_contract',
|
||||
statuses: ['APPROVED', 'CONTRACT_READY', 'SIGNED_CUSTOMER', 'FULLY_EXECUTED'],
|
||||
},
|
||||
{ key: 'payment', statuses: ['FULLY_EXECUTED', 'PAID'] },
|
||||
{ key: 'payment', statuses: ['FULLY_EXECUTED'] },
|
||||
{
|
||||
key: 'operations',
|
||||
statuses: ['IN_TRANSIT', 'PENDING_CONSOLIDATION', 'CONSOLIDATED'],
|
||||
statuses: ['IN_TRANSIT', 'PENDING_CONSOLIDATION', 'CONSOLIDATED','PAID'],
|
||||
},
|
||||
{ key: 'completed', statuses: ['COMPLETED'] },
|
||||
{ key: 'closed', statuses: ['REJECTED', 'CANCELLED'] },
|
||||
|
||||
@@ -34,8 +34,8 @@ export function computeNextStep(
|
||||
};
|
||||
case 'APPROVED':
|
||||
return {
|
||||
action: 'GENERATE_CONTRACT',
|
||||
description: 'Generate the contract document',
|
||||
action: 'CUSTOMER_SIGN',
|
||||
description: 'Contract generated; customer must sign',
|
||||
};
|
||||
case 'CONTRACT_READY':
|
||||
return {
|
||||
@@ -49,8 +49,8 @@ export function computeNextStep(
|
||||
};
|
||||
case 'FULLY_EXECUTED':
|
||||
return {
|
||||
action: 'PAY',
|
||||
description: 'Complete in-app payment',
|
||||
action: 'AWAIT_PAYMENT',
|
||||
description: 'Awaiting customer payment',
|
||||
};
|
||||
case 'PAID':
|
||||
return {
|
||||
|
||||
@@ -3,45 +3,57 @@ import { BookingsRepository } from './bookings.repository';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import { assertBookingStatus } from './booking-status.util';
|
||||
import { InAppPaymentReceiptDto } from './dto/pay-booking.dto';
|
||||
import { PaymentService } from '../payment/payment.service';
|
||||
|
||||
export interface InAppPaymentReceipt extends InAppPaymentReceiptDto {}
|
||||
export interface InAppPaymentReceipt extends InAppPaymentReceiptDto { }
|
||||
|
||||
@Injectable()
|
||||
export class BookingPaymentService {
|
||||
constructor(private readonly bookingsRepository: BookingsRepository) {}
|
||||
constructor(private readonly bookingsRepository: BookingsRepository, private readonly paymentService: PaymentService) { }
|
||||
|
||||
async pay(
|
||||
bookingId: string,
|
||||
): Promise<{ booking: Booking; receipt: InAppPaymentReceipt }> {
|
||||
): Promise<{ redirectUrl: string }> {
|
||||
const booking = await this.requireBooking(bookingId);
|
||||
assertBookingStatus(booking, ['FULLY_EXECUTED']);
|
||||
|
||||
const receipt = this.buildMockReceipt(booking);
|
||||
// const receipt = this.buildMockReceipt(booking);
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
status: 'PAID',
|
||||
paymentStatus: 'PAID',
|
||||
} as never);
|
||||
|
||||
return { booking: updated!, receipt };
|
||||
}
|
||||
|
||||
private buildMockReceipt(booking: Booking): InAppPaymentReceipt {
|
||||
const timestamp = Date.now();
|
||||
const isEtb = booking.paymentCurrency === 'ETB';
|
||||
const prefix = isEtb ? 'TB' : 'CARD';
|
||||
const provider = isEtb ? 'TELEBIRR' : 'CARD';
|
||||
// const updated = await this.bookingsRepository.update(bookingId, {
|
||||
// status: 'PAID',
|
||||
// paymentStatus: 'PAID',
|
||||
// } as never);
|
||||
const resp = await this.paymentService.pay(booking.totalAmount, "ETB", "telebirr", "payment for booking", 'booking', (_) => {
|
||||
return new Promise((resp, _) => {
|
||||
resp({
|
||||
id: booking.id,
|
||||
type: "booking"
|
||||
})
|
||||
});
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
provider,
|
||||
providerRef: `${prefix}-${booking.reference}-${timestamp}`,
|
||||
amount: booking.totalAmount,
|
||||
currency: booking.paymentCurrency,
|
||||
paidAt: new Date().toISOString(),
|
||||
};
|
||||
redirectUrl: resp.clientAction.type == "REDIRECT" ? `http://localhost:3001/api/payments/telebirr/${booking.id}` : ""
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// private buildMockReceipt(booking: Booking): InAppPaymentReceipt {
|
||||
// const timestamp = Date.now();
|
||||
// const isEtb = booking.paymentCurrency === 'ETB';
|
||||
// const prefix = isEtb ? 'TB' : 'CARD';
|
||||
// const provider = isEtb ? 'TELEBIRR' : 'CARD';
|
||||
|
||||
// return {
|
||||
// success: true,
|
||||
// provider,
|
||||
// providerRef: `${prefix}-${booking.reference}-${timestamp}`,
|
||||
// amount: booking.totalAmount,
|
||||
// currency: booking.paymentCurrency,
|
||||
// paidAt: new Date().toISOString(),
|
||||
// };
|
||||
// }
|
||||
|
||||
private async requireBooking(id: string): Promise<Booking> {
|
||||
const booking = await this.bookingsRepository.findById(id);
|
||||
if (!booking) throw new NotFoundException(`Booking ${id} not found`);
|
||||
|
||||
@@ -185,6 +185,11 @@ export class BookingTransitionService {
|
||||
await this.bookingsRepository.update(bookingId, updates as never);
|
||||
}
|
||||
|
||||
if (allDone) {
|
||||
const generated = await this.contractService.generateContract(bookingId);
|
||||
return this.bookingsService.findById(generated.id);
|
||||
}
|
||||
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ import { ContractPricingScheduleBuilder } from '../../contracts/contract-pricing
|
||||
import { ContractRendererService } from '../../contracts/contract-renderer.service';
|
||||
import { ContractTemplateResolver } from '../../contracts/contract-template.resolver';
|
||||
import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder';
|
||||
import { PaymentModule } from '../payment/payment.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -40,6 +41,7 @@ import { ContractViewModelBuilder } from '../../contracts/contract-view-model.bu
|
||||
BookingReviewNote,
|
||||
BookingContractSignature,
|
||||
]),
|
||||
PaymentModule,
|
||||
FilesModule,
|
||||
MinioModule,
|
||||
CompaniesModule,
|
||||
|
||||
@@ -2,10 +2,10 @@ import { Controller, Param, ParseUUIDPipe, Post } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { BookingPaymentService } from './booking-payment.service';
|
||||
import { BookingTransitionService } from './booking-transition.service';
|
||||
import { InAppPaymentReceiptDto } from './dto/pay-booking.dto';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import { BookingNextStep } from './booking-next-step.util';
|
||||
// import { BookingTransitionService } from './booking-transition.service';
|
||||
// import { InAppPaymentReceiptDto } from './dto/pay-booking.dto';
|
||||
// import { Booking } from './entities/booking.entity';
|
||||
// import { BookingNextStep } from './booking-next-step.util';
|
||||
|
||||
@ApiTags('payments')
|
||||
@ApiBearerAuth()
|
||||
@@ -13,22 +13,15 @@ import { BookingNextStep } from './booking-next-step.util';
|
||||
export class PayController {
|
||||
constructor(
|
||||
private readonly paymentService: BookingPaymentService,
|
||||
private readonly transitionService: BookingTransitionService,
|
||||
) {}
|
||||
// private readonly transitionService: BookingTransitionService,
|
||||
) { }
|
||||
|
||||
@Post(':id/payment/pay')
|
||||
@ApiOperation({ summary: 'Complete in-app payment (mock)' })
|
||||
@ApiOkResponse({ description: 'Enriched booking with ephemeral payment receipt' })
|
||||
async pay(@Param('id', ParseUUIDPipe) id: string): Promise<
|
||||
Booking & {
|
||||
latestChangeRequestNote?: string | null;
|
||||
contractSummary?: string | null;
|
||||
nextStep: BookingNextStep | null;
|
||||
paymentReceipt: InAppPaymentReceiptDto;
|
||||
}
|
||||
> {
|
||||
const { booking, receipt } = await this.paymentService.pay(id);
|
||||
const abstract = await this.transitionService.enrichBookingResponse(booking);
|
||||
return { ...abstract, paymentReceipt: receipt };
|
||||
async pay(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return await this.paymentService.pay(id);
|
||||
// const abstract = await this.transitionService.enrichBookingResponse(booking);
|
||||
// return { ...abstract, paymentReceipt: receipt };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsIn, IsNumber, IsOptional, IsString, MaxLength, Min } from 'class-validator';
|
||||
|
||||
import { LOCOMOTIVE_STATUSES, LOCOMOTIVE_TYPES } from '../entities/locomotive.entity';
|
||||
|
||||
export class CreateLocomotiveDto {
|
||||
@ApiProperty({ example: 'LOCO-001' })
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
code!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
name?: string;
|
||||
|
||||
@ApiProperty({ enum: LOCOMOTIVE_TYPES })
|
||||
@IsIn([...LOCOMOTIVE_TYPES])
|
||||
locomotiveType!: string;
|
||||
|
||||
@ApiProperty({ enum: LOCOMOTIVE_STATUSES })
|
||||
@IsIn([...LOCOMOTIVE_STATUSES])
|
||||
status!: string;
|
||||
|
||||
@ApiProperty({ example: 3500 })
|
||||
@Transform(({ value }) => Number(value))
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
maxPullWeightTons!: number;
|
||||
|
||||
@ApiProperty({ example: 760 })
|
||||
@Transform(({ value }) => Number(value))
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
maxTrainLengthMeters!: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 4200 })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
powerKw?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 300 })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
tractionForceKn?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 120 })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
maxSpeedKmh?: number;
|
||||
}
|
||||
@@ -1,11 +1,16 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsIn, IsOptional } from 'class-validator';
|
||||
|
||||
import { LOCOMOTIVE_STATUSES } from '../entities/locomotive.entity';
|
||||
import { LOCOMOTIVE_STATUSES, LOCOMOTIVE_TYPES } from '../entities/locomotive.entity';
|
||||
|
||||
export class FilterLocomotivesDto {
|
||||
@ApiPropertyOptional({ enum: LOCOMOTIVE_STATUSES })
|
||||
@IsOptional()
|
||||
@IsIn([...LOCOMOTIVE_STATUSES])
|
||||
status?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: LOCOMOTIVE_TYPES })
|
||||
@IsOptional()
|
||||
@IsIn([...LOCOMOTIVE_TYPES])
|
||||
locomotiveType?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
|
||||
import { CreateLocomotiveDto } from './create-locomotive.dto';
|
||||
|
||||
export class UpdateLocomotiveDto extends PartialType(CreateLocomotiveDto) {}
|
||||
@@ -7,10 +7,13 @@ export const LOCOMOTIVE_STATUSES = [
|
||||
'AVAILABLE',
|
||||
'ASSIGNED',
|
||||
'MAINTENANCE',
|
||||
'INACTIVE',
|
||||
'OUT_OF_SERVICE',
|
||||
] as const;
|
||||
|
||||
export const LOCOMOTIVE_TYPES = ['DIESEL', 'ELECTRIC'] as const;
|
||||
|
||||
export type LocomotiveStatus = (typeof LOCOMOTIVE_STATUSES)[number];
|
||||
export type LocomotiveType = (typeof LOCOMOTIVE_TYPES)[number];
|
||||
|
||||
@Entity({ schema: 'freight', name: 'locomotives' })
|
||||
@Index(['code'])
|
||||
@@ -22,14 +25,26 @@ export class Locomotive extends BaseEntity {
|
||||
@Column({ name: 'name', type: 'varchar', length: 100, nullable: true })
|
||||
name?: string | null;
|
||||
|
||||
@Column({ name: 'locomotive_type', type: 'varchar', length: 20, default: 'DIESEL' })
|
||||
locomotiveType!: LocomotiveType;
|
||||
|
||||
@Column({ name: 'max_pull_weight_tons', type: 'numeric', precision: 10, scale: 3 })
|
||||
maxPullWeightTons!: number;
|
||||
|
||||
@Column({ name: 'max_train_length_meters', type: 'numeric', precision: 10, scale: 3, default: 760 })
|
||||
maxTrainLengthMeters!: number;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 20, default: 'AVAILABLE' })
|
||||
status!: LocomotiveStatus;
|
||||
|
||||
@Column({ name: 'available_from', type: 'timestamptz', nullable: true })
|
||||
availableFrom?: Date | null;
|
||||
@Column({ name: 'power_kw', type: 'numeric', precision: 10, scale: 3, nullable: true })
|
||||
powerKw?: number | null;
|
||||
|
||||
@Column({ name: 'traction_force_kn', type: 'numeric', precision: 10, scale: 3, nullable: true })
|
||||
tractionForceKn?: number | null;
|
||||
|
||||
@Column({ name: 'max_speed_kmh', type: 'numeric', precision: 10, scale: 3, nullable: true })
|
||||
maxSpeedKmh?: number | null;
|
||||
|
||||
@OneToMany(() => TrainSet, (trainSet) => trainSet.locomotive)
|
||||
trainSets?: TrainSet[];
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { CreateLocomotiveDto } from './dto/create-locomotive.dto';
|
||||
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
|
||||
import { UpdateLocomotiveDto } from './dto/update-locomotive.dto';
|
||||
import { LocomotivesService } from './locomotives.service';
|
||||
|
||||
@ApiTags('locomotives')
|
||||
@@ -15,4 +17,28 @@ export class LocomotivesController {
|
||||
findAll(@Query() filter: FilterLocomotivesDto) {
|
||||
return this.locomotivesService.findAll(filter);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get a locomotive by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.locomotivesService.findById(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a locomotive' })
|
||||
create(@Body() dto: CreateLocomotiveDto) {
|
||||
return this.locomotivesService.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'Update a locomotive' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLocomotiveDto) {
|
||||
return this.locomotivesService.update(id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/decommission')
|
||||
@ApiOperation({ summary: 'Decommission a locomotive' })
|
||||
decommission(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.locomotivesService.decommission(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
|
||||
import { CreateLocomotiveDto } from './dto/create-locomotive.dto';
|
||||
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
|
||||
import { Locomotive, type LocomotiveStatus } from './entities/locomotive.entity';
|
||||
import { UpdateLocomotiveDto } from './dto/update-locomotive.dto';
|
||||
import { Locomotive, type LocomotiveStatus, type LocomotiveType } from './entities/locomotive.entity';
|
||||
import { LocomotivesRepository } from './locomotives.repository';
|
||||
|
||||
@Injectable()
|
||||
@@ -10,13 +12,36 @@ export class LocomotivesService {
|
||||
|
||||
findAll(filter: FilterLocomotivesDto): Promise<Locomotive[]> {
|
||||
return this.locomotivesRepository.findAll({
|
||||
where: filter.status
|
||||
? { status: filter.status as LocomotiveStatus }
|
||||
: undefined,
|
||||
where: {
|
||||
...(filter.status ? { status: filter.status as LocomotiveStatus } : {}),
|
||||
...(filter.locomotiveType
|
||||
? { locomotiveType: filter.locomotiveType as LocomotiveType }
|
||||
: {}),
|
||||
},
|
||||
order: { code: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async create(dto: CreateLocomotiveDto): Promise<Locomotive> {
|
||||
const [existing] = await this.locomotivesRepository.findAll({ where: { code: dto.code } });
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictException(`Locomotive code ${dto.code} already exists`);
|
||||
}
|
||||
|
||||
return this.locomotivesRepository.create({
|
||||
code: dto.code,
|
||||
name: dto.name?.trim() || null,
|
||||
locomotiveType: dto.locomotiveType as LocomotiveType,
|
||||
status: dto.status as LocomotiveStatus,
|
||||
maxPullWeightTons: dto.maxPullWeightTons,
|
||||
maxTrainLengthMeters: dto.maxTrainLengthMeters,
|
||||
powerKw: dto.powerKw ?? null,
|
||||
tractionForceKn: dto.tractionForceKn ?? null,
|
||||
maxSpeedKmh: dto.maxSpeedKmh ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Locomotive> {
|
||||
const locomotive = await this.locomotivesRepository.findById(id);
|
||||
|
||||
@@ -26,4 +51,48 @@ export class LocomotivesService {
|
||||
|
||||
return locomotive;
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateLocomotiveDto): Promise<Locomotive> {
|
||||
const locomotive = await this.findById(id);
|
||||
|
||||
if (dto.code && dto.code !== locomotive.code) {
|
||||
const [existing] = await this.locomotivesRepository.findAll({ where: { code: dto.code } });
|
||||
if (existing && existing.id !== id) {
|
||||
throw new ConflictException(`Locomotive code ${dto.code} already exists`);
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await this.locomotivesRepository.update(id, {
|
||||
...dto,
|
||||
locomotiveType:
|
||||
dto.locomotiveType === undefined ? locomotive.locomotiveType : dto.locomotiveType as LocomotiveType,
|
||||
status: dto.status === undefined ? locomotive.status : dto.status as LocomotiveStatus,
|
||||
name: dto.name === undefined ? locomotive.name : dto.name?.trim() || null,
|
||||
powerKw: dto.powerKw === undefined ? locomotive.powerKw : dto.powerKw ?? null,
|
||||
tractionForceKn:
|
||||
dto.tractionForceKn === undefined ? locomotive.tractionForceKn : dto.tractionForceKn ?? null,
|
||||
maxSpeedKmh:
|
||||
dto.maxSpeedKmh === undefined ? locomotive.maxSpeedKmh : dto.maxSpeedKmh ?? null,
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
throw new NotFoundException(`Locomotive ${id} not found`);
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async decommission(id: string): Promise<Locomotive> {
|
||||
await this.findById(id);
|
||||
|
||||
const updated = await this.locomotivesRepository.update(id, {
|
||||
status: 'OUT_OF_SERVICE',
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
throw new NotFoundException(`Locomotive ${id} not found`);
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +1,42 @@
|
||||
import { Controller, Get, NotFoundException, Param, ParseUUIDPipe, Post, Res } from "@nestjs/common";
|
||||
import { PaymentService } from "./payment.service";
|
||||
import { Public } from "@edr/api-common";
|
||||
import { randomUUID } from "crypto";
|
||||
// import { randomUUID } from "crypto";
|
||||
import { Response } from "express"
|
||||
|
||||
@Public()
|
||||
@Controller("payments")
|
||||
export class PaymentController {
|
||||
constructor(private readonly paymentService: PaymentService) { }
|
||||
constructor(private readonly paymentService: PaymentService,) { }
|
||||
|
||||
|
||||
@Get("/receipts/:orderId/html")
|
||||
async genReceipt(@Param("orderId") orderId: string, @Res() res: Response) {
|
||||
const filled = await this.paymentService.genReceiptHtml(orderId);
|
||||
return res.send(filled)
|
||||
}
|
||||
// @Get("/receipts/:orderId/html")
|
||||
// async genReceipt(@Param("orderId") orderId: string, @Res() res: Response) {
|
||||
// const filled = await this.paymentService.genReceiptHtml(orderId);
|
||||
// return res.send(filled)
|
||||
// }
|
||||
|
||||
@Post("/initiate/booking")
|
||||
async initiatePayment() {
|
||||
// @Post("/initiate/booking")
|
||||
// async initiatePayment() {
|
||||
|
||||
//Only for testing..
|
||||
const description = "booking"
|
||||
const data = await this.paymentService.pay(20, "ETB", "telebirr", description, "booking", (_) => {
|
||||
return new Promise((resp, _) => {
|
||||
resp({
|
||||
id: randomUUID(),
|
||||
type: "booking"
|
||||
})
|
||||
});
|
||||
})
|
||||
// //Only for testing..
|
||||
// const description = "Booking for contact"
|
||||
// const price = 2000
|
||||
// const data = await this.paymentService.pay(price, "ETB", "telebirr", description, "booking", (_) => {
|
||||
// return new Promise((resp, _) => {
|
||||
// resp({
|
||||
// id: randomUUID(),
|
||||
// type: "booking"
|
||||
// })
|
||||
// });
|
||||
// })
|
||||
|
||||
return data
|
||||
// return data
|
||||
// }
|
||||
|
||||
@Post("/bookings/check-payment/:orderId")
|
||||
checkPayment(@Param("orderId", ParseUUIDPipe) orderId: string) {
|
||||
return this.paymentService.checkStatusAndUpdate(orderId)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -7,11 +7,11 @@ import { ConfigModule } from "@nestjs/config";
|
||||
import { PaymentRepository } from "./payment.repository";
|
||||
import { WebhookController } from "./webhooks/webhook.controller";
|
||||
import { TelebirrWebhookService } from "./webhooks/providers/telebirr.service";
|
||||
import { BookingsModule } from "../bookings/bookings.module";
|
||||
|
||||
@Module({
|
||||
imports: [HttpModule, ConfigModule, BookingsModule],
|
||||
imports: [HttpModule, ConfigModule],
|
||||
providers: [PaymentRepository, PaymentTelebirrStrategy, PaymentService, TelebirrWebhookService],
|
||||
controllers: [PaymentController, WebhookController]
|
||||
controllers: [PaymentController, WebhookController],
|
||||
exports: [PaymentService]
|
||||
})
|
||||
export class PaymentModule { }
|
||||
@@ -10,6 +10,8 @@ import * as crypto from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as Handlebars from 'handlebars';
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
|
||||
|
||||
type PaymentMethod = PaymentEntity["method"]
|
||||
@@ -20,6 +22,7 @@ export class PaymentService {
|
||||
private strategies: Map<PaymentMethod, PaymentStrategy>;
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly datasource: DataSource,
|
||||
private readonly paymentRepo: PaymentRepository,
|
||||
private readonly telebirrPaymentStategy: PaymentTelebirrStrategy) {
|
||||
@@ -47,7 +50,8 @@ export class PaymentService {
|
||||
let redirectUrl: string;
|
||||
switch (type) {
|
||||
case "booking":
|
||||
redirectUrl = `http://localhost:3001/api/payments/receipts/${orderId}/html`
|
||||
const url = this.configService.get<string>("TELEBIRR_SUCCESS_REDIRECT_BASE_URL")
|
||||
redirectUrl = `${url}/check-status/${orderId}`
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -132,17 +136,27 @@ export class PaymentService {
|
||||
return html;
|
||||
}
|
||||
|
||||
// const templatePath = path.join(
|
||||
// process.cwd(),
|
||||
// 'src/modules/payment/templates/receipt.hbs',
|
||||
// );
|
||||
async checkStatusAndUpdate(orderId: string) {
|
||||
const resp = await this.paymentRepo.findOneBy({ merchantOrderId: orderId })
|
||||
if (!resp) {
|
||||
throw new NotFoundException("order id not found")
|
||||
}
|
||||
const result = await this.telebirrPaymentStategy.queryStatus(resp.merchantOrderId)
|
||||
const bizContent = result.rawResponse.biz_content as {
|
||||
order_status: string;
|
||||
};
|
||||
|
||||
|
||||
// console.log(templatePath)
|
||||
|
||||
// const source = fs.readFileSync(templatePath, 'utf8');
|
||||
// const template = Handlebars.compile(source);
|
||||
// return this.getReceiptTemplate();
|
||||
const ordersStatus = bizContent.order_status
|
||||
if (ordersStatus == "PAY_SUCCESS") {
|
||||
await this.datasource.transaction(async (mg) => {
|
||||
await mg.update(Booking, { id: resp.refId }, { status: "PAID" })
|
||||
await mg.update(PaymentEntity, { id: resp.id }, { status: "success" })
|
||||
})
|
||||
}
|
||||
return {
|
||||
status: result.status
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -301,5 +301,4 @@ export class PaymentTelebirrStrategy implements PaymentStrategy {
|
||||
return this.config.get<string>('telebirr.publicKey') ?? '';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -2,15 +2,16 @@ import { Injectable, } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as crypto from "crypto"
|
||||
import { TelebirrDto } from '../dto/telebirr.dto';
|
||||
import { BookingsRepository } from 'src/modules/bookings/bookings.repository';
|
||||
import { PaymentRepository } from '../../payment.repository';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { Booking } from 'src/modules/bookings/entities/booking.entity';
|
||||
@Injectable()
|
||||
export class TelebirrWebhookService {
|
||||
// private readonly logger = new Logger(TelebirrWebhookService.name);
|
||||
|
||||
constructor(
|
||||
private readonly datasource: DataSource,
|
||||
private readonly config: ConfigService,
|
||||
private readonly bookingRepo: BookingsRepository,
|
||||
private readonly paymentRepo: PaymentRepository,
|
||||
|
||||
) { }
|
||||
@@ -57,7 +58,8 @@ export class TelebirrWebhookService {
|
||||
await this.paymentRepo.update({ id: payment.id }, { status: "success", paidAt: new Date() })
|
||||
switch (payment.type) {
|
||||
case "booking":
|
||||
await this.bookingRepo.update(payment.refId, { paymentStatus: "PAID", })
|
||||
await this.datasource.manager.update(Booking, { id: payment.refId }, { paymentStatus: "PAID", })
|
||||
// await this.bookingRepo.update(payment.refId, { paymentStatus: "PAID", })
|
||||
break;
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ArrayMinSize, IsArray, IsBoolean, IsOptional, IsString, IsUUID, MaxLength, ValidateNested } from 'class-validator';
|
||||
|
||||
export class CreateRouteMilestoneDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
yardId!: string;
|
||||
}
|
||||
|
||||
export class CreateRouteDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ type: [CreateRouteMilestoneDto] })
|
||||
@IsArray()
|
||||
@ArrayMinSize(2)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CreateRouteMilestoneDto)
|
||||
milestones!: CreateRouteMilestoneDto[];
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsBoolean, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class FilterRoutesDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
|
||||
import { CreateRouteDto } from './create-route.dto';
|
||||
|
||||
export class UpdateRouteDto extends PartialType(CreateRouteDto) {}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
|
||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
import { Route } from './route.entity';
|
||||
|
||||
@Entity({ schema: 'freight', name: 'route_milestones' })
|
||||
@Index(['routeId', 'sequenceNo'], { unique: true })
|
||||
export class RouteMilestone extends BaseEntity {
|
||||
@Column({ name: 'route_id', type: 'uuid' })
|
||||
routeId!: string;
|
||||
|
||||
@ManyToOne(() => Route, (route) => route.milestones, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'route_id' })
|
||||
route?: Route;
|
||||
|
||||
@Column({ name: 'yard_id', type: 'uuid' })
|
||||
yardId!: string;
|
||||
|
||||
@ManyToOne(() => Yard)
|
||||
@JoinColumn({ name: 'yard_id' })
|
||||
yard?: Yard;
|
||||
|
||||
@Column({ name: 'sequence_no', type: 'int' })
|
||||
sequenceNo!: number;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
||||
|
||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
import { RouteMilestone } from './route-milestone.entity';
|
||||
|
||||
@Entity({ schema: 'freight', name: 'routes' })
|
||||
@Index(['name'])
|
||||
@Index(['isActive'])
|
||||
export class Route extends BaseEntity {
|
||||
@Column({ name: 'name', type: 'varchar', length: 120, unique: true })
|
||||
name!: string;
|
||||
|
||||
@Column({ name: 'origin_yard_id', type: 'uuid' })
|
||||
originYardId!: string;
|
||||
|
||||
@ManyToOne(() => Yard)
|
||||
@JoinColumn({ name: 'origin_yard_id' })
|
||||
originYard?: Yard;
|
||||
|
||||
@Column({ name: 'destination_yard_id', type: 'uuid' })
|
||||
destinationYardId!: string;
|
||||
|
||||
@ManyToOne(() => Yard)
|
||||
@JoinColumn({ name: 'destination_yard_id' })
|
||||
destinationYard?: Yard;
|
||||
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
isActive!: boolean;
|
||||
|
||||
@OneToMany(() => RouteMilestone, (milestone) => milestone.route, { cascade: false })
|
||||
milestones?: RouteMilestone[];
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { RouteMilestone } from './entities/route-milestone.entity';
|
||||
|
||||
@Injectable()
|
||||
export class RouteMilestonesRepository extends BaseRepository<RouteMilestone> {
|
||||
constructor(@InjectRepository(RouteMilestone) repository: Repository<RouteMilestone>) {
|
||||
super(repository);
|
||||
}
|
||||
}
|
||||
44
apps/edr-freight-api/src/modules/routes/routes.controller.ts
Normal file
44
apps/edr-freight-api/src/modules/routes/routes.controller.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { CreateRouteDto } from './dto/create-route.dto';
|
||||
import { FilterRoutesDto } from './dto/filter-routes.dto';
|
||||
import { UpdateRouteDto } from './dto/update-route.dto';
|
||||
import { RoutesService } from './routes.service';
|
||||
|
||||
@ApiTags('routes')
|
||||
@ApiBearerAuth()
|
||||
@Controller('routes')
|
||||
export class RoutesController {
|
||||
constructor(private readonly routesService: RoutesService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List routes' })
|
||||
findAll(@Query() filter: FilterRoutesDto) {
|
||||
return this.routesService.findAll(filter);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get route by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.routesService.findById(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create route' })
|
||||
create(@Body() dto: CreateRouteDto) {
|
||||
return this.routesService.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'Update route' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateRouteDto) {
|
||||
return this.routesService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Deactivate route' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.routesService.deactivate(id);
|
||||
}
|
||||
}
|
||||
18
apps/edr-freight-api/src/modules/routes/routes.module.ts
Normal file
18
apps/edr-freight-api/src/modules/routes/routes.module.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { RouteMilestone } from './entities/route-milestone.entity';
|
||||
import { Route } from './entities/route.entity';
|
||||
import { RouteMilestonesRepository } from './route-milestones.repository';
|
||||
import { RoutesController } from './routes.controller';
|
||||
import { RoutesRepository } from './routes.repository';
|
||||
import { RoutesService } from './routes.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Route, RouteMilestone, Yard])],
|
||||
controllers: [RoutesController],
|
||||
providers: [RoutesRepository, RouteMilestonesRepository, RoutesService],
|
||||
exports: [RoutesRepository, RouteMilestonesRepository, RoutesService],
|
||||
})
|
||||
export class RoutesModule {}
|
||||
13
apps/edr-freight-api/src/modules/routes/routes.repository.ts
Normal file
13
apps/edr-freight-api/src/modules/routes/routes.repository.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { Route } from './entities/route.entity';
|
||||
|
||||
@Injectable()
|
||||
export class RoutesRepository extends BaseRepository<Route> {
|
||||
constructor(@InjectRepository(Route) repository: Repository<Route>) {
|
||||
super(repository);
|
||||
}
|
||||
}
|
||||
171
apps/edr-freight-api/src/modules/routes/routes.service.ts
Normal file
171
apps/edr-freight-api/src/modules/routes/routes.service.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource, ILike } from 'typeorm';
|
||||
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { CreateRouteDto } from './dto/create-route.dto';
|
||||
import { FilterRoutesDto } from './dto/filter-routes.dto';
|
||||
import { UpdateRouteDto } from './dto/update-route.dto';
|
||||
import { RouteMilestone } from './entities/route-milestone.entity';
|
||||
import { Route } from './entities/route.entity';
|
||||
import { RoutesRepository } from './routes.repository';
|
||||
|
||||
@Injectable()
|
||||
export class RoutesService {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly routesRepository: RoutesRepository,
|
||||
) {}
|
||||
|
||||
findAll(filter: FilterRoutesDto): Promise<Route[]> {
|
||||
return this.routesRepository.findAll({
|
||||
where: {
|
||||
...(filter.search ? { name: ILike(`%${filter.search.trim()}%`) } : {}),
|
||||
...(filter.isActive !== undefined ? { isActive: filter.isActive } : {}),
|
||||
},
|
||||
relations: {
|
||||
originYard: true,
|
||||
destinationYard: true,
|
||||
milestones: { yard: true },
|
||||
},
|
||||
order: {
|
||||
name: 'ASC',
|
||||
milestones: { sequenceNo: 'ASC' },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Route> {
|
||||
const route = await this.dataSource.getRepository(Route).findOne({
|
||||
where: { id },
|
||||
relations: {
|
||||
originYard: true,
|
||||
destinationYard: true,
|
||||
milestones: { yard: true },
|
||||
},
|
||||
order: { milestones: { sequenceNo: 'ASC' } },
|
||||
});
|
||||
|
||||
if (!route) {
|
||||
throw new NotFoundException(`Route ${id} not found`);
|
||||
}
|
||||
|
||||
return route;
|
||||
}
|
||||
|
||||
async create(dto: CreateRouteDto): Promise<Route> {
|
||||
await this.validateRouteName(dto.name);
|
||||
const validated = await this.validateMilestones(dto.milestones);
|
||||
|
||||
const route = await this.dataSource.transaction(async (manager) => {
|
||||
const savedRoute = await manager.getRepository(Route).save(
|
||||
manager.getRepository(Route).create({
|
||||
name: dto.name.trim(),
|
||||
originYardId: validated.originYardId,
|
||||
destinationYardId: validated.destinationYardId,
|
||||
isActive: dto.isActive ?? true,
|
||||
}),
|
||||
);
|
||||
|
||||
await manager.getRepository(RouteMilestone).save(
|
||||
validated.milestones.map((milestone) =>
|
||||
manager.getRepository(RouteMilestone).create({
|
||||
routeId: savedRoute.id,
|
||||
yardId: milestone.yardId,
|
||||
sequenceNo: milestone.sequenceNo,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return savedRoute;
|
||||
});
|
||||
|
||||
return this.findById(route.id);
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateRouteDto): Promise<Route> {
|
||||
const existing = await this.findById(id);
|
||||
|
||||
if (dto.name && dto.name.trim() !== existing.name) {
|
||||
await this.validateRouteName(dto.name, id);
|
||||
}
|
||||
|
||||
const milestoneInput = dto.milestones
|
||||
? await this.validateMilestones(dto.milestones)
|
||||
: null;
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(Route).update(id, {
|
||||
name: dto.name?.trim() ?? existing.name,
|
||||
originYardId: milestoneInput?.originYardId ?? existing.originYardId,
|
||||
destinationYardId: milestoneInput?.destinationYardId ?? existing.destinationYardId,
|
||||
isActive: dto.isActive ?? existing.isActive,
|
||||
});
|
||||
|
||||
if (milestoneInput) {
|
||||
await manager.getRepository(RouteMilestone).delete({ routeId: id });
|
||||
await manager.getRepository(RouteMilestone).save(
|
||||
milestoneInput.milestones.map((milestone) =>
|
||||
manager.getRepository(RouteMilestone).create({
|
||||
routeId: id,
|
||||
yardId: milestone.yardId,
|
||||
sequenceNo: milestone.sequenceNo,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
async deactivate(id: string): Promise<Route> {
|
||||
await this.findById(id);
|
||||
const updated = await this.routesRepository.update(id, { isActive: false });
|
||||
|
||||
if (!updated) {
|
||||
throw new NotFoundException(`Route ${id} not found`);
|
||||
}
|
||||
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
private async validateRouteName(name: string, routeId?: string) {
|
||||
const trimmedName = name.trim();
|
||||
const [existing] = await this.routesRepository.findAll({ where: { name: trimmedName } });
|
||||
|
||||
if (existing && existing.id !== routeId) {
|
||||
throw new ConflictException(`Route name ${trimmedName} already exists`);
|
||||
}
|
||||
}
|
||||
|
||||
private async validateMilestones(milestones: Array<{ yardId: string }>) {
|
||||
if (milestones.length < 2) {
|
||||
throw new BadRequestException('A route requires at least two yards');
|
||||
}
|
||||
|
||||
const normalized = milestones.map((milestone, index) => ({
|
||||
yardId: milestone.yardId,
|
||||
sequenceNo: index + 1,
|
||||
}));
|
||||
|
||||
const uniqueYardIds = [...new Set(normalized.map((milestone) => milestone.yardId))];
|
||||
const yards = await this.dataSource.getRepository(Yard).find({ where: uniqueYardIds.map((id) => ({ id })) });
|
||||
const yardIds = new Set(yards.map((yard) => yard.id));
|
||||
|
||||
for (const milestone of normalized) {
|
||||
if (!yardIds.has(milestone.yardId)) {
|
||||
throw new BadRequestException(`Yard ${milestone.yardId} does not exist`);
|
||||
}
|
||||
}
|
||||
|
||||
if (normalized[0].yardId === normalized[normalized.length - 1].yardId) {
|
||||
throw new BadRequestException('Origin and destination yards must be different');
|
||||
}
|
||||
|
||||
return {
|
||||
originYardId: normalized[0].yardId,
|
||||
destinationYardId: normalized[normalized.length - 1].yardId,
|
||||
milestones: normalized,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ 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 { Route } from '../../routes/entities/route.entity';
|
||||
import { TrainSet } from '../../train-sets/entities/train-set.entity';
|
||||
import { TrainScheduleBooking } from './train-schedule-booking.entity';
|
||||
|
||||
@@ -26,6 +27,13 @@ export class TrainSchedule extends BaseEntity {
|
||||
@JoinColumn({ name: 'train_set_id' })
|
||||
trainSet?: TrainSet;
|
||||
|
||||
@Column({ name: 'route_id', type: 'uuid', nullable: true })
|
||||
routeId?: string | null;
|
||||
|
||||
@ManyToOne(() => Route)
|
||||
@JoinColumn({ name: 'route_id' })
|
||||
route?: Route | null;
|
||||
|
||||
@Column({ name: 'origin_station_id', type: 'uuid' })
|
||||
originStationId!: string;
|
||||
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsUUID } from 'class-validator';
|
||||
import { IsDateString, IsUUID } from 'class-validator';
|
||||
|
||||
import { PreviewContainerTrainScheduleDto } from './preview-container-train-schedule.dto';
|
||||
export class CreateContainerTrainScheduleDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
routeId!: string;
|
||||
|
||||
@ApiProperty({ example: '2026-06-20T08:00:00.000Z' })
|
||||
@IsDateString()
|
||||
scheduleDate!: string;
|
||||
|
||||
export class CreateContainerTrainScheduleDto extends PreviewContainerTrainScheduleDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
locomotiveId!: string;
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsDateString, IsIn, IsOptional, IsUUID } from 'class-validator';
|
||||
|
||||
import { BOOKING_STATUSES } from '../../bookings/entities/booking.entity';
|
||||
import { IsDateString, IsOptional, IsUUID } from 'class-validator';
|
||||
|
||||
export class GetEligibleContainerBookingsDto {
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@@ -18,9 +16,4 @@ export class GetEligibleContainerBookingsDto {
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
scheduleDate?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsIn(BOOKING_STATUSES)
|
||||
status?: string;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ const locomotive = {
|
||||
id: 'loc-1',
|
||||
code: 'LOC-001',
|
||||
maxPullWeightTons: 3500,
|
||||
maxTrainLengthMeters: 760,
|
||||
status: 'AVAILABLE',
|
||||
};
|
||||
|
||||
@@ -37,7 +38,7 @@ const makeBooking = (
|
||||
scheduledDate: new Date(scheduledDate),
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
status: 'APPROVED',
|
||||
status: 'PAID',
|
||||
customer: { companyName: 'Demo Customer' },
|
||||
originYard: { label: 'Djibouti', code: 'DJIBOUTI' },
|
||||
destinationYard: { label: 'Addis Ababa', code: 'ADDIS_ABABA' },
|
||||
@@ -163,48 +164,51 @@ describe('TrainSchedulingService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
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,
|
||||
it('rejects bookings that are not in schedulable status', async () => {
|
||||
const bookings = [
|
||||
{
|
||||
...makeBooking('b7', 'BKG-CONT-007', 120, 2, '40FT'),
|
||||
status: 'APPROVED',
|
||||
},
|
||||
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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
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: ['b7'],
|
||||
scheduleDate: '2026-06-20T08:00:00.000Z',
|
||||
originStationId: 'yard-origin',
|
||||
destinationStationId: 'yard-destination',
|
||||
});
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.violations).toContain(
|
||||
'Only PAID bookings can be scheduled; received: APPROVED',
|
||||
);
|
||||
});
|
||||
|
||||
it('creates a schedule transactionally when validation passes', async () => {
|
||||
const route = {
|
||||
id: 'route-1',
|
||||
name: 'Djibouti to Addis',
|
||||
originYardId: 'yard-origin',
|
||||
destinationYardId: 'yard-destination',
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
const lockedLocomotiveRepo = {
|
||||
@@ -215,23 +219,6 @@ describe('TrainSchedulingService', () => {
|
||||
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' }),
|
||||
@@ -243,12 +230,6 @@ describe('TrainSchedulingService', () => {
|
||||
return lockedLocomotiveRepo;
|
||||
case 'TrainSchedule':
|
||||
return trainScheduleRepo;
|
||||
case 'TrainScheduleBooking':
|
||||
return trainScheduleBookingRepo;
|
||||
case 'TrainSetWagon':
|
||||
return trainSetWagonRepo;
|
||||
case 'WagonBookingAllocation':
|
||||
return wagonAllocRepo;
|
||||
case 'TrainSet':
|
||||
return trainSetRepo;
|
||||
default:
|
||||
@@ -257,70 +238,60 @@ describe('TrainSchedulingService', () => {
|
||||
}),
|
||||
};
|
||||
|
||||
jest.spyOn(service, 'validateContainerBookingsForScheduling').mockResolvedValue(validation as never);
|
||||
jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never);
|
||||
dataSource.getRepository.mockImplementation((entity: { name?: string }) => {
|
||||
if (entity?.name === 'Route') {
|
||||
return { findOne: jest.fn().mockResolvedValue(route) };
|
||||
}
|
||||
throw new Error(`Unexpected repository ${entity?.name}`);
|
||||
});
|
||||
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'],
|
||||
routeId: 'route-1',
|
||||
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.getRepository.mockImplementation((entity: { name?: string }) => {
|
||||
if (entity?.name === 'Route') {
|
||||
return {
|
||||
findOne: jest.fn().mockResolvedValue({
|
||||
id: 'route-1',
|
||||
name: 'Djibouti to Addis',
|
||||
originYardId: 'yard-origin',
|
||||
destinationYardId: 'yard-destination',
|
||||
isActive: true,
|
||||
}),
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected repository ${entity?.name}`);
|
||||
});
|
||||
dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise<string>) =>
|
||||
callback(manager),
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.createContainerTrainSchedule({
|
||||
bookingIds: ['b1'],
|
||||
routeId: 'route-1',
|
||||
scheduleDate: '2026-06-20T08:00:00.000Z',
|
||||
originStationId: 'yard-origin',
|
||||
destinationStationId: 'yard-destination',
|
||||
locomotiveId: 'loc-1',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
|
||||
@@ -15,9 +15,9 @@ import {
|
||||
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 { Route } from "../routes/entities/route.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";
|
||||
@@ -27,6 +27,7 @@ import { PreviewContainerTrainScheduleDto } from "./dto/preview-container-train-
|
||||
const DEFAULT_WAGON_TYPE_CODE = "NW5";
|
||||
const MAX_TRAIN_WEIGHT_TONS = 3500;
|
||||
const MAX_TRAIN_LENGTH_METERS = 760;
|
||||
const SCHEDULABLE_BOOKING_STATUSES = ["PAID"] as const;
|
||||
|
||||
type EligibleBookingItem = {
|
||||
id: string;
|
||||
@@ -83,7 +84,7 @@ export class TrainSchedulingService {
|
||||
const bookingRepository = this.dataSource.getRepository(Booking);
|
||||
const queryBuilder = bookingRepository
|
||||
.createQueryBuilder("booking")
|
||||
.leftJoinAndSelect("booking.customer", "customer")
|
||||
.leftJoinAndSelect("booking.company", "company")
|
||||
.leftJoinAndSelect("booking.originYard", "originYard")
|
||||
.leftJoinAndSelect("booking.destinationYard", "destinationYard")
|
||||
.leftJoinAndSelect("booking.bookingContainers", "bookingContainer")
|
||||
@@ -96,6 +97,10 @@ export class TrainSchedulingService {
|
||||
.where("booking.freightType = :freightType", { freightType: "CONTAINER" })
|
||||
.andWhere("scheduleBooking.id IS NULL");
|
||||
|
||||
queryBuilder.andWhere("booking.status IN (:...schedulableStatuses)", {
|
||||
schedulableStatuses: SCHEDULABLE_BOOKING_STATUSES,
|
||||
});
|
||||
|
||||
if (query.originStationId) {
|
||||
queryBuilder.andWhere("booking.originYardId = :originStationId", {
|
||||
originStationId: query.originStationId,
|
||||
@@ -118,12 +123,6 @@ export class TrainSchedulingService {
|
||||
);
|
||||
}
|
||||
|
||||
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")
|
||||
@@ -180,18 +179,12 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
async createContainerTrainSchedule(dto: CreateContainerTrainScheduleDto) {
|
||||
const validation = await this.validateContainerBookingsForScheduling(dto);
|
||||
|
||||
if (!validation.valid) {
|
||||
throw new BadRequestException({
|
||||
message: "train_schedule_invalid",
|
||||
violations: validation.violations,
|
||||
});
|
||||
}
|
||||
const route = await this.getActiveRoute(dto.routeId);
|
||||
|
||||
const locomotive = await this.selectOrValidateLocomotive(
|
||||
dto.locomotiveId,
|
||||
validation.summary.totalWeightTons,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
|
||||
const createdSchedule = await this.dataSource.transaction(
|
||||
@@ -212,90 +205,24 @@ export class TrainSchedulingService {
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
const trainSet = await this.buildEmptyTrainSet(
|
||||
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,
|
||||
routeId: route.id,
|
||||
originStationId: route.originYardId,
|
||||
destinationStationId: route.destinationYardId,
|
||||
scheduledDepartureDate: new Date(dto.scheduleDate),
|
||||
status: "SCHEDULED",
|
||||
status: "DRAFT",
|
||||
});
|
||||
|
||||
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",
|
||||
});
|
||||
@@ -357,6 +284,16 @@ export class TrainSchedulingService {
|
||||
);
|
||||
}
|
||||
|
||||
const invalidStatusBookings = bookings.filter(
|
||||
(booking) => !SCHEDULABLE_BOOKING_STATUSES.includes(booking.status as "PAID"),
|
||||
);
|
||||
if (invalidStatusBookings.length > 0) {
|
||||
const invalidStatuses = [...new Set(invalidStatusBookings.map((booking) => booking.status))];
|
||||
violations.push(
|
||||
`Only ${SCHEDULABLE_BOOKING_STATUSES.join(", ")} bookings can be scheduled; received: ${invalidStatuses.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const scheduleDateKey = this.toUtcDateKey(dto.scheduleDate);
|
||||
const routeMismatch = bookings.some(
|
||||
(booking) =>
|
||||
@@ -452,10 +389,14 @@ export class TrainSchedulingService {
|
||||
where: { status: "AVAILABLE" },
|
||||
});
|
||||
const canPull = capableLocomotives.some(
|
||||
(locomotive) => Number(locomotive.maxPullWeightTons) >= totalWeightTons,
|
||||
(locomotive) =>
|
||||
Number(locomotive.maxPullWeightTons) >= totalWeightTons &&
|
||||
Number(locomotive.maxTrainLengthMeters) >= totalLengthMeters,
|
||||
);
|
||||
if (!canPull) {
|
||||
violations.push("No available locomotive can pull the total weight");
|
||||
violations.push(
|
||||
'No available locomotive can support the total train weight and length',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -504,6 +445,7 @@ export class TrainSchedulingService {
|
||||
async selectOrValidateLocomotive(
|
||||
locomotiveId: string,
|
||||
totalWeightTons: number,
|
||||
totalLengthMeters: number,
|
||||
) {
|
||||
const locomotive = await this.locomotivesRepository.findById(locomotiveId);
|
||||
|
||||
@@ -523,6 +465,12 @@ export class TrainSchedulingService {
|
||||
);
|
||||
}
|
||||
|
||||
if (Number(locomotive.maxTrainLengthMeters) < totalLengthMeters) {
|
||||
throw new BadRequestException(
|
||||
`Locomotive ${locomotive.code} cannot support ${totalLengthMeters}m`,
|
||||
);
|
||||
}
|
||||
|
||||
return locomotive;
|
||||
}
|
||||
|
||||
@@ -559,6 +507,21 @@ export class TrainSchedulingService {
|
||||
return savedTrainSet;
|
||||
}
|
||||
|
||||
async buildEmptyTrainSet(
|
||||
manager: EntityManager,
|
||||
locomotive: Locomotive,
|
||||
) {
|
||||
const trainSet = manager.getRepository(TrainSet).create({
|
||||
locomotiveId: locomotive.id,
|
||||
totalWeightTons: 0,
|
||||
totalLengthMeters: 0,
|
||||
wagonCount: 0,
|
||||
status: 'DRAFT',
|
||||
});
|
||||
|
||||
return manager.getRepository(TrainSet).save(trainSet);
|
||||
}
|
||||
|
||||
allocateBookingsToWagons(
|
||||
bookings: Booking[],
|
||||
baseWagonPlan: WagonPlanRecord[],
|
||||
@@ -618,6 +581,7 @@ export class TrainSchedulingService {
|
||||
const schedules = await this.dataSource.getRepository(TrainSchedule).find({
|
||||
relations: {
|
||||
trainSet: { locomotive: true },
|
||||
route: true,
|
||||
originStation: true,
|
||||
destinationStation: true,
|
||||
scheduleBookings: true,
|
||||
@@ -628,6 +592,7 @@ export class TrainSchedulingService {
|
||||
return schedules.map((schedule) => ({
|
||||
id: schedule.id,
|
||||
scheduleDate: schedule.scheduledDepartureDate,
|
||||
routeName: schedule.route?.name ?? null,
|
||||
origin:
|
||||
schedule.originStation?.label ?? schedule.originStation?.code ?? null,
|
||||
destination:
|
||||
@@ -659,6 +624,7 @@ export class TrainSchedulingService {
|
||||
.findOne({
|
||||
where: { id },
|
||||
relations: {
|
||||
route: true,
|
||||
trainSet: {
|
||||
locomotive: true,
|
||||
wagons: { wagonType: true, allocations: { booking: true } },
|
||||
@@ -678,6 +644,12 @@ export class TrainSchedulingService {
|
||||
return {
|
||||
id: schedule.id,
|
||||
status: schedule.status,
|
||||
route: schedule.route
|
||||
? {
|
||||
id: schedule.route.id,
|
||||
name: schedule.route.name,
|
||||
}
|
||||
: null,
|
||||
scheduledDepartureDate: schedule.scheduledDepartureDate,
|
||||
scheduledArrivalDate: schedule.scheduledArrivalDate,
|
||||
originStation: schedule.originStation,
|
||||
@@ -702,6 +674,9 @@ export class TrainSchedulingService {
|
||||
maxPullWeightTons: this.roundTons(
|
||||
Number(schedule.trainSet.locomotive.maxPullWeightTons),
|
||||
),
|
||||
maxTrainLengthMeters: this.roundTons(
|
||||
Number(schedule.trainSet.locomotive.maxTrainLengthMeters),
|
||||
),
|
||||
}
|
||||
: null,
|
||||
wagons: [...(schedule.trainSet.wagons ?? [])]
|
||||
@@ -797,6 +772,22 @@ export class TrainSchedulingService {
|
||||
});
|
||||
}
|
||||
|
||||
private async getActiveRoute(routeId: string) {
|
||||
const route = await this.dataSource.getRepository(Route).findOne({
|
||||
where: { id: routeId },
|
||||
});
|
||||
|
||||
if (!route) {
|
||||
throw new NotFoundException(`Route ${routeId} not found`);
|
||||
}
|
||||
|
||||
if (!route.isActive) {
|
||||
throw new BadRequestException(`Route ${route.name} is inactive`);
|
||||
}
|
||||
|
||||
return route;
|
||||
}
|
||||
|
||||
private toUtcDateKey(value: Date | string) {
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
return date.toISOString().slice(0, 10);
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
const parseLoadTypes = (value: unknown): string[] => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => String(item).trim()).filter(Boolean);
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
export class CreateWagonTypeDto {
|
||||
@ApiProperty({ description: 'Display name, e.g. "Flat Wagon"', maxLength: 100 })
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ description: 'Maximum payload capacity in metric tons' })
|
||||
@IsNumber()
|
||||
@Min(0.001)
|
||||
@Transform(({ value }) => Number(value))
|
||||
capacityTons!: number;
|
||||
|
||||
@ApiProperty({ description: 'Wagon length in meters' })
|
||||
@IsNumber()
|
||||
@Min(0.001)
|
||||
@Transform(({ value }) => Number(value))
|
||||
lengthMeters!: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Maximum wagons of this type per train' })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Transform(({ value }) => (value === '' || value === null || value === undefined ? undefined : Number(value)))
|
||||
maxWagonsPerTrain?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Supported load types, e.g. CONTAINER,BULK',
|
||||
type: [String],
|
||||
default: [],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@Transform(({ value }) => parseLoadTypes(value))
|
||||
supportedLoadTypes?: string[];
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
|
||||
import { CreateWagonTypeDto } from './create-wagon-type.dto';
|
||||
|
||||
export class UpdateWagonTypeDto extends PartialType(CreateWagonTypeDto) {}
|
||||
@@ -1,16 +1,67 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import { WagonTypesService } from './wagon-types.service';
|
||||
import { WagonType } from './entities/wagon-type.entity';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
@ApiTags('Wagon Types')
|
||||
import { RuleEngineManage, RuleEngineView } from '../../common/rule-engine-guards';
|
||||
|
||||
import { CreateWagonTypeDto } from './dto/create-wagon-type.dto';
|
||||
import { UpdateWagonTypeDto } from './dto/update-wagon-type.dto';
|
||||
import { WagonTypesService } from './wagon-types.service';
|
||||
|
||||
@ApiTags('wagon-types')
|
||||
@Controller('wagon-types')
|
||||
@ApiBearerAuth()
|
||||
export class WagonTypesController {
|
||||
constructor(private readonly wagonTypesService: WagonTypesService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Get all active wagon types' })
|
||||
async findAll(): Promise<WagonType[]> {
|
||||
return this.wagonTypesService.findAll();
|
||||
@RuleEngineView('wagon-types')
|
||||
@ApiOperation({ summary: 'List wagon types' })
|
||||
findAll(@Query() query: Record<string, string>) {
|
||||
return this.wagonTypesService.findAll({
|
||||
isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined,
|
||||
page: query['page'] ? parseInt(query['page'], 10) : undefined,
|
||||
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RuleEngineView('wagon-types')
|
||||
@ApiOperation({ summary: 'Get a wagon type by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.wagonTypesService.findById(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RuleEngineManage('wagon-types')
|
||||
@ApiOperation({ summary: 'Create a wagon type' })
|
||||
create(@Body() dto: CreateWagonTypeDto) {
|
||||
return this.wagonTypesService.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RuleEngineManage('wagon-types')
|
||||
@ApiOperation({ summary: 'Update a wagon type' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWagonTypeDto) {
|
||||
return this.wagonTypesService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RuleEngineManage('wagon-types')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Soft-delete a wagon type' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.wagonTypesService.remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,4 +13,8 @@ export class WagonTypesRepository extends BaseRepository<WagonType> {
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
findByCode(code: string): Promise<WagonType | null> {
|
||||
return this.repository.findOne({ where: { code } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
|
||||
import { generateCode } from '../../common/utils/generate-code.util';
|
||||
|
||||
import { CreateWagonTypeDto } from './dto/create-wagon-type.dto';
|
||||
import { UpdateWagonTypeDto } from './dto/update-wagon-type.dto';
|
||||
import { WagonType } from './entities/wagon-type.entity';
|
||||
import { WagonTypesRepository } from './wagon-types.repository';
|
||||
|
||||
@@ -7,20 +15,86 @@ import { WagonTypesRepository } from './wagon-types.repository';
|
||||
export class WagonTypesService {
|
||||
constructor(private readonly wagonTypesRepository: WagonTypesRepository) {}
|
||||
|
||||
async findAll(): Promise<WagonType[]> {
|
||||
return this.wagonTypesRepository.findAll({
|
||||
where: { isActive: true },
|
||||
async findAll(filter: {
|
||||
isActive?: boolean;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
} = {}): Promise<{
|
||||
data: WagonType[];
|
||||
meta: { total: number; page: number; pageSize: number; totalPages: number };
|
||||
}> {
|
||||
const page = filter.page ?? 1;
|
||||
const pageSize = filter.pageSize ?? 20;
|
||||
const where: Record<string, unknown> = {};
|
||||
if (filter.isActive !== undefined) {
|
||||
where.isActive = filter.isActive;
|
||||
}
|
||||
|
||||
const [data, total] = await this.wagonTypesRepository.findAndCount({
|
||||
where,
|
||||
order: { code: 'ASC' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
});
|
||||
|
||||
return {
|
||||
data,
|
||||
meta: {
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.max(1, Math.ceil(total / pageSize)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<WagonType> {
|
||||
const wagonType = await this.wagonTypesRepository.findById(id);
|
||||
if (!wagonType) {
|
||||
throw new NotFoundException(`Wagon type ${id} not found`);
|
||||
}
|
||||
return wagonType;
|
||||
}
|
||||
|
||||
async findByCode(code: string): Promise<WagonType> {
|
||||
const [wagonType] = await this.wagonTypesRepository.findAll({ where: { code } });
|
||||
|
||||
const wagonType = await this.wagonTypesRepository.findByCode(code);
|
||||
if (!wagonType) {
|
||||
throw new NotFoundException(`Wagon type ${code} not found`);
|
||||
}
|
||||
|
||||
return wagonType;
|
||||
}
|
||||
|
||||
async create(dto: CreateWagonTypeDto): Promise<WagonType> {
|
||||
const code = generateCode(dto.name);
|
||||
const existing = await this.wagonTypesRepository.findByCode(code);
|
||||
if (existing) {
|
||||
throw new ConflictException(
|
||||
`Wagon type with name "${dto.name}" conflicts with existing code "${code}"`,
|
||||
);
|
||||
}
|
||||
|
||||
return this.wagonTypesRepository.create({
|
||||
code,
|
||||
name: dto.name,
|
||||
capacityTons: dto.capacityTons,
|
||||
lengthMeters: dto.lengthMeters,
|
||||
maxWagonsPerTrain: dto.maxWagonsPerTrain ?? null,
|
||||
supportedLoadTypes: dto.supportedLoadTypes ?? [],
|
||||
isActive: dto.isActive ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateWagonTypeDto): Promise<WagonType> {
|
||||
await this.findById(id);
|
||||
const updated = await this.wagonTypesRepository.update(id, dto);
|
||||
if (!updated) {
|
||||
throw new NotFoundException(`Wagon type ${id} not found`);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
await this.findById(id);
|
||||
await this.wagonTypesRepository.softDelete(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,8 @@ const DEMO_BOOKINGS = [
|
||||
originCode: "DJIBOUTI",
|
||||
destinationCode: "ADDIS_ABABA",
|
||||
scheduledDate: "2026-06-20T08:00:00.000Z",
|
||||
status: "PAID",
|
||||
paymentStatus: "PAID",
|
||||
},
|
||||
{
|
||||
reference: "BKG-CONT-002",
|
||||
@@ -60,6 +62,8 @@ const DEMO_BOOKINGS = [
|
||||
originCode: "DJIBOUTI",
|
||||
destinationCode: "ADDIS_ABABA",
|
||||
scheduledDate: "2026-06-20T08:00:00.000Z",
|
||||
status: "PAID",
|
||||
paymentStatus: "PAID",
|
||||
},
|
||||
{
|
||||
reference: "BKG-CONT-003",
|
||||
@@ -69,6 +73,8 @@ const DEMO_BOOKINGS = [
|
||||
originCode: "DJIBOUTI",
|
||||
destinationCode: "ADDIS_ABABA",
|
||||
scheduledDate: "2026-06-20T08:00:00.000Z",
|
||||
status: "PAID",
|
||||
paymentStatus: "PAID",
|
||||
},
|
||||
{
|
||||
reference: "BKG-CONT-007",
|
||||
@@ -78,6 +84,30 @@ const DEMO_BOOKINGS = [
|
||||
originCode: "DJIBOUTI",
|
||||
destinationCode: "ADDIS_ABABA",
|
||||
scheduledDate: "2026-06-20T08:00:00.000Z",
|
||||
status: "PAID",
|
||||
paymentStatus: "PAID",
|
||||
},
|
||||
{
|
||||
reference: "BKG-CONT-008",
|
||||
containerCode: "40FT",
|
||||
quantity: 4,
|
||||
totalWeightTons: 120,
|
||||
originCode: "DJIBOUTI",
|
||||
destinationCode: "ADDIS_ABABA",
|
||||
scheduledDate: "2026-06-20T08:00:00.000Z",
|
||||
status: "PAID",
|
||||
paymentStatus: "PAID",
|
||||
},
|
||||
{
|
||||
reference: "BKG-CONT-009",
|
||||
containerCode: "20FT",
|
||||
quantity: 5,
|
||||
totalWeightTons: 110,
|
||||
originCode: "DJIBOUTI",
|
||||
destinationCode: "ADDIS_ABABA",
|
||||
scheduledDate: "2026-06-20T08:00:00.000Z",
|
||||
status: "PAID",
|
||||
paymentStatus: "PAID",
|
||||
},
|
||||
{
|
||||
reference: "BKG-CONT-004",
|
||||
@@ -87,6 +117,8 @@ const DEMO_BOOKINGS = [
|
||||
originCode: "ADDIS_ABABA",
|
||||
destinationCode: "DIRE_DAWA",
|
||||
scheduledDate: "2026-06-20T08:00:00.000Z",
|
||||
status: "PAID",
|
||||
paymentStatus: "PAID",
|
||||
},
|
||||
{
|
||||
reference: "BKG-CONT-005",
|
||||
@@ -96,6 +128,8 @@ const DEMO_BOOKINGS = [
|
||||
originCode: "DJIBOUTI",
|
||||
destinationCode: "ADDIS_ABABA",
|
||||
scheduledDate: "2026-06-21T08:00:00.000Z",
|
||||
status: "PAID",
|
||||
paymentStatus: "PAID",
|
||||
},
|
||||
{
|
||||
reference: "BKG-CONT-006",
|
||||
@@ -105,6 +139,8 @@ const DEMO_BOOKINGS = [
|
||||
originCode: "DJIBOUTI",
|
||||
destinationCode: "ADDIS_ABABA",
|
||||
scheduledDate: "2026-06-20T08:00:00.000Z",
|
||||
status: "PAID",
|
||||
paymentStatus: "PAID",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -142,13 +178,17 @@ export class DemoBookingsSeeder {
|
||||
{
|
||||
code: "LOC-001",
|
||||
name: "Demo Locomotive 1",
|
||||
locomotiveType: 'ELECTRIC',
|
||||
maxPullWeightTons: 3500,
|
||||
maxTrainLengthMeters: 760,
|
||||
status: "AVAILABLE",
|
||||
},
|
||||
{
|
||||
code: "LOC-002",
|
||||
name: "Demo Locomotive 2",
|
||||
locomotiveType: 'DIESEL',
|
||||
maxPullWeightTons: 2500,
|
||||
maxTrainLengthMeters: 760,
|
||||
status: "AVAILABLE",
|
||||
},
|
||||
],
|
||||
@@ -249,10 +289,10 @@ export class DemoBookingsSeeder {
|
||||
{
|
||||
reference: demoBooking.reference,
|
||||
companyId: company.id,
|
||||
status: "APPROVED",
|
||||
status: demoBooking.status,
|
||||
scheduledDate: new Date(demoBooking.scheduledDate),
|
||||
totalAmount: 0,
|
||||
paymentStatus: "PENDING",
|
||||
paymentStatus: demoBooking.paymentStatus,
|
||||
contractType: "NEW",
|
||||
serviceTypeId: serviceType.id,
|
||||
equipmentReturn: "WITHOUT_RETURN",
|
||||
|
||||
@@ -10,6 +10,7 @@ export type FreightPermissionSeed = {
|
||||
export const RULE_ENGINE_RESOURCE_SLUGS = [
|
||||
'cargo-types',
|
||||
'container-types',
|
||||
'wagon-types',
|
||||
'service-types',
|
||||
'yards',
|
||||
'shipping-lines',
|
||||
@@ -56,6 +57,7 @@ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
const RULE_ENGINE_PERMISSION_IDS: Record<RuleEngineResourceSlug, { view: string; manage: string }> = {
|
||||
'cargo-types': { view: 'b2000001-0001-4000-8000-000000000001', manage: 'b2000001-0001-4000-8000-000000000002' },
|
||||
'container-types': { view: 'b2000001-0001-4000-8000-000000000003', manage: 'b2000001-0001-4000-8000-000000000004' },
|
||||
'wagon-types': { view: 'b2000001-0001-4000-8000-000000000015', manage: 'b2000001-0001-4000-8000-000000000016' },
|
||||
'service-types': { view: 'b2000001-0001-4000-8000-000000000005', manage: 'b2000001-0001-4000-8000-000000000006' },
|
||||
yards: { view: 'b2000001-0001-4000-8000-000000000007', manage: 'b2000001-0001-4000-8000-000000000008' },
|
||||
'shipping-lines': { view: 'b2000001-0001-4000-8000-000000000009', manage: 'b2000001-0001-4000-8000-00000000000a' },
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
@import "tailwindcss";
|
||||
@import "@edr/ui-common/theme.css" layer(theme);
|
||||
|
||||
:root {
|
||||
--freight-brand: #15803d;
|
||||
--freight-brand-dark: #166534;
|
||||
--freight-brand-light: #22c55e;
|
||||
--freight-brand-muted: #f0fdf4;
|
||||
--freight-brand-border: #bbf7d0;
|
||||
--freight-brand-ring: rgb(21 128 61 / 0.2);
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
|
||||
@@ -38,11 +38,13 @@ import TrainsPage from "./pages/trains/TrainsPage";
|
||||
import {
|
||||
CargoesCrudPage,
|
||||
ContainersCrudPage,
|
||||
LocomotivesCrudPage,
|
||||
TrainMasterDataPage,
|
||||
WagonsCrudPage,
|
||||
} from "./pages/fleet/FleetCrudPages";
|
||||
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
|
||||
import TrainDetailPage from "./pages/trains/TrainDetailPage";
|
||||
import RoutesPage from "./pages/fleet/RoutesPage";
|
||||
|
||||
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
{
|
||||
@@ -59,17 +61,32 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
href: "/dashboard/booking-requests",
|
||||
icon: <FileText />,
|
||||
},
|
||||
...demoItems,
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Operations",
|
||||
items: [
|
||||
{
|
||||
label: "Train scheduling",
|
||||
label: "Train Schedules",
|
||||
href: "/dashboard/operations/train-scheduling",
|
||||
icon: <Train />,
|
||||
},
|
||||
...demoItems,
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Fleet Management",
|
||||
items: [
|
||||
{
|
||||
label: "Routes",
|
||||
href: "/dashboard/routes",
|
||||
icon: <Network />,
|
||||
},
|
||||
{
|
||||
label: "Locomotives",
|
||||
href: "/dashboard/locomotives",
|
||||
icon: <Train />,
|
||||
},
|
||||
{
|
||||
label: "Trains",
|
||||
href: "/dashboard/trains",
|
||||
@@ -173,26 +190,7 @@ const DashboardShell = () => {
|
||||
const location = useLocation();
|
||||
const { user, logout } = useAuth();
|
||||
|
||||
const demoItems: SidebarItem[] = [
|
||||
...(hasPermission(user, "can:demo:user1")
|
||||
? [
|
||||
{
|
||||
label: "User1",
|
||||
href: "/dashboard/user1",
|
||||
icon: <Settings />,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(hasPermission(user, "can:demo:user2")
|
||||
? [
|
||||
{
|
||||
label: "User2",
|
||||
href: "/dashboard/user2",
|
||||
icon: <Settings />,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
const demoItems: SidebarItem[] = [];
|
||||
|
||||
const sidebarSections = buildSidebarSections(demoItems);
|
||||
const displayName = user?.name?.en || user?.username || user?.email || "User";
|
||||
@@ -244,6 +242,10 @@ const App = () => {
|
||||
/>
|
||||
<Route path="operations/train-scheduling" element={<TrainsPage />} />
|
||||
<Route path="trains" element={<TrainMasterDataPage />} />
|
||||
<Route path="operations/train-scheduling" element={<TrainsPage />} />
|
||||
<Route path="routes" element={<RoutesPage />} />
|
||||
<Route path="locomotives" element={<LocomotivesCrudPage />} />
|
||||
<Route path="trains" element={<TrainMasterDataPage />} />
|
||||
<Route path="trains/:id" element={<TrainDetailPage />} />
|
||||
<Route path="wagons" element={<WagonsCrudPage />} />
|
||||
<Route path="containers" element={<ContainersCrudPage />} />
|
||||
|
||||
@@ -162,7 +162,7 @@ function StepRow({
|
||||
borderRadius: 8,
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
borderLeft: isNext
|
||||
? "3px solid var(--mantine-color-green-6)"
|
||||
? "3px solid var(--freight-brand)"
|
||||
: "1px solid var(--mantine-color-gray-2)",
|
||||
background: isNext ? "var(--mantine-color-gray-0)" : "white",
|
||||
}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Download, Zap, FileText } from "lucide-react";
|
||||
import { Download, Zap, FileText, Clock } from "lucide-react";
|
||||
import { Stack, Text, Button } from "@mantine/core";
|
||||
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
@@ -69,6 +69,28 @@ export function BookingActionsToolbar({ booking, mutations }: BookingActionsTool
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
["FULLY_EXECUTED", "PNR_GENERATED", "PAYMENT_VERIFICATION_IN_PROGRESS"].includes(
|
||||
status,
|
||||
)
|
||||
) {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<SectionCard icon={Clock} title="Awaiting customer payment">
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" c="dimmed">
|
||||
Payment is completed by the customer. The booking status updates
|
||||
automatically once payment is confirmed, then moves to Operations.
|
||||
</Text>
|
||||
{status === "FULLY_EXECUTED" && (
|
||||
<BookingActionsMenu row={row} variant="toolbar" />
|
||||
)}
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<SectionCard icon={Zap} title="Staff actions">
|
||||
|
||||
@@ -14,7 +14,7 @@ export function BookingApprovalProgressCell({ row }: BookingApprovalProgressCell
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm font-semibold",
|
||||
summary.complete ? "text-emerald-700 dark:text-emerald-400" : "text-foreground",
|
||||
summary.complete ? "text-[color:var(--freight-brand)]" : "text-foreground",
|
||||
)}
|
||||
>
|
||||
{summary.label}
|
||||
|
||||
@@ -12,7 +12,7 @@ export interface StatItem {
|
||||
const accentColors = {
|
||||
default: { bg: "var(--mantine-color-gray-1)", color: "var(--mantine-color-gray-6)" },
|
||||
amber: { bg: "var(--mantine-color-yellow-1)", color: "var(--mantine-color-yellow-6)" },
|
||||
emerald: { bg: "var(--mantine-color-green-1)", color: "var(--mantine-color-green-6)" },
|
||||
emerald: { bg: "var(--freight-brand-muted)", color: "var(--freight-brand)" },
|
||||
rose: { bg: "var(--mantine-color-red-1)", color: "var(--mantine-color-red-6)" },
|
||||
};
|
||||
|
||||
@@ -61,7 +61,7 @@ export function BookingStatGrid({ items }: { items: StatItem[] }) {
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.boxShadow = "0 4px 12px rgba(34, 197, 94, 0.12)";
|
||||
e.currentTarget.style.borderColor = "var(--mantine-color-green-3)";
|
||||
e.currentTarget.style.borderColor = "var(--freight-brand-border)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.boxShadow = "none";
|
||||
|
||||
@@ -63,12 +63,12 @@ export function BookingStatusTabs({
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
background: isActive ? "white" : "transparent",
|
||||
border: isActive ? "1px solid var(--mantine-color-green-3)" : "1px solid var(--mantine-color-gray-2)",
|
||||
border: isActive ? "1px solid var(--freight-brand-border)" : "1px solid var(--mantine-color-gray-2)",
|
||||
borderRadius: "10px",
|
||||
padding: "10px 16px",
|
||||
transition: "all 0.2s ease",
|
||||
cursor: "pointer",
|
||||
boxShadow: isActive ? "0 2px 8px rgba(34, 197, 94, 0.1)" : "none",
|
||||
boxShadow: isActive ? "0 2px 8px rgb(21 128 61 / 0.12)" : "none",
|
||||
}}
|
||||
>
|
||||
<Group gap="sm" justify="space-between" wrap="nowrap">
|
||||
@@ -81,8 +81,8 @@ export function BookingStatusTabs({
|
||||
width: "32px",
|
||||
height: "32px",
|
||||
borderRadius: "8px",
|
||||
background: isActive ? "var(--mantine-color-green-1)" : "var(--mantine-color-gray-1)",
|
||||
color: isActive ? "var(--mantine-color-green-7)" : "var(--mantine-color-gray-6)",
|
||||
background: isActive ? "var(--freight-brand-muted)" : "var(--mantine-color-gray-1)",
|
||||
color: isActive ? "var(--freight-brand-dark)" : "var(--mantine-color-gray-6)",
|
||||
}}
|
||||
>
|
||||
{TAB_ICONS[tab.key]}
|
||||
|
||||
@@ -73,7 +73,7 @@ export function BookingWorkflowStepper({
|
||||
color: isComplete
|
||||
? "white"
|
||||
: isActive
|
||||
? "var(--mantine-color-green-7)"
|
||||
? "var(--freight-brand-dark)"
|
||||
: "var(--mantine-color-gray-5)",
|
||||
transition: "all 0.2s ease",
|
||||
}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Shared surfaces for booking list & detail — frosted glass, neutral accents. */
|
||||
/** Shared surfaces for booking list & detail — frosted glass, brand accents. */
|
||||
|
||||
export const bookingGlass = {
|
||||
card: "border border-border/50 bg-card/75 shadow-sm backdrop-blur-md supports-[backdrop-filter]:bg-card/60",
|
||||
@@ -10,9 +10,9 @@ export const bookingGlass = {
|
||||
iconWellHero:
|
||||
"border border-border/50 bg-background/60 text-foreground shadow-sm ring-1 ring-border/30 backdrop-blur-md supports-[backdrop-filter]:bg-background/45",
|
||||
activeTab:
|
||||
"border border-emerald-500/20 bg-emerald-500/10 shadow-sm backdrop-blur-md ring-1 ring-emerald-500/10 supports-[backdrop-filter]:bg-emerald-500/[0.08]",
|
||||
"border border-[color:var(--freight-brand-border)] bg-[color:var(--freight-brand-muted)] shadow-sm backdrop-blur-md ring-1 ring-[color:var(--freight-brand-ring)]",
|
||||
iconWellGreen:
|
||||
"border border-emerald-500/20 bg-emerald-500/15 text-emerald-700 shadow-sm backdrop-blur-sm supports-[backdrop-filter]:bg-emerald-500/10 dark:text-emerald-400",
|
||||
"border border-[color:var(--freight-brand-border)] bg-[color:var(--freight-brand-muted)] text-[color:var(--freight-brand)] shadow-sm backdrop-blur-sm",
|
||||
tabRail:
|
||||
"rounded-xl border border-border/60 bg-muted/10 p-2 backdrop-blur-sm supports-[backdrop-filter]:bg-muted/5",
|
||||
tableHeader:
|
||||
@@ -38,7 +38,7 @@ export const bookingSurface = {
|
||||
sectionIcon: `flex size-9 shrink-0 items-center justify-center rounded-lg ${bookingGlass.iconWellGreen}`,
|
||||
sectionIconLg: `flex size-11 shrink-0 items-center justify-center rounded-xl ${bookingGlass.iconWellGreen}`,
|
||||
valueCard:
|
||||
"rounded-xl border border-emerald-500/20 bg-emerald-500/10 p-4 shadow-sm backdrop-blur-md supports-[backdrop-filter]:bg-emerald-500/[0.08]",
|
||||
"rounded-xl border border-[color:var(--freight-brand-border)] bg-[color:var(--freight-brand-muted)] p-4 shadow-sm backdrop-blur-md",
|
||||
stickySidebar: "lg:sticky lg:top-6 lg:self-start",
|
||||
metricTile:
|
||||
"rounded-lg border border-border/50 bg-background/70 px-4 py-3 shadow-xs backdrop-blur-sm",
|
||||
@@ -48,7 +48,7 @@ export const bookingSurface = {
|
||||
|
||||
export const bookingInput = {
|
||||
search:
|
||||
"h-10 w-full rounded-lg border border-border/60 bg-background/80 pl-10 text-sm shadow-xs backdrop-blur-sm transition-[box-shadow,border-color] placeholder:text-muted-foreground focus-visible:border-ring/60 focus-visible:ring-[3px] focus-visible:ring-ring/20 sm:max-w-xs",
|
||||
"h-10 w-full rounded-lg border border-border/60 bg-background/80 pl-10 text-sm shadow-xs backdrop-blur-sm transition-[box-shadow,border-color] placeholder:text-muted-foreground focus-visible:border-[color:var(--freight-brand)] focus-visible:ring-[3px] focus-visible:ring-[color:var(--freight-brand-ring)] sm:max-w-xs",
|
||||
} as const;
|
||||
|
||||
export const bookingTable = {
|
||||
|
||||
@@ -36,7 +36,7 @@ export function BookingDocumentsCard({ files, onDownload }: BookingDocumentsCard
|
||||
style={detailStyles.fileRow}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = "var(--mantine-color-gray-0)";
|
||||
e.currentTarget.style.borderColor = "var(--mantine-color-green-3)";
|
||||
e.currentTarget.style.borderColor = "var(--freight-brand-border)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = "transparent";
|
||||
|
||||
@@ -52,7 +52,7 @@ export function BookingLifecycleStepper({ status }: BookingLifecycleStepperProps
|
||||
color: isComplete
|
||||
? "white"
|
||||
: isActive
|
||||
? "var(--mantine-color-green-7)"
|
||||
? "var(--freight-brand-dark)"
|
||||
: "var(--mantine-color-gray-5)",
|
||||
transition: "all 0.2s ease",
|
||||
}}
|
||||
|
||||
@@ -31,7 +31,7 @@ export function BookingRouteCard({ booking }: BookingRouteCardProps) {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: "50%",
|
||||
background: "var(--mantine-color-green-6)",
|
||||
background: "var(--freight-brand)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -27,7 +27,7 @@ function Endpoint({
|
||||
{label}
|
||||
</Text>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<MapPin size={15} color="var(--mantine-color-green-6)" />
|
||||
<MapPin size={15} color="var(--freight-brand)" />
|
||||
<Text fw={600} truncate>
|
||||
{station}
|
||||
</Text>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
import { FREIGHT_BRAND } from "@/theme/freight-brand";
|
||||
|
||||
/** Single brand accent. Minimal design uses solid green sparingly, no gradients. */
|
||||
export const BRAND_GREEN = "var(--mantine-color-green-6)";
|
||||
export const BRAND_GREEN = FREIGHT_BRAND;
|
||||
|
||||
/** Centralised style tokens for the booking detail page + cards. */
|
||||
export const detailStyles = {
|
||||
|
||||
@@ -86,14 +86,8 @@ export function useBookingActionDialog(
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "generateContract":
|
||||
mutations.generateContract.mutate(undefined, { onSuccess });
|
||||
break;
|
||||
case "viewContract":
|
||||
break;
|
||||
case "payBooking":
|
||||
mutations.payBooking.mutate(undefined, { onSuccess });
|
||||
break;
|
||||
case "startTransit":
|
||||
mutations.startTransit.mutate(undefined, { onSuccess });
|
||||
break;
|
||||
|
||||
@@ -9,9 +9,10 @@ import {
|
||||
Sun,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { Group, Stack, Text, Avatar, Menu, ActionIcon, Badge } from "@mantine/core";
|
||||
import { Group, Stack, Text, Avatar, Menu, ActionIcon, Badge, Box } from "@mantine/core";
|
||||
|
||||
import type { PageMeta } from "./types";
|
||||
import { freightBrand } from "@/theme/freight-brand";
|
||||
|
||||
export interface FreightDashboardHeaderProps {
|
||||
pageMeta: PageMeta;
|
||||
@@ -81,10 +82,11 @@ const FreightDashboardHeader = ({
|
||||
justifyContent: "space-between",
|
||||
gap: "16px",
|
||||
padding: "0 24px",
|
||||
// borderBottom: `3px solid ${freightBrand.primary}`,
|
||||
}}
|
||||
>
|
||||
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="lg" fw={700} truncate>
|
||||
<Text size="lg" fw={700} truncate style={{ color: freightBrand.primaryDark }}>
|
||||
{pageMeta.title}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" truncate>
|
||||
@@ -173,7 +175,7 @@ const FreightDashboardHeader = ({
|
||||
<Menu position="bottom-end" shadow="md" opened={isUserMenuOpen} onOpen={() => setIsUserMenuOpen(true)} onClose={() => setIsUserMenuOpen(false)}>
|
||||
<Menu.Target>
|
||||
<Group gap="sm" p="xs" style={{ cursor: "pointer", borderRadius: "12px" }}>
|
||||
<Avatar name={initials} color="green" size="md" />
|
||||
<Avatar name={initials} color="green" size="md" styles={{ root: { background: freightBrand.primary } }} />
|
||||
<ChevronDown size={16} style={{ transition: "transform 0.2s", transform: isUserMenuOpen ? "rotate(180deg)" : "rotate(0deg)" }} />
|
||||
</Group>
|
||||
</Menu.Target>
|
||||
|
||||
@@ -5,6 +5,7 @@ import FreightDashboardHeader from "./FreightDashboardHeader";
|
||||
import FreightSidebar from "./FreightSidebar";
|
||||
import { getPageMeta } from "./route-meta";
|
||||
import type { SidebarSection } from "./types";
|
||||
import { freightMantineTheme } from "@/theme/freight-brand";
|
||||
|
||||
type Theme = "light" | "dark";
|
||||
const THEME_STORAGE_KEY = "edr-theme";
|
||||
@@ -71,7 +72,7 @@ const FreightDashboardLayout = ({
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
<MantineProvider>
|
||||
<MantineProvider theme={freightMantineTheme}>
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ChevronDown, ChevronRight, Train } from "lucide-react";
|
||||
import { Stack, Group, Text, Box, UnstyledButton, NavLink } from "@mantine/core";
|
||||
|
||||
import type { SidebarItem, SidebarSection } from "./types";
|
||||
import { freightBrand } from "@/theme/freight-brand";
|
||||
|
||||
export interface FreightSidebarProps {
|
||||
sections: SidebarSection[];
|
||||
@@ -118,7 +119,7 @@ const FreightSidebar = ({
|
||||
<UnstyledButton
|
||||
onClick={() => toggleExpanded(key)}
|
||||
style={{
|
||||
background: groupActive ? "var(--mantine-color-green-1)" : "transparent",
|
||||
background: groupActive ? freightBrand.mutedBg : "transparent",
|
||||
padding: "8px 12px",
|
||||
borderRadius: "8px",
|
||||
width: "100%",
|
||||
@@ -126,7 +127,7 @@ const FreightSidebar = ({
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between">
|
||||
<Text size="xs" fw={600} c={groupActive ? "green" : "dimmed"} tt="uppercase">
|
||||
<Text size="xs" fw={600} style={{ color: groupActive ? freightBrand.primary : undefined }} c={groupActive ? undefined : "dimmed"} tt="uppercase">
|
||||
{child.label}
|
||||
</Text>
|
||||
<ChevronDown
|
||||
@@ -134,13 +135,13 @@ const FreightSidebar = ({
|
||||
style={{
|
||||
transform: isOpen ? "rotate(0deg)" : "rotate(-90deg)",
|
||||
transition: "transform 0.2s",
|
||||
color: groupActive ? "var(--mantine-color-green-6)" : "var(--mantine-color-gray-5)",
|
||||
color: groupActive ? freightBrand.primary : "var(--mantine-color-gray-5)",
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
{isOpen && (
|
||||
<Stack gap={2} style={{ paddingLeft: "12px", borderLeft: "2px solid var(--mantine-color-green-2)" }}>
|
||||
<Stack gap={2} style={{ paddingLeft: "12px", borderLeft: `2px solid ${freightBrand.mutedBorder}` }}>
|
||||
{renderNavBranch(child.children!, depth + 1, key)}
|
||||
</Stack>
|
||||
)}
|
||||
@@ -184,11 +185,9 @@ const FreightSidebar = ({
|
||||
height: "30px",
|
||||
borderRadius: "8px",
|
||||
flexShrink: 0,
|
||||
background: active
|
||||
? "linear-gradient(135deg, #10b981 0%, #059669 100%)"
|
||||
: "var(--mantine-color-gray-1)",
|
||||
background: active ? freightBrand.gradient : "var(--mantine-color-gray-1)",
|
||||
color: active ? "white" : "var(--mantine-color-gray-6)",
|
||||
boxShadow: active ? "0 2px 6px rgba(16, 185, 129, 0.25)" : "none",
|
||||
boxShadow: active ? freightBrand.shadowSm : "none",
|
||||
transition: "all 0.2s ease",
|
||||
}}
|
||||
>
|
||||
@@ -251,7 +250,7 @@ const FreightSidebar = ({
|
||||
/>
|
||||
|
||||
{hasChildren && isOpen && (
|
||||
<Stack gap={4} style={{ paddingLeft: "16px", borderLeft: "2px solid var(--mantine-color-green-2)" }}>
|
||||
<Stack gap={4} style={{ paddingLeft: "16px", borderLeft: `2px solid ${freightBrand.mutedBorder}` }}>
|
||||
{renderNavBranch(item.children!, 0, item.href)}
|
||||
</Stack>
|
||||
)}
|
||||
@@ -295,8 +294,8 @@ const FreightSidebar = ({
|
||||
width: "44px",
|
||||
height: "44px",
|
||||
borderRadius: "12px",
|
||||
background: "linear-gradient(135deg, #10b981 0%, #059669 100%)",
|
||||
boxShadow: "0 4px 12px rgba(16, 185, 129, 0.3)",
|
||||
background: freightBrand.gradient,
|
||||
boxShadow: freightBrand.shadow,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -35,6 +35,20 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
|
||||
subtitle: "Dashboard summary and key metrics",
|
||||
},
|
||||
},
|
||||
{
|
||||
prefix: "/dashboard/routes",
|
||||
meta: {
|
||||
title: "Routes",
|
||||
subtitle: "Manage route definitions built from freight yards",
|
||||
},
|
||||
},
|
||||
{
|
||||
prefix: "/dashboard/locomotives",
|
||||
meta: {
|
||||
title: "Locomotives",
|
||||
subtitle: "Manage locomotive master data and service status",
|
||||
},
|
||||
},
|
||||
{
|
||||
prefix: "/dashboard/user-management/employees",
|
||||
meta: {
|
||||
@@ -81,7 +95,7 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
|
||||
prefix: RULE_ENGINE_CATEGORY_BASE_PATH.configuration,
|
||||
meta: {
|
||||
title: "Configuration",
|
||||
subtitle: "Master data: cargo, containers, services, surcharges, yards, and shipping lines",
|
||||
subtitle: "Master data: cargo, containers, wagon types, services, surcharges, yards, and shipping lines",
|
||||
},
|
||||
},
|
||||
...configurationRouteMeta,
|
||||
|
||||
@@ -121,8 +121,8 @@ const RuleEngineCardGrid = ({
|
||||
);
|
||||
}
|
||||
|
||||
const avatarColors = ["blue", "cyan", "grape", "green", "lime", "orange", "pink", "red", "teal", "violet", "yellow"];
|
||||
const getAvatarColor = (title: string) => avatarColors[title.charCodeAt(0) % avatarColors.length];
|
||||
const avatarBg = "#f1f5f9";
|
||||
const avatarText = "#475569";
|
||||
|
||||
return (
|
||||
<Stack gap="md" p="md">
|
||||
@@ -160,8 +160,8 @@ const RuleEngineCardGrid = ({
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.boxShadow = "0 4px 12px rgba(0, 0, 0, 0.1)";
|
||||
e.currentTarget.style.borderColor = "var(--mantine-color-blue-3)";
|
||||
e.currentTarget.style.boxShadow = "0 4px 12px rgba(0, 0, 0, 0.08)";
|
||||
e.currentTarget.style.borderColor = "var(--mantine-color-gray-3)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.boxShadow = "none";
|
||||
@@ -178,10 +178,10 @@ const RuleEngineCardGrid = ({
|
||||
width: "44px",
|
||||
height: "44px",
|
||||
borderRadius: "8px",
|
||||
background: `var(--mantine-color-${getAvatarColor(title)}-1)`,
|
||||
background: avatarBg,
|
||||
fontSize: "16px",
|
||||
fontWeight: 700,
|
||||
color: `var(--mantine-color-${getAvatarColor(title)}-7)`,
|
||||
color: avatarText,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import {
|
||||
Modal,
|
||||
@@ -9,9 +9,10 @@ import {
|
||||
Switch,
|
||||
Stack,
|
||||
Group,
|
||||
Divider,
|
||||
Text,
|
||||
Box,
|
||||
SimpleGrid,
|
||||
Divider,
|
||||
} from "@mantine/core";
|
||||
|
||||
import {
|
||||
@@ -32,6 +33,43 @@ export interface RuleEngineFormDialogProps {
|
||||
onSubmit: (values: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
type FormRow =
|
||||
| { kind: "pair"; fields: [FormFieldDef, FormFieldDef] }
|
||||
| { kind: "single"; field: FormFieldDef };
|
||||
|
||||
const isShortField = (field: FormFieldDef) =>
|
||||
field.type === "text" ||
|
||||
field.type === "number" ||
|
||||
field.type === "select" ||
|
||||
field.type === "date";
|
||||
|
||||
const buildFormRows = (fields: FormFieldDef[]): FormRow[] => {
|
||||
const rows: FormRow[] = [];
|
||||
let index = 0;
|
||||
|
||||
while (index < fields.length) {
|
||||
const field = fields[index];
|
||||
|
||||
if (field.type === "textarea" || field.type === "boolean") {
|
||||
rows.push({ kind: "single", field });
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const next = fields[index + 1];
|
||||
if (next && isShortField(next)) {
|
||||
rows.push({ kind: "pair", fields: [field, next] });
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
rows.push({ kind: "single", field });
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return rows;
|
||||
};
|
||||
|
||||
const buildInitialValues = (
|
||||
fields: FormFieldDef[],
|
||||
record?: RuleEngineRecord | null,
|
||||
@@ -42,6 +80,8 @@ const buildInitialValues = (
|
||||
if (raw !== undefined && raw !== null) {
|
||||
if (field.type === "date" && typeof raw === "string") {
|
||||
values[field.name] = raw.slice(0, 10);
|
||||
} else if (Array.isArray(raw)) {
|
||||
values[field.name] = raw.join(", ");
|
||||
} else {
|
||||
values[field.name] = raw;
|
||||
}
|
||||
@@ -74,11 +114,34 @@ const resolveSelectValue = (
|
||||
return String(raw);
|
||||
};
|
||||
|
||||
const inputStyles = {
|
||||
label: { fontWeight: 600, marginBottom: 6, color: "var(--mantine-color-gray-8)" },
|
||||
input: {
|
||||
borderColor: "#e2e8f0",
|
||||
background: "white",
|
||||
transition: "border-color 0.15s ease, box-shadow 0.15s ease",
|
||||
"&:focus": {
|
||||
borderColor: "var(--freight-brand)",
|
||||
boxShadow: "0 0 0 3px var(--freight-brand-ring)",
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const FieldLabel = ({ label, required }: { label: string; required?: boolean }) => (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<span>{label}</span>
|
||||
{required ? (
|
||||
<Text component="span" c="red" size="sm">
|
||||
*
|
||||
</Text>
|
||||
) : null}
|
||||
</Group>
|
||||
);
|
||||
|
||||
const RuleEngineFormDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
fields,
|
||||
initialRecord,
|
||||
isSubmitting,
|
||||
@@ -95,6 +158,8 @@ const RuleEngineFormDialog = ({
|
||||
}
|
||||
}, [open, fields, initialRecord]);
|
||||
|
||||
const formRows = useMemo(() => buildFormRows(fields), [fields]);
|
||||
|
||||
const setField = (name: string, value: unknown) => {
|
||||
setValues((current) => ({ ...current, [name]: value }));
|
||||
};
|
||||
@@ -130,157 +195,147 @@ const RuleEngineFormDialog = ({
|
||||
onSubmit(payload);
|
||||
};
|
||||
|
||||
const renderField = (field: FormFieldDef) => {
|
||||
if (field.type === "boolean") {
|
||||
return (
|
||||
<Group
|
||||
key={field.name}
|
||||
justify="space-between"
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
gap="md"
|
||||
px="md"
|
||||
style={{
|
||||
minHeight: 42,
|
||||
background: "#f8fafc",
|
||||
border: "1px solid #e2e8f0",
|
||||
borderRadius: "var(--mantine-radius-md)",
|
||||
}}
|
||||
>
|
||||
<Text size="sm" fw={600}>
|
||||
{field.label}
|
||||
</Text>
|
||||
<Switch
|
||||
checked={Boolean(values[field.name])}
|
||||
onChange={(e) => setField(field.name, e.currentTarget.checked)}
|
||||
size="md"
|
||||
color="green"
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
const label = <FieldLabel label={field.label} required={field.required} />;
|
||||
|
||||
if (field.type === "select") {
|
||||
return (
|
||||
<Select
|
||||
key={field.name}
|
||||
label={label}
|
||||
placeholder={
|
||||
selectOptionsLoading ? "Loading options..." : (field.placeholder ?? "Select an option")
|
||||
}
|
||||
value={resolveSelectValue(field, values)}
|
||||
onChange={(v) => setField(field.name, v === RULE_ENGINE_SELECT_NONE ? "" : v)}
|
||||
disabled={selectOptionsLoading}
|
||||
data={(field.options ?? [])
|
||||
.filter((opt) => opt.value !== "")
|
||||
.map((opt) => ({
|
||||
label: opt.label,
|
||||
value: opt.value,
|
||||
}))}
|
||||
searchable
|
||||
clearable
|
||||
size="md"
|
||||
radius="md"
|
||||
styles={inputStyles}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === "textarea") {
|
||||
return (
|
||||
<Textarea
|
||||
key={field.name}
|
||||
label={label}
|
||||
value={String(values[field.name] ?? "")}
|
||||
onChange={(e) => setField(field.name, e.currentTarget.value)}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
minRows={4}
|
||||
autosize
|
||||
maxRows={8}
|
||||
size="md"
|
||||
radius="md"
|
||||
styles={inputStyles}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
key={field.name}
|
||||
label={label}
|
||||
type={field.type === "number" ? "number" : field.type === "date" ? "date" : "text"}
|
||||
value={String(values[field.name] ?? "")}
|
||||
onChange={(e) => setField(field.name, e.currentTarget.value)}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
size="md"
|
||||
radius="md"
|
||||
styles={inputStyles}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={open}
|
||||
onClose={() => onOpenChange(false)}
|
||||
title={title}
|
||||
title={
|
||||
<Text size="lg" fw={700} lh={1.2}>
|
||||
{title}
|
||||
</Text>
|
||||
}
|
||||
centered
|
||||
size="md"
|
||||
size={720}
|
||||
radius="lg"
|
||||
padding="xl"
|
||||
overlayProps={{ backgroundOpacity: 0.45, blur: 3 }}
|
||||
styles={{
|
||||
header: {
|
||||
paddingBottom: "20px",
|
||||
borderBottom: "1px solid var(--mantine-color-gray-2)",
|
||||
content: {
|
||||
maxWidth: "min(720px, 95vw)",
|
||||
},
|
||||
body: {
|
||||
paddingTop: "24px",
|
||||
paddingBottom: "24px",
|
||||
},
|
||||
title: {
|
||||
fontSize: "1.25rem",
|
||||
fontWeight: 700,
|
||||
color: "var(--mantine-color-gray-9)",
|
||||
paddingTop: 20,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="lg">
|
||||
<Text size="sm" c="dimmed">
|
||||
{description}
|
||||
</Text>
|
||||
|
||||
<div style={{ maxHeight: "calc(60vh - 150px)", overflowY: "auto", paddingRight: "12px" }}>
|
||||
<Stack gap="lg">
|
||||
{fields.map((field) => (
|
||||
<Box key={field.name}>
|
||||
{field.type === "boolean" ? (
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="center"
|
||||
p="lg"
|
||||
style={{
|
||||
background: "linear-gradient(135deg, var(--mantine-color-blue-0) 0%, var(--mantine-color-cyan-0) 100%)",
|
||||
borderRadius: "12px",
|
||||
border: "1px solid var(--mantine-color-blue-2)",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb="4px">
|
||||
{field.label}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{Boolean(values[field.name]) ? "✓ Enabled" : "○ Disabled"}
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={Boolean(values[field.name])}
|
||||
onChange={(e) => setField(field.name, e.currentTarget.checked)}
|
||||
size="lg"
|
||||
/>
|
||||
</Group>
|
||||
) : field.type === "select" ? (
|
||||
<Select
|
||||
label={
|
||||
<Group gap="4px">
|
||||
<span>{field.label}</span>
|
||||
{field.required && <span style={{ color: "var(--mantine-color-red-6)" }}>*</span>}
|
||||
</Group>
|
||||
}
|
||||
placeholder={
|
||||
selectOptionsLoading ? "Loading options..." : (field.placeholder ?? "Select an option")
|
||||
}
|
||||
value={resolveSelectValue(field, values)}
|
||||
onChange={(v) =>
|
||||
setField(field.name, v === RULE_ENGINE_SELECT_NONE ? "" : v)
|
||||
}
|
||||
disabled={selectOptionsLoading}
|
||||
data={(field.options ?? []).filter((opt) => opt.value !== "").map((opt) => ({
|
||||
label: opt.label,
|
||||
value: opt.value,
|
||||
}))}
|
||||
searchable
|
||||
clearable
|
||||
size="md"
|
||||
radius="lg"
|
||||
styles={{
|
||||
input: {
|
||||
borderColor: "var(--mantine-color-gray-3)",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
) : field.type === "textarea" ? (
|
||||
<Textarea
|
||||
label={
|
||||
<Group gap="4px">
|
||||
<span>{field.label}</span>
|
||||
{field.required && <span style={{ color: "var(--mantine-color-red-6)" }}>*</span>}
|
||||
</Group>
|
||||
}
|
||||
value={String(values[field.name] ?? "")}
|
||||
onChange={(e) => setField(field.name, e.currentTarget.value)}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
minRows={5}
|
||||
size="md"
|
||||
radius="lg"
|
||||
styles={{
|
||||
input: {
|
||||
borderColor: "var(--mantine-color-gray-3)",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<TextInput
|
||||
label={
|
||||
<Group gap="4px">
|
||||
<span>{field.label}</span>
|
||||
{field.required && <span style={{ color: "var(--mantine-color-red-6)" }}>*</span>}
|
||||
</Group>
|
||||
}
|
||||
type={
|
||||
field.type === "number"
|
||||
? "number"
|
||||
: field.type === "date"
|
||||
? "date"
|
||||
: "text"
|
||||
}
|
||||
value={String(values[field.name] ?? "")}
|
||||
onChange={(e) => setField(field.name, e.currentTarget.value)}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
size="md"
|
||||
radius="lg"
|
||||
styles={{
|
||||
input: {
|
||||
borderColor: "var(--mantine-color-gray-3)",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
<Box style={{ maxHeight: "calc(65vh - 120px)", overflowY: "auto", paddingRight: 4 }}>
|
||||
<Stack gap="md">
|
||||
{formRows.map((row) =>
|
||||
row.kind === "pair" ? (
|
||||
<SimpleGrid key={`${row.fields[0].name}-${row.fields[1].name}`} cols={2} spacing="md">
|
||||
<Box style={{ minWidth: 0 }}>{renderField(row.fields[0])}</Box>
|
||||
<Box style={{ minWidth: 0 }}>{renderField(row.fields[1])}</Box>
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<Box key={row.field.name}>{renderField(row.field)}</Box>
|
||||
),
|
||||
)}
|
||||
</Stack>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="light"
|
||||
variant="default"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
radius="lg"
|
||||
radius="md"
|
||||
size="md"
|
||||
>
|
||||
Cancel
|
||||
@@ -288,9 +343,15 @@ const RuleEngineFormDialog = ({
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
leftSection={isSubmitting && <Loader2 size={18} style={{ animation: "spin 1s linear infinite" }} />}
|
||||
radius="lg"
|
||||
color="blue"
|
||||
leftSection={
|
||||
isSubmitting ? (
|
||||
<Loader2 size={18} style={{ animation: "spin 1s linear infinite" }} />
|
||||
) : undefined
|
||||
}
|
||||
radius="md"
|
||||
color="green"
|
||||
variant="filled"
|
||||
fw={600}
|
||||
size="md"
|
||||
>
|
||||
{isSubmitting ? "Saving..." : "Save"}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
Send,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { Button, Menu, Group } from "@mantine/core";
|
||||
import { ActionIcon, Button, Group, Menu, Tooltip } from "@mantine/core";
|
||||
|
||||
import type { RuleEngineResourceConfig } from "@/pages/ruleEngine/config/resources";
|
||||
import type { RuleEngineRecord } from "@/types/rule-engine";
|
||||
@@ -23,6 +23,16 @@ export interface RuleEngineRecordActionsProps {
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
const actionGroupStyle = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
borderRadius: 10,
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
padding: 3,
|
||||
} as const;
|
||||
|
||||
const RuleEngineRecordActions = ({
|
||||
record,
|
||||
config,
|
||||
@@ -37,85 +47,209 @@ const RuleEngineRecordActions = ({
|
||||
const status = String(record.status ?? "");
|
||||
const hasRateActions =
|
||||
config.slug === "rates" && (status === "DRAFT" || status === "PENDING_APPROVAL");
|
||||
const showViewChain = config.slug === "approval-rules" && onViewChain;
|
||||
|
||||
if (readOnly) {
|
||||
return onViewChain ? (
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
onClick={onViewChain}
|
||||
leftSection={<Eye size={16} />}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
return showViewChain ? (
|
||||
<Tooltip label="View approval chain">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="md"
|
||||
radius="md"
|
||||
onClick={onViewChain}
|
||||
aria-label="View approval chain"
|
||||
>
|
||||
<Eye size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Group gap={2} justify="flex-end">
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
onClick={() => onEdit(record)}
|
||||
leftSection={<Pencil size={16} />}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
if (layout === "compact") {
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap" justify="flex-end">
|
||||
{showViewChain ? (
|
||||
<Button
|
||||
variant="light"
|
||||
color="gray"
|
||||
size="compact-sm"
|
||||
radius="md"
|
||||
onClick={onViewChain}
|
||||
leftSection={<Eye size={14} />}
|
||||
>
|
||||
Chain
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{config.slug === "approval-rules" && onViewChain ? (
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
onClick={onViewChain}
|
||||
leftSection={<Eye size={16} />}
|
||||
>
|
||||
View Chain
|
||||
</Button>
|
||||
{hasRateActions ? (
|
||||
<RateActionsMenu
|
||||
status={status}
|
||||
onSubmitRate={onSubmitRate}
|
||||
onApproveRate={onApproveRate}
|
||||
record={record}
|
||||
compact
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div style={actionGroupStyle}>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="compact-sm"
|
||||
radius="md"
|
||||
onClick={() => onEdit(record)}
|
||||
leftSection={<Pencil size={14} />}
|
||||
styles={{ root: { fontWeight: 600 } }}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="compact-sm"
|
||||
radius="md"
|
||||
onClick={() => onDelete(record)}
|
||||
leftSection={<Trash2 size={14} />}
|
||||
styles={{ root: { fontWeight: 600 } }}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap" justify="flex-end">
|
||||
{showViewChain ? (
|
||||
<Tooltip label="View approval chain">
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="gray"
|
||||
size="md"
|
||||
radius="md"
|
||||
onClick={onViewChain}
|
||||
aria-label="View approval chain"
|
||||
style={{
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
background: "white",
|
||||
}}
|
||||
>
|
||||
<Eye size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
|
||||
{hasRateActions ? (
|
||||
<Menu position="bottom-end" shadow="md">
|
||||
<Menu.Target>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
leftSection={<MoreHorizontal size={16} />}
|
||||
>
|
||||
More
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{status === "DRAFT" && onSubmitRate ? (
|
||||
<Menu.Item
|
||||
leftSection={<Send size={14} />}
|
||||
onClick={() => onSubmitRate(record.id)}
|
||||
>
|
||||
Submit for approval
|
||||
</Menu.Item>
|
||||
) : null}
|
||||
{status === "PENDING_APPROVAL" && onApproveRate ? (
|
||||
<Menu.Item
|
||||
leftSection={<CheckCircle2 size={14} />}
|
||||
onClick={() => onApproveRate(record)}
|
||||
>
|
||||
Approve
|
||||
</Menu.Item>
|
||||
) : null}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
<RateActionsMenu
|
||||
status={status}
|
||||
onSubmitRate={onSubmitRate}
|
||||
onApproveRate={onApproveRate}
|
||||
record={record}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
onClick={() => onDelete(record)}
|
||||
leftSection={<Trash2 size={16} />}
|
||||
color="red"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
<div style={actionGroupStyle}>
|
||||
<Tooltip label="Edit">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="md"
|
||||
radius="md"
|
||||
onClick={() => onEdit(record)}
|
||||
aria-label="Edit record"
|
||||
style={{
|
||||
background: "white",
|
||||
}}
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="Delete">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="md"
|
||||
radius="md"
|
||||
onClick={() => onDelete(record)}
|
||||
aria-label="Delete record"
|
||||
style={{
|
||||
background: "var(--mantine-color-red-0)",
|
||||
}}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
function RateActionsMenu({
|
||||
status,
|
||||
onSubmitRate,
|
||||
onApproveRate,
|
||||
record,
|
||||
compact = false,
|
||||
}: {
|
||||
status: string;
|
||||
onSubmitRate?: (id: string) => void;
|
||||
onApproveRate?: (record: RuleEngineRecord) => void;
|
||||
record: RuleEngineRecord;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Menu position="bottom-end" shadow="md" withinPortal>
|
||||
<Menu.Target>
|
||||
{compact ? (
|
||||
<Button
|
||||
variant="light"
|
||||
color="gray"
|
||||
size="compact-sm"
|
||||
radius="md"
|
||||
leftSection={<MoreHorizontal size={14} />}
|
||||
>
|
||||
More
|
||||
</Button>
|
||||
) : (
|
||||
<Tooltip label="More actions">
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="gray"
|
||||
size="md"
|
||||
radius="md"
|
||||
aria-label="More actions"
|
||||
style={{
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
background: "white",
|
||||
}}
|
||||
>
|
||||
<MoreHorizontal size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{status === "DRAFT" && onSubmitRate ? (
|
||||
<Menu.Item
|
||||
leftSection={<Send size={14} />}
|
||||
onClick={() => onSubmitRate(record.id)}
|
||||
>
|
||||
Submit for approval
|
||||
</Menu.Item>
|
||||
) : null}
|
||||
{status === "PENDING_APPROVAL" && onApproveRate ? (
|
||||
<Menu.Item
|
||||
leftSection={<CheckCircle2 size={14} />}
|
||||
onClick={() => onApproveRate(record)}
|
||||
>
|
||||
Approve
|
||||
</Menu.Item>
|
||||
) : null}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
export default RuleEngineRecordActions;
|
||||
|
||||
@@ -44,6 +44,7 @@ const RuleEngineToolbar = ({
|
||||
onChange={(value) => onViewModeChange(value as RuleEngineViewMode)}
|
||||
size="sm"
|
||||
radius="lg"
|
||||
color="green"
|
||||
data={[
|
||||
{
|
||||
value: "table",
|
||||
@@ -77,7 +78,9 @@ const RuleEngineToolbar = ({
|
||||
leftSection={<Plus size={18} />}
|
||||
size="sm"
|
||||
radius="lg"
|
||||
color="blue"
|
||||
color="green"
|
||||
variant="filled"
|
||||
fw={600}
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
{addLabel}
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
const TITLE_KEY_PRIORITY = [
|
||||
"cargoTypeName",
|
||||
"serviceName",
|
||||
"name",
|
||||
"label",
|
||||
"actionLabel",
|
||||
"rateType",
|
||||
|
||||
@@ -91,6 +91,12 @@ export const formatCell = (value: unknown, format?: ColumnFormat): ReactNode =>
|
||||
return <Text size="sm">{d.toLocaleDateString()}</Text>;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return (
|
||||
<Text size="sm">{value.length > 0 ? value.join(", ") : "—"}</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (format === "entityLabel" && value && typeof value === "object") {
|
||||
const label = extractLabel(value);
|
||||
if (label) {
|
||||
|
||||
@@ -40,7 +40,7 @@ export const ruleEngineCard = {
|
||||
"group flex flex-col overflow-hidden rounded-lg border border-border bg-card shadow-sm transition-shadow duration-200 hover:shadow-md",
|
||||
header: "border-b border-border bg-muted/25 px-3 py-2.5 sm:px-4 sm:py-3.5",
|
||||
avatar:
|
||||
"flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-primary/12 text-xs font-semibold text-primary sm:h-10 sm:w-10 sm:text-sm",
|
||||
"flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-[#f1f5f9] text-xs font-semibold text-slate-600 sm:h-10 sm:w-10 sm:text-sm",
|
||||
title: "truncate text-sm font-semibold text-foreground sm:text-[15px]",
|
||||
meta: "text-xs text-muted-foreground",
|
||||
detailLabel:
|
||||
|
||||
@@ -20,7 +20,7 @@ export default function Breadcrumbs({ items }: BreadcrumbsProps) {
|
||||
<Link
|
||||
to="/"
|
||||
aria-label="Home"
|
||||
className="flex items-center transition hover:text-[#10B981]"
|
||||
className="flex items-center transition hover:text-[var(--freight-brand)]"
|
||||
>
|
||||
{/* <Home className="h-4 w-4" /> */}
|
||||
Dashboard
|
||||
@@ -36,7 +36,7 @@ export default function Breadcrumbs({ items }: BreadcrumbsProps) {
|
||||
{item.href && !isLast ? (
|
||||
<Link
|
||||
to={item.href}
|
||||
className="transition hover:text-[#10B981]"
|
||||
className="transition hover:text-[var(--freight-brand)]"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
|
||||
@@ -111,6 +111,13 @@ export const URL_CONSTANTS = {
|
||||
|
||||
LOCOMOTIVES: {
|
||||
BASE: "/locomotives",
|
||||
BY_ID: (id: string) => `/locomotives/${id}`,
|
||||
DECOMMISSION: (id: string) => `/locomotives/${id}/decommission`,
|
||||
},
|
||||
|
||||
ROUTES: {
|
||||
BASE: '/routes',
|
||||
BY_ID: (id: string) => `/routes/${id}`,
|
||||
},
|
||||
|
||||
TRAIN_SCHEDULING: {
|
||||
@@ -129,6 +136,9 @@ export const URL_CONSTANTS = {
|
||||
CONTAINER_TYPES: "/container-types",
|
||||
CONTAINER_TYPE_BY_ID: (id: string) => `/container-types/${id}`,
|
||||
|
||||
WAGON_TYPES: "/wagon-types",
|
||||
WAGON_TYPE_BY_ID: (id: string) => `/wagon-types/${id}`,
|
||||
|
||||
PRIORITY_RULES: "/priority-rules",
|
||||
PRIORITY_RULE_BY_ID: (id: string) => `/priority-rules/${id}`,
|
||||
|
||||
|
||||
@@ -3,12 +3,10 @@ import {
|
||||
Ban,
|
||||
Check,
|
||||
FileSignature,
|
||||
FileText,
|
||||
MessageSquareWarning,
|
||||
Play,
|
||||
ShieldCheck,
|
||||
Truck,
|
||||
Wallet,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -30,10 +28,8 @@ export type BookingActionId =
|
||||
| "reject"
|
||||
| "approve"
|
||||
| "rejectApproval"
|
||||
| "generateContract"
|
||||
| "viewContract"
|
||||
| "signContractStaff"
|
||||
| "payBooking"
|
||||
| "startTransit"
|
||||
| "complete"
|
||||
| "cancel";
|
||||
@@ -174,19 +170,6 @@ const SIGN_CONTRACT_STAFF_ACTION: BookingActionDef = {
|
||||
primary: true,
|
||||
};
|
||||
|
||||
const PAY_BOOKING_ACTION: BookingActionDef = {
|
||||
id: "payBooking",
|
||||
label: "Pay",
|
||||
shortLabel: "Pay",
|
||||
description: "Complete in-app payment",
|
||||
confirmTitle: "Complete payment?",
|
||||
confirmDescription:
|
||||
"This simulates an in-app payment (Telebirr for ETB, card for USD) and marks the booking as paid.",
|
||||
variant: "default",
|
||||
icon: Wallet,
|
||||
primary: true,
|
||||
};
|
||||
|
||||
function withCancel(actions: BookingActionDef[]): BookingActionDef[] {
|
||||
return [...actions, CANCEL_ACTION];
|
||||
}
|
||||
@@ -196,10 +179,8 @@ const ACTION_PERMISSION: Partial<Record<BookingActionId, string>> = {
|
||||
requestChanges: FREIGHT_PERMS.bookings.requestChanges,
|
||||
reject: FREIGHT_PERMS.bookings.reject,
|
||||
rejectApproval: FREIGHT_PERMS.bookings.rejectApproval,
|
||||
generateContract: FREIGHT_PERMS.bookings.generateContract,
|
||||
viewContract: FREIGHT_PERMS.bookings.view,
|
||||
signContractStaff: FREIGHT_PERMS.bookings.signStaff,
|
||||
payBooking: FREIGHT_PERMS.bookings.view,
|
||||
startTransit: FREIGHT_PERMS.bookings.operations,
|
||||
complete: FREIGHT_PERMS.bookings.operations,
|
||||
cancel: FREIGHT_PERMS.bookings.cancel,
|
||||
@@ -277,21 +258,7 @@ export function getBookingActions(
|
||||
actions = withCancel(approvalActions(approvalSteps));
|
||||
break;
|
||||
case "APPROVED":
|
||||
actions = [
|
||||
{
|
||||
id: "generateContract",
|
||||
label: "Generate contract",
|
||||
shortLabel: "Contract",
|
||||
description: "Create contract document",
|
||||
confirmTitle: "Generate contract?",
|
||||
confirmDescription:
|
||||
"A contract will be generated and the booking moves to contract ready.",
|
||||
variant: "default",
|
||||
icon: FileText,
|
||||
primary: true,
|
||||
},
|
||||
CANCEL_ACTION,
|
||||
];
|
||||
actions = [{ ...VIEW_CONTRACT_ACTION, primary: true }, CANCEL_ACTION];
|
||||
break;
|
||||
case "CONTRACT_READY":
|
||||
actions = [{ ...VIEW_CONTRACT_ACTION, primary: true }];
|
||||
@@ -300,10 +267,7 @@ export function getBookingActions(
|
||||
actions = [SIGN_CONTRACT_STAFF_ACTION, VIEW_CONTRACT_ACTION];
|
||||
break;
|
||||
case "FULLY_EXECUTED":
|
||||
actions = [
|
||||
PAY_BOOKING_ACTION,
|
||||
{ ...VIEW_CONTRACT_ACTION, label: "View executed contract" },
|
||||
];
|
||||
actions = [{ ...VIEW_CONTRACT_ACTION, label: "View executed contract", primary: true }];
|
||||
break;
|
||||
case "PAID":
|
||||
actions = [
|
||||
|
||||
@@ -28,7 +28,7 @@ export const BOOKING_STATUS_STYLES: Record<string, StatusStyle> = {
|
||||
},
|
||||
APPROVED: {
|
||||
label: "Approved",
|
||||
color: "bg-emerald-50 text-emerald-700 border-emerald-200",
|
||||
color: "bg-[color:var(--freight-brand-muted)] text-[color:var(--freight-brand)] border-[color:var(--freight-brand-border)]",
|
||||
},
|
||||
CONTRACT_READY: {
|
||||
label: "Contract Ready",
|
||||
@@ -52,7 +52,7 @@ export const BOOKING_STATUS_STYLES: Record<string, StatusStyle> = {
|
||||
},
|
||||
PAID: {
|
||||
label: "Paid",
|
||||
color: "bg-emerald-50 text-emerald-700 border-emerald-200",
|
||||
color: "bg-[color:var(--freight-brand-muted)] text-[color:var(--freight-brand)] border-[color:var(--freight-brand-border)]",
|
||||
},
|
||||
IN_TRANSIT: {
|
||||
label: "In Transit",
|
||||
@@ -120,8 +120,8 @@ export const BOOKING_STATUS_META: Record<string, StatusMeta> = {
|
||||
},
|
||||
APPROVED: {
|
||||
title: "Approved",
|
||||
description: "Ready to generate contract.",
|
||||
color: "text-emerald-600",
|
||||
description: "Contract generated automatically; awaiting customer signature.",
|
||||
color: "text-[color:var(--freight-brand)]",
|
||||
stage: 2,
|
||||
},
|
||||
CONTRACT_READY: {
|
||||
@@ -138,7 +138,7 @@ export const BOOKING_STATUS_META: Record<string, StatusMeta> = {
|
||||
},
|
||||
FULLY_EXECUTED: {
|
||||
title: "Fully Executed",
|
||||
description: "Contract locked; proceed to payment.",
|
||||
description: "Contract locked; awaiting customer payment.",
|
||||
color: "text-indigo-600",
|
||||
stage: 3,
|
||||
},
|
||||
@@ -157,7 +157,7 @@ export const BOOKING_STATUS_META: Record<string, StatusMeta> = {
|
||||
PAID: {
|
||||
title: "Paid",
|
||||
description: "Payment confirmed; ready for operations.",
|
||||
color: "text-emerald-600",
|
||||
color: "text-[color:var(--freight-brand)]",
|
||||
stage: 4,
|
||||
},
|
||||
IN_TRANSIT: {
|
||||
@@ -219,9 +219,17 @@ export const BOOKING_LIST_TABS = [
|
||||
{
|
||||
key: "payment",
|
||||
label: "Payment",
|
||||
statuses: ["FULLY_EXECUTED", "PAID"],
|
||||
statuses: [
|
||||
"FULLY_EXECUTED",
|
||||
"PNR_GENERATED",
|
||||
"PAYMENT_VERIFICATION_IN_PROGRESS",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "operations",
|
||||
label: "Operations",
|
||||
statuses: ["PAID", "IN_TRANSIT", "PENDING_CONSOLIDATION", "CONSOLIDATED"],
|
||||
},
|
||||
{ key: "operations", label: "Operations", statuses: ["IN_TRANSIT", "PENDING_CONSOLIDATION", "CONSOLIDATED"] },
|
||||
{ key: "completed", label: "Completed", statuses: ["COMPLETED"] },
|
||||
{ key: "closed", label: "Closed", statuses: ["REJECTED", "CANCELLED"] },
|
||||
] as const;
|
||||
@@ -240,11 +248,15 @@ export const WORKFLOW_STAGES = [
|
||||
},
|
||||
{
|
||||
label: "Payment",
|
||||
statuses: ["FULLY_EXECUTED", "PAID"],
|
||||
statuses: [
|
||||
"FULLY_EXECUTED",
|
||||
"PNR_GENERATED",
|
||||
"PAYMENT_VERIFICATION_IN_PROGRESS",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Operations",
|
||||
statuses: ["IN_TRANSIT", "PENDING_CONSOLIDATION", "CONSOLIDATED"],
|
||||
statuses: ["PAID", "IN_TRANSIT", "PENDING_CONSOLIDATION", "CONSOLIDATED"],
|
||||
},
|
||||
{ label: "Done", statuses: ["COMPLETED"] },
|
||||
] as const;
|
||||
|
||||
47
apps/edr-freight-web/backoffice/src/hooks/useLocomotives.ts
Normal file
47
apps/edr-freight-web/backoffice/src/hooks/useLocomotives.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { locomotivesService } from '@/services/locomotives.service';
|
||||
|
||||
export const locomotiveKeys = {
|
||||
all: ['locomotives'] as const,
|
||||
details: () => [...locomotiveKeys.all, 'detail'] as const,
|
||||
detail: (id: string) => [...locomotiveKeys.details(), id] as const,
|
||||
};
|
||||
|
||||
export function useLocomotives() {
|
||||
return useQuery({
|
||||
queryKey: locomotiveKeys.all,
|
||||
queryFn: () => locomotivesService.getAll().then((response) => response.data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateLocomotive() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: locomotivesService.create,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: locomotiveKeys.all }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateLocomotive() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
|
||||
locomotivesService.update(id, data),
|
||||
onSuccess: (_, { id }) => {
|
||||
qc.invalidateQueries({ queryKey: locomotiveKeys.all });
|
||||
qc.invalidateQueries({ queryKey: locomotiveKeys.detail(id) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDecommissionLocomotive() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: locomotivesService.decommission,
|
||||
onSuccess: (_, id) => {
|
||||
qc.invalidateQueries({ queryKey: locomotiveKeys.all });
|
||||
qc.invalidateQueries({ queryKey: locomotiveKeys.detail(id) });
|
||||
},
|
||||
});
|
||||
}
|
||||
55
apps/edr-freight-web/backoffice/src/hooks/useRoutes.ts
Normal file
55
apps/edr-freight-web/backoffice/src/hooks/useRoutes.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { routesService } from '@/services/routes.service';
|
||||
|
||||
export const routeKeys = {
|
||||
all: ['routes'] as const,
|
||||
yards: ['routes', 'yards'] as const,
|
||||
details: () => [...routeKeys.all, 'detail'] as const,
|
||||
detail: (id: string) => [...routeKeys.details(), id] as const,
|
||||
};
|
||||
|
||||
export function useRoutes() {
|
||||
return useQuery({
|
||||
queryKey: routeKeys.all,
|
||||
queryFn: () => routesService.getAll().then((response) => response.data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useRouteYards() {
|
||||
return useQuery({
|
||||
queryKey: routeKeys.yards,
|
||||
queryFn: () => routesService.getYards().then((response) => response.data.data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateRoute() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: routesService.create,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: routeKeys.all }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateRoute() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
|
||||
routesService.update(id, data),
|
||||
onSuccess: (_, { id }) => {
|
||||
qc.invalidateQueries({ queryKey: routeKeys.all });
|
||||
qc.invalidateQueries({ queryKey: routeKeys.detail(id) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeactivateRoute() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: routesService.deactivate,
|
||||
onSuccess: (_, id) => {
|
||||
qc.invalidateQueries({ queryKey: routeKeys.all });
|
||||
qc.invalidateQueries({ queryKey: routeKeys.detail(id) });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -11,8 +11,9 @@ import { Toaster } from "react-hot-toast";
|
||||
|
||||
import App from "./App";
|
||||
import { AuthProvider } from "./auth/AuthProvider";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { queryClient } from "./lib/queryClient";
|
||||
import { freightMantineTheme } from "./theme/freight-brand";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
const THEME_STORAGE_KEY = "edr-theme";
|
||||
|
||||
@@ -43,7 +44,7 @@ if (!rootElement) {
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MantineProvider>
|
||||
<MantineProvider theme={freightMantineTheme}>
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
|
||||
@@ -27,8 +27,15 @@ import {
|
||||
} from '@/hooks/useContainers';
|
||||
import { useCreateTrain, useDeleteTrain, useTrains, useUpdateTrain } from '@/hooks/useTrains';
|
||||
import { useCreateWagon, useDeleteWagon, useUpdateWagon, useWagons } from '@/hooks/useWagons';
|
||||
import {
|
||||
useCreateLocomotive,
|
||||
useDecommissionLocomotive,
|
||||
useLocomotives,
|
||||
useUpdateLocomotive,
|
||||
} from '@/hooks/useLocomotives';
|
||||
import type { Cargo } from '@/services/cargoService';
|
||||
import type { Container } from '@/services/containerService';
|
||||
import type { Locomotive } from '@/services/locomotives.service';
|
||||
import type { Train } from '@/services/trains.service';
|
||||
import type { Wagon } from '@/services/wagon.service';
|
||||
|
||||
@@ -57,6 +64,7 @@ type FleetCrudPageProps<T extends { id: string }> = {
|
||||
title: string;
|
||||
description: string;
|
||||
addLabel: string;
|
||||
entityLabel?: string;
|
||||
data?: T[];
|
||||
isLoading: boolean;
|
||||
columns: Column<T>[];
|
||||
@@ -66,6 +74,10 @@ type FleetCrudPageProps<T extends { id: string }> = {
|
||||
create: { mutateAsync: (data: Record<string, unknown>) => Promise<unknown>; isPending: boolean };
|
||||
update: { mutateAsync: (data: { id: string; data: Record<string, unknown> }) => Promise<unknown>; isPending: boolean };
|
||||
remove: { mutateAsync: (id: string) => Promise<unknown>; isPending: boolean };
|
||||
removeActionLabel?: string;
|
||||
removeConfirmMessage?: string;
|
||||
removeSuccessMessage?: string;
|
||||
hideViewAction?: boolean;
|
||||
};
|
||||
|
||||
const normalizePayload = (values: Record<string, FormValue>) =>
|
||||
@@ -121,6 +133,7 @@ function FleetCrudPage<T extends { id: string }>({
|
||||
title,
|
||||
description,
|
||||
addLabel,
|
||||
entityLabel,
|
||||
data,
|
||||
isLoading,
|
||||
columns,
|
||||
@@ -130,6 +143,10 @@ function FleetCrudPage<T extends { id: string }>({
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
removeActionLabel = 'Delete',
|
||||
removeConfirmMessage,
|
||||
removeSuccessMessage,
|
||||
hideViewAction = false,
|
||||
}: FleetCrudPageProps<T>) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
@@ -228,12 +245,13 @@ function FleetCrudPage<T extends { id: string }>({
|
||||
};
|
||||
|
||||
const handleDelete = async (item: T) => {
|
||||
if (!window.confirm(`Delete this ${title.slice(0, -1).toLowerCase()}?`)) return;
|
||||
const normalizedEntityLabel = entityLabel ?? title.slice(0, -1);
|
||||
if (!window.confirm(removeConfirmMessage ?? `${removeActionLabel} this ${normalizedEntityLabel.toLowerCase()}?`)) return;
|
||||
try {
|
||||
await remove.mutateAsync(item.id);
|
||||
toast({ title: `${title.slice(0, -1)} deleted` });
|
||||
toast({ title: removeSuccessMessage ?? `${normalizedEntityLabel} ${removeActionLabel.toLowerCase()}ed` });
|
||||
} catch {
|
||||
toast({ title: 'Delete failed', description: 'This record may still be referenced.', variant: 'destructive' });
|
||||
toast({ title: `${removeActionLabel} failed`, description: 'This record may still be referenced.', variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -294,13 +312,15 @@ function FleetCrudPage<T extends { id: string }>({
|
||||
))}
|
||||
<TableCell>
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button variant="ghost" size="icon" onClick={() => setViewing(item)} title="View">
|
||||
<Eye className="size-4" />
|
||||
</Button>
|
||||
{!hideViewAction ? (
|
||||
<Button variant="ghost" size="icon" onClick={() => setViewing(item)} title="View">
|
||||
<Eye className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="ghost" size="icon" onClick={() => openEdit(item)} title="Edit">
|
||||
<Edit className="size-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => handleDelete(item)} title="Delete">
|
||||
<Button variant="ghost" size="icon" onClick={() => handleDelete(item)} title={removeActionLabel}>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -631,3 +651,82 @@ export function CargoesCrudPage() {
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function LocomotivesCrudPage() {
|
||||
const query = useLocomotives();
|
||||
|
||||
return (
|
||||
<FleetCrudPage<Locomotive>
|
||||
title="Locomotives"
|
||||
entityLabel="Locomotive"
|
||||
description="Manage locomotive master data used by train scheduling and fleet operations."
|
||||
addLabel="Add Locomotive"
|
||||
data={query.data}
|
||||
isLoading={query.isLoading}
|
||||
create={useCreateLocomotive()}
|
||||
update={useUpdateLocomotive()}
|
||||
remove={useDecommissionLocomotive()}
|
||||
removeActionLabel="Decommission"
|
||||
removeConfirmMessage="Decommission this locomotive?"
|
||||
removeSuccessMessage="Locomotive decommissioned"
|
||||
searchText={(locomotive) =>
|
||||
[
|
||||
locomotive.code,
|
||||
locomotive.name,
|
||||
locomotive.locomotiveType,
|
||||
locomotive.status,
|
||||
].join(' ')
|
||||
}
|
||||
columns={[
|
||||
{ key: 'code', label: 'Code' },
|
||||
{ key: 'name', label: 'Name', render: (locomotive) => locomotive.name || '-' },
|
||||
{ key: 'locomotiveType', label: 'Type' },
|
||||
{ key: 'status', label: 'Status', render: (locomotive) => statusBadge(locomotive.status) },
|
||||
{ key: 'maxPullWeightTons', label: 'Max pull (tons)' },
|
||||
{ key: 'maxTrainLengthMeters', label: 'Max length (m)' },
|
||||
]}
|
||||
fields={[
|
||||
{ key: 'code', label: 'Code', required: true },
|
||||
{ key: 'name', label: 'Name' },
|
||||
{
|
||||
key: 'locomotiveType',
|
||||
label: 'Locomotive type',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: [
|
||||
{ value: 'DIESEL', label: 'Diesel' },
|
||||
{ value: 'ELECTRIC', label: 'Electric' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: [
|
||||
{ value: 'AVAILABLE', label: 'Available' },
|
||||
{ value: 'MAINTENANCE', label: 'Maintenance' },
|
||||
{ value: 'ASSIGNED', label: 'Assigned' },
|
||||
{ value: 'OUT_OF_SERVICE', label: 'Out of service' },
|
||||
],
|
||||
},
|
||||
{ key: 'maxPullWeightTons', label: 'Max pulling weight (tons)', type: 'number', required: true },
|
||||
{ key: 'maxTrainLengthMeters', label: 'Max train length (meters)', type: 'number', required: true },
|
||||
{ key: 'powerKw', label: 'Power (kW)', type: 'number' },
|
||||
{ key: 'tractionForceKn', label: 'Traction force (kN)', type: 'number' },
|
||||
{ key: 'maxSpeedKmh', label: 'Max speed (km/h)', type: 'number' },
|
||||
]}
|
||||
emptyValues={{
|
||||
code: '',
|
||||
name: '',
|
||||
locomotiveType: 'DIESEL',
|
||||
status: 'AVAILABLE',
|
||||
maxPullWeightTons: 0,
|
||||
maxTrainLengthMeters: 760,
|
||||
powerKw: '',
|
||||
tractionForceKn: '',
|
||||
maxSpeedKmh: '',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
379
apps/edr-freight-web/backoffice/src/pages/fleet/RoutesPage.tsx
Normal file
379
apps/edr-freight-web/backoffice/src/pages/fleet/RoutesPage.tsx
Normal file
@@ -0,0 +1,379 @@
|
||||
import { FormEvent, useMemo, useState } from 'react';
|
||||
import { Edit, Eye, Plus, Search, Trash2 } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { useCreateRoute, useDeactivateRoute, useRouteYards, useRoutes, useUpdateRoute } from '@/hooks/useRoutes';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import type { RouteRecord, YardRef } from '@/services/routes.service';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@edr/ui-common';
|
||||
|
||||
type RouteFormState = {
|
||||
name: string;
|
||||
milestones: string[];
|
||||
};
|
||||
|
||||
const emptyForm = (): RouteFormState => ({ name: '', milestones: ['', ''] });
|
||||
|
||||
const yardLabel = (yard?: YardRef | null) => (yard ? `${yard.label} (${yard.code})` : '-');
|
||||
|
||||
const routeStops = (route: RouteRecord) =>
|
||||
(route.milestones ?? [])
|
||||
.sort((left, right) => left.sequenceNo - right.sequenceNo)
|
||||
.map((milestone) => milestone.yard?.label ?? milestone.yard?.code ?? milestone.yardId);
|
||||
|
||||
const normalizeRouteError = (error: unknown) => {
|
||||
const responseData = (error as { response?: { data?: unknown } })?.response?.data;
|
||||
const data = responseData && typeof responseData === 'object' ? (responseData as Record<string, unknown>) : undefined;
|
||||
const rawMessage = data?.message ?? data?.error ?? (error as Error)?.message;
|
||||
|
||||
return Array.isArray(rawMessage)
|
||||
? rawMessage.join(', ')
|
||||
: rawMessage
|
||||
? String(rawMessage)
|
||||
: 'Save failed';
|
||||
};
|
||||
|
||||
export default function RoutesPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [viewing, setViewing] = useState<RouteRecord | null>(null);
|
||||
const [editing, setEditing] = useState<RouteRecord | null>(null);
|
||||
const [form, setForm] = useState<RouteFormState>(emptyForm());
|
||||
const { toast } = useToast();
|
||||
|
||||
const routesQuery = useRoutes();
|
||||
const yardsQuery = useRouteYards();
|
||||
const createMutation = useCreateRoute();
|
||||
const updateMutation = useUpdateRoute();
|
||||
const deactivateMutation = useDeactivateRoute();
|
||||
|
||||
const filteredRoutes = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
if (!query) return routesQuery.data ?? [];
|
||||
|
||||
return (routesQuery.data ?? []).filter((route) => {
|
||||
const searchable = [
|
||||
route.name,
|
||||
route.originYard?.label,
|
||||
route.originYard?.code,
|
||||
route.destinationYard?.label,
|
||||
route.destinationYard?.code,
|
||||
...routeStops(route),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
|
||||
return searchable.includes(query);
|
||||
});
|
||||
}, [routesQuery.data, search]);
|
||||
|
||||
const yardOptions = useMemo(
|
||||
() =>
|
||||
(yardsQuery.data ?? []).map((yard) => ({
|
||||
value: yard.id,
|
||||
label: `${yard.label} (${yard.code})`,
|
||||
})),
|
||||
[yardsQuery.data],
|
||||
);
|
||||
|
||||
const resetForm = () => {
|
||||
setFormOpen(false);
|
||||
setEditing(null);
|
||||
setForm(emptyForm());
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setForm(emptyForm());
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (route: RouteRecord) => {
|
||||
setEditing(route);
|
||||
setForm({
|
||||
name: route.name,
|
||||
milestones: (route.milestones ?? [])
|
||||
.sort((left, right) => left.sequenceNo - right.sequenceNo)
|
||||
.map((milestone) => milestone.yardId),
|
||||
});
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const setMilestone = (index: number, yardId: string) => {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
milestones: current.milestones.map((value, currentIndex) =>
|
||||
currentIndex === index ? yardId : value,
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const addMilestone = () => {
|
||||
setForm((current) => ({ ...current, milestones: [...current.milestones, ''] }));
|
||||
};
|
||||
|
||||
const removeMilestone = (index: number) => {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
milestones: current.milestones.filter((_, currentIndex) => currentIndex !== index),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!form.name.trim()) {
|
||||
toast({ title: 'Save failed', description: 'Route name is required', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (form.milestones.length < 2 || form.milestones.some((yardId) => !yardId)) {
|
||||
toast({
|
||||
title: 'Save failed',
|
||||
description: 'Select at least an origin and destination yard',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
milestones: form.milestones.map((yardId) => ({ yardId })),
|
||||
isActive: editing?.isActive ?? true,
|
||||
};
|
||||
|
||||
if (editing) {
|
||||
await updateMutation.mutateAsync({ id: editing.id, data: payload });
|
||||
toast({ title: 'Route updated' });
|
||||
} else {
|
||||
await createMutation.mutateAsync(payload);
|
||||
toast({ title: 'Route created' });
|
||||
}
|
||||
|
||||
resetForm();
|
||||
} catch (error) {
|
||||
toast({ title: 'Save failed', description: normalizeRouteError(error), variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeactivate = async (route: RouteRecord) => {
|
||||
if (!window.confirm('Deactivate this route?')) return;
|
||||
|
||||
try {
|
||||
await deactivateMutation.mutateAsync(route.id);
|
||||
toast({ title: 'Route deactivated' });
|
||||
} catch {
|
||||
toast({ title: 'Deactivate failed', description: 'Could not deactivate route', variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
const isSaving = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
const availableOptionsForIndex = (index: number) => {
|
||||
const selectedByOthers = new Set(
|
||||
form.milestones.filter((value, currentIndex) => currentIndex !== index && value),
|
||||
);
|
||||
|
||||
return yardOptions.filter(
|
||||
(option) => option.value === form.milestones[index] || !selectedByOthers.has(option.value),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5 p-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Routes</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Build train routes from an ordered yard list where the first stop is the origin and the last stop is the destination.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={openCreate}>
|
||||
<Plus className="size-4" />
|
||||
Add Route
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex max-w-md items-center gap-2 rounded-md border bg-background px-3">
|
||||
<Search className="size-4 text-muted-foreground" />
|
||||
<Input
|
||||
className="border-0 px-0 shadow-none focus-visible:ring-0"
|
||||
placeholder="Search routes"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Origin</TableHead>
|
||||
<TableHead>Destination</TableHead>
|
||||
<TableHead>Milestones</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="w-[150px] text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredRoutes.map((route) => (
|
||||
<TableRow key={route.id}>
|
||||
<TableCell>{route.name}</TableCell>
|
||||
<TableCell>{yardLabel(route.originYard)}</TableCell>
|
||||
<TableCell>{yardLabel(route.destinationYard)}</TableCell>
|
||||
<TableCell>{Math.max((route.milestones?.length ?? 0) - 2, 0)}</TableCell>
|
||||
<TableCell>{route.isActive ? 'Active' : 'Inactive'}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button variant="ghost" size="icon" onClick={() => setViewing(route)} title="View">
|
||||
<Eye className="size-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => openEdit(route)} title="Edit">
|
||||
<Edit className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDeactivate(route)}
|
||||
title="Deactivate"
|
||||
disabled={!route.isActive || deactivateMutation.isPending}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{!routesQuery.isLoading && filteredRoutes.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="h-28 text-center text-muted-foreground">
|
||||
No routes found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
{routesQuery.isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="h-28 text-center text-muted-foreground">
|
||||
Loading...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<Dialog open={formOpen} onOpenChange={(open) => (!open ? resetForm() : setFormOpen(true))}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editing ? 'Edit Route' : 'Add Route'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="route-name">Name</Label>
|
||||
<Input
|
||||
id="route-name"
|
||||
value={form.name}
|
||||
onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Stops</Label>
|
||||
<Button type="button" variant="outline" size="sm" onClick={addMilestone}>
|
||||
<Plus className="size-4" />
|
||||
Add next milestone
|
||||
</Button>
|
||||
</div>
|
||||
{form.milestones.map((yardId, index) => {
|
||||
const role = index === 0 ? 'Origin' : index === form.milestones.length - 1 ? 'Destination' : 'Milestone';
|
||||
const availableOptions = availableOptionsForIndex(index);
|
||||
return (
|
||||
<div key={`${role}-${index}`} className="grid gap-2 rounded-lg border p-3 sm:grid-cols-[120px,1fr,auto] sm:items-center">
|
||||
<p className="text-sm font-medium">{role}</p>
|
||||
<Select value={yardId} onValueChange={(value) => setMilestone(index, value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select yard" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeMilestone(index)}
|
||||
disabled={form.milestones.length <= 2}
|
||||
title="Remove stop"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={resetForm}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(viewing)} onOpenChange={(open) => (!open ? setViewing(null) : null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Route details</DialogTitle>
|
||||
</DialogHeader>
|
||||
{viewing ? (
|
||||
<div className="space-y-3 text-sm">
|
||||
<div>
|
||||
<p className="font-medium">Name</p>
|
||||
<p className="text-muted-foreground">{viewing.name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Status</p>
|
||||
<p className="text-muted-foreground">{viewing.isActive ? 'Active' : 'Inactive'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Stops</p>
|
||||
<div className="mt-2 space-y-2">
|
||||
{routeStops(viewing).map((stop, index, stops) => (
|
||||
<div key={`${stop}-${index}`} className="rounded-md border px-3 py-2 text-muted-foreground">
|
||||
{index === 0 ? 'Origin' : index === stops.length - 1 ? 'Destination' : `Milestone ${index}`}:
|
||||
{' '}
|
||||
{stop}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -191,14 +191,19 @@ const RuleEngineResourcePage = () => {
|
||||
|
||||
base.push({
|
||||
id: "actions",
|
||||
header: "Details",
|
||||
size: 120,
|
||||
meta: { headerClassName, cellClassName },
|
||||
header: "Actions",
|
||||
size: 140,
|
||||
minSize: 120,
|
||||
meta: {
|
||||
headerClassName,
|
||||
cellClassName: `${cellClassName} whitespace-nowrap`,
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<div onClick={(e) => e.stopPropagation()} data-stop-row-click>
|
||||
<RuleEngineRecordActions
|
||||
record={row.original}
|
||||
config={config}
|
||||
layout="row"
|
||||
readOnly={!canManage}
|
||||
onEdit={(record) => {
|
||||
setEditing(record);
|
||||
|
||||
@@ -180,6 +180,46 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
{ name: "displayOrder", label: "Display order", type: "number" },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "wagon-types",
|
||||
label: "Wagon Types",
|
||||
category: "configuration",
|
||||
subtitle: "Configure wagon classes used for capacity and train planning",
|
||||
searchPlaceholder: "Search wagon types by name or code...",
|
||||
cardTitleKey: "name",
|
||||
columns: [
|
||||
codeColumn("code"),
|
||||
{ id: "name", header: "Name", accessorKey: "name" },
|
||||
{ id: "capacityTons", header: "Capacity (t)", accessorKey: "capacityTons", format: "number" },
|
||||
{ id: "lengthMeters", header: "Length (m)", accessorKey: "lengthMeters", format: "number" },
|
||||
{ id: "maxWagonsPerTrain", header: "Max / train", accessorKey: "maxWagonsPerTrain", format: "number" },
|
||||
{
|
||||
id: "supportedLoadTypes",
|
||||
header: "Load types",
|
||||
accessorKey: "supportedLoadTypes",
|
||||
},
|
||||
activeColumn,
|
||||
],
|
||||
formFields: [
|
||||
{ name: "name", label: "Name", type: "text", required: true },
|
||||
{ name: "capacityTons", label: "Capacity (tons)", type: "number", required: true },
|
||||
{ name: "lengthMeters", label: "Length (meters)", type: "number", required: true },
|
||||
{
|
||||
name: "maxWagonsPerTrain",
|
||||
label: "Max wagons per train",
|
||||
type: "number",
|
||||
optional: true,
|
||||
},
|
||||
{
|
||||
name: "supportedLoadTypes",
|
||||
label: "Supported load types",
|
||||
type: "textarea",
|
||||
optional: true,
|
||||
placeholder: "CONTAINER, BULK",
|
||||
},
|
||||
{ name: "isActive", label: "Active", type: "boolean" },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "priority-rules",
|
||||
label: "Priority Rules",
|
||||
|
||||
@@ -19,13 +19,8 @@ import {
|
||||
|
||||
import Breadcrumbs from '@/components/ui/Breadcrumbs';
|
||||
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
|
||||
import { useRoutes } from '@/hooks/useRoutes';
|
||||
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';
|
||||
@@ -43,11 +38,6 @@ const formatDate = (value?: string | null) => {
|
||||
}).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;
|
||||
@@ -59,62 +49,39 @@ const parseError = (error: unknown, fallback: string) => {
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const deriveFromBooking = (
|
||||
booking: EligibleContainerBooking | undefined,
|
||||
stations: YardOption[],
|
||||
) => {
|
||||
if (!booking) {
|
||||
return { originStationId: '', destinationStationId: '', scheduleDate: '' };
|
||||
}
|
||||
|
||||
const originStationId = stations.find((station) => station.name === booking.origin)?.id ?? '';
|
||||
const destinationStationId =
|
||||
stations.find((station) => station.name === booking.destination)?.id ?? '';
|
||||
|
||||
return {
|
||||
originStationId,
|
||||
destinationStationId,
|
||||
scheduleDate: formatDayInput(booking.preferredDepartureDate),
|
||||
};
|
||||
};
|
||||
|
||||
const TrainsPage = () => {
|
||||
const qc = useQueryClient();
|
||||
const [filters, setFilters] = useState<TrainScheduleFilters>({});
|
||||
const [selectedBookingIds, setSelectedBookingIds] = useState<string[]>([]);
|
||||
const [preview, setPreview] = useState<TrainSchedulePreviewResponse | null>(null);
|
||||
const [routeId, setRouteId] = useState('');
|
||||
const [scheduleDate, setScheduleDate] = useState('');
|
||||
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 routesQuery = useRoutes();
|
||||
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 activeRoutes = useMemo(
|
||||
() => (routesQuery.data ?? []).filter((route) => route.isActive),
|
||||
[routesQuery.data],
|
||||
);
|
||||
const selectedRoute = activeRoutes.find((route) => route.id === routeId) ?? null;
|
||||
const selectedLocomotive = (locomotivesQuery.data ?? []).find(
|
||||
(locomotive) => locomotive.id === selectedLocomotiveId,
|
||||
);
|
||||
|
||||
const filteredSchedules = useMemo(() => {
|
||||
const query = scheduleSearch.trim().toLowerCase();
|
||||
|
||||
@@ -132,6 +99,7 @@ const TrainsPage = () => {
|
||||
|
||||
const haystack = [
|
||||
schedule.id,
|
||||
schedule.routeName ?? '',
|
||||
schedule.origin ?? '',
|
||||
schedule.destination ?? '',
|
||||
schedule.locomotive?.code ?? '',
|
||||
@@ -143,70 +111,24 @@ const TrainsPage = () => {
|
||||
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');
|
||||
if (!routeId || !scheduleDate || !selectedLocomotiveId) {
|
||||
throw new Error('Please select route, departure date, and locomotive');
|
||||
}
|
||||
|
||||
return trainSchedulingService.createSchedule({
|
||||
bookingIds: selectedBookingIds,
|
||||
scheduleDate: new Date(`${filters.scheduleDate}T08:00:00.000Z`).toISOString(),
|
||||
originStationId: filters.originStationId,
|
||||
destinationStationId: filters.destinationStationId,
|
||||
routeId,
|
||||
scheduleDate: new Date(`${scheduleDate}T08:00:00.000Z`).toISOString(),
|
||||
locomotiveId: selectedLocomotiveId,
|
||||
});
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
toast.success('Train schedule created');
|
||||
setSelectedBookingIds([]);
|
||||
setRouteId('');
|
||||
setScheduleDate('');
|
||||
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() });
|
||||
@@ -232,32 +154,12 @@ const TrainsPage = () => {
|
||||
},
|
||||
});
|
||||
|
||||
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;
|
||||
const isBusy = createMutation.isPending;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<Breadcrumbs items={[{ label: 'Operations' }, { label: 'Train scheduling' }]} />
|
||||
<Breadcrumbs items={[{ label: 'Operations' }, { label: 'Train schedules' }]} />
|
||||
|
||||
<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">
|
||||
@@ -266,9 +168,9 @@ const TrainsPage = () => {
|
||||
<TrainTrack className="size-7" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Train Scheduling</h1>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Train Schedules</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Build container train schedules from compatible bookings, preview wagon plans, and assign locomotives.
|
||||
Create the train schedule first, reserve the locomotive, and assign bookings and wagons later.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -276,7 +178,7 @@ const TrainsPage = () => {
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
onClick={() => {
|
||||
void eligibleQuery.refetch();
|
||||
void routesQuery.refetch();
|
||||
void schedulesQuery.refetch();
|
||||
void locomotivesQuery.refetch();
|
||||
}}
|
||||
@@ -286,368 +188,185 @@ const TrainsPage = () => {
|
||||
</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>
|
||||
<Select
|
||||
value={filters.status ?? '__all__'}
|
||||
onValueChange={(value) =>
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
status: value === '__all__' ? undefined : value,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="All statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">All eligible statuses</SelectItem>
|
||||
<SelectItem value="PAID">Paid</SelectItem>
|
||||
<SelectItem value="FULLY_EXECUTED">Fully executed</SelectItem>
|
||||
<SelectItem value="APPROVED">Approved</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</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">
|
||||
<div className="grid gap-6 p-6 xl:grid-cols-[1.1fr,1.4fr]">
|
||||
<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-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>
|
||||
|
||||
<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}>
|
||||
<div className="grid gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Route</label>
|
||||
<Select value={routeId} onValueChange={setRouteId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="All statuses" />
|
||||
<SelectValue placeholder="Select active route" />
|
||||
</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>
|
||||
{activeRoutes.map((route) => (
|
||||
<SelectItem key={route.id} value={route.id}>
|
||||
{route.name}
|
||||
</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 className="space-y-2">
|
||||
<label className="text-sm font-medium">Departure date</label>
|
||||
<input
|
||||
className={inputClassName}
|
||||
type="date"
|
||||
value={scheduleDate}
|
||||
onChange={(event) => setScheduleDate(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<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 / {locomotive.maxTrainLengthMeters}m
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-3 sm:grid-cols-2">
|
||||
<div className="rounded-xl border border-border bg-card p-4">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Origin</p>
|
||||
<p className="mt-2 text-sm font-medium">
|
||||
{selectedRoute?.originYard?.label ?? selectedRoute?.originYard?.code ?? '-'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-card p-4">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Destination</p>
|
||||
<p className="mt-2 text-sm font-medium">
|
||||
{selectedRoute?.destinationYard?.label ?? selectedRoute?.destinationYard?.code ?? '-'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-card p-4">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Locomotive capacity</p>
|
||||
<p className="mt-2 text-sm font-medium">
|
||||
{selectedLocomotive
|
||||
? `${selectedLocomotive.maxPullWeightTons}T / ${selectedLocomotive.maxTrainLengthMeters}m`
|
||||
: '-'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-card p-4">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Next step</p>
|
||||
<p className="mt-2 text-sm font-medium">Assign bookings, then allocate wagons</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<Button className="w-full" disabled={isBusy} onClick={() => createMutation.mutate()}>
|
||||
Create schedule
|
||||
</Button>
|
||||
</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">Created schedules</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Open a schedule to inspect the reserved locomotive and prepare for later booking and wagon work.
|
||||
</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.routeName ?? `${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>
|
||||
</section>
|
||||
|
||||
@@ -656,13 +375,13 @@ const TrainsPage = () => {
|
||||
<DialogHeader>
|
||||
<DialogTitle>Train schedule detail</DialogTitle>
|
||||
<DialogDescription>
|
||||
Inspect the selected schedule, locomotive, wagons, and booking allocations.
|
||||
Inspect the selected schedule. Booking assignment and wagon allocation happen after schedule creation.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{detail ? (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
|
||||
<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>
|
||||
@@ -673,6 +392,10 @@ const TrainsPage = () => {
|
||||
</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.route?.name ?? '-'}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border p-4">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Origin / destination</p>
|
||||
<p className="mt-2 text-sm font-medium">
|
||||
{detail.originStation?.label ?? detail.originStation?.code ?? '-'} to{' '}
|
||||
{detail.destinationStation?.label ?? detail.destinationStation?.code ?? '-'}
|
||||
@@ -688,73 +411,63 @@ const TrainsPage = () => {
|
||||
<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)`
|
||||
? `${detail.trainSet.locomotive.code} (${detail.trainSet.locomotive.maxPullWeightTons}T pull capacity / ${detail.trainSet.locomotive.maxTrainLengthMeters ?? 0}m)`
|
||||
: '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>
|
||||
{(detail.trainSet?.wagons?.length ?? 0) === 0 ? (
|
||||
<p className="mt-3 text-sm text-muted-foreground">No wagons allocated yet.</p>
|
||||
) : (
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
{detail.bookings.length === 0 ? (
|
||||
<p className="mt-3 text-sm text-muted-foreground">No bookings assigned yet.</p>
|
||||
) : (
|
||||
<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>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</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>
|
||||
) : (
|
||||
@@ -765,43 +478,5 @@ const TrainsPage = () => {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrainsPage;
|
||||
|
||||
// export default function TrainsPage() {
|
||||
// const { data: trains, isLoading } = useTrains();
|
||||
// const deleteTrain = useDeleteTrain();
|
||||
// const [open, setOpen] = useState(false);
|
||||
|
||||
// if (isLoading) return <div className="p-8">Loading trains...</div>;
|
||||
|
||||
// return (
|
||||
// <Card>
|
||||
// <CardHeader className="flex flex-row items-center justify-between">
|
||||
// <CardTitle>Trains</CardTitle>
|
||||
// <Dialog open={open} onOpenChange={setOpen}>
|
||||
// <DialogTrigger asChild><Button size="sm"><Plus className="mr-2 h-4 w-4" />New Train</Button></DialogTrigger>
|
||||
// <DialogContent><DialogHeader><DialogTitle>Create Train</DialogTitle></DialogHeader><CreateTrainForm onSuccess={() => setOpen(false)} /></DialogContent>
|
||||
// </Dialog>
|
||||
// </CardHeader>
|
||||
// <CardContent>
|
||||
// <Table>
|
||||
// <TableHeader><TableRow><TableHead>Number</TableHead><TableHead>Name</TableHead><TableHead>Status</TableHead><TableHead>Capacity</TableHead><TableHead>Actions</TableHead></TableRow></TableHeader>
|
||||
// <TableBody>
|
||||
// {trains?.map(train => (
|
||||
// <TableRow key={train.id}>
|
||||
// <TableCell>{train.trainNumber || train.code}</TableCell>
|
||||
// <TableCell>{train.trainName || '-'}</TableCell>
|
||||
// <TableCell><Badge variant="outline">{train.status}</Badge></TableCell>
|
||||
// <TableCell>{train.capacityTons} t</TableCell>
|
||||
// <TableCell className="flex space-x-2">
|
||||
// <Link to={`/trains/${train.id}`}><Button variant="ghost" size="icon"><Eye className="h-4 w-4" /></Button></Link>
|
||||
// <Button variant="ghost" size="icon" onClick={() => deleteTrain.mutate(train.id)}><Trash2 className="h-4 w-4" /></Button>
|
||||
// </TableCell>
|
||||
// </TableRow>
|
||||
// ))}
|
||||
// </TableBody>
|
||||
// </Table>
|
||||
// </CardContent>
|
||||
// </Card>
|
||||
// );
|
||||
// }
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { api as apiClient } from '../auth/http';
|
||||
|
||||
import { URL_CONSTANTS } from '@/constants/URLS';
|
||||
|
||||
export type LocomotiveType = 'DIESEL' | 'ELECTRIC';
|
||||
export type LocomotiveStatus =
|
||||
| 'AVAILABLE'
|
||||
| 'MAINTENANCE'
|
||||
| 'ASSIGNED'
|
||||
| 'OUT_OF_SERVICE';
|
||||
|
||||
export interface Locomotive {
|
||||
id: string;
|
||||
code: string;
|
||||
name?: string | null;
|
||||
locomotiveType: LocomotiveType;
|
||||
status: LocomotiveStatus;
|
||||
maxPullWeightTons: number;
|
||||
maxTrainLengthMeters: number;
|
||||
powerKw?: number | null;
|
||||
tractionForceKn?: number | null;
|
||||
maxSpeedKmh?: number | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type SaveLocomotivePayload = Omit<
|
||||
Locomotive,
|
||||
'id' | 'createdAt' | 'updatedAt'
|
||||
>;
|
||||
|
||||
export const locomotivesService = {
|
||||
getAll: () => apiClient.get<Locomotive[]>(URL_CONSTANTS.LOCOMOTIVES.BASE),
|
||||
getById: (id: string) => apiClient.get<Locomotive>(URL_CONSTANTS.LOCOMOTIVES.BY_ID(id)),
|
||||
create: (data: Partial<SaveLocomotivePayload>) =>
|
||||
apiClient.post(URL_CONSTANTS.LOCOMOTIVES.BASE, data),
|
||||
update: (id: string, data: Partial<SaveLocomotivePayload>) =>
|
||||
apiClient.patch(URL_CONSTANTS.LOCOMOTIVES.BY_ID(id), data),
|
||||
decommission: (id: string) => apiClient.post(URL_CONSTANTS.LOCOMOTIVES.DECOMMISSION(id), {}),
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import { api as apiClient } from '../auth/http';
|
||||
|
||||
import { URL_CONSTANTS } from '@/constants/URLS';
|
||||
|
||||
export interface YardRef {
|
||||
id: string;
|
||||
code: string;
|
||||
label: string;
|
||||
country?: string;
|
||||
}
|
||||
|
||||
export interface RouteMilestone {
|
||||
id: string;
|
||||
routeId: string;
|
||||
yardId: string;
|
||||
sequenceNo: number;
|
||||
yard?: YardRef | null;
|
||||
}
|
||||
|
||||
export interface RouteRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
originYardId: string;
|
||||
destinationYardId: string;
|
||||
isActive: boolean;
|
||||
originYard?: YardRef | null;
|
||||
destinationYard?: YardRef | null;
|
||||
milestones?: RouteMilestone[];
|
||||
}
|
||||
|
||||
export interface SaveRoutePayload {
|
||||
name: string;
|
||||
milestones: Array<{ yardId: string }>;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
interface YardListResponse {
|
||||
data: YardRef[];
|
||||
}
|
||||
|
||||
export const routesService = {
|
||||
getAll: () => apiClient.get<RouteRecord[]>(URL_CONSTANTS.ROUTES.BASE),
|
||||
getById: (id: string) => apiClient.get<RouteRecord>(URL_CONSTANTS.ROUTES.BY_ID(id)),
|
||||
create: (data: SaveRoutePayload) => apiClient.post(URL_CONSTANTS.ROUTES.BASE, data),
|
||||
update: (id: string, data: Partial<SaveRoutePayload>) =>
|
||||
apiClient.patch(URL_CONSTANTS.ROUTES.BY_ID(id), data),
|
||||
deactivate: (id: string) => apiClient.delete(URL_CONSTANTS.ROUTES.BY_ID(id)),
|
||||
getYards: () =>
|
||||
apiClient.get<YardListResponse>(URL_CONSTANTS.RULE_ENGINE.YARDS, {
|
||||
params: { isActive: true, pageSize: 200 },
|
||||
}),
|
||||
};
|
||||
@@ -19,6 +19,7 @@ export interface RuleEngineListParams {
|
||||
const RESOURCE_BASE: Record<RuleEngineResourceSlug, string> = {
|
||||
"cargo-types": URL_CONSTANTS.RULE_ENGINE.CARGO_TYPES,
|
||||
"container-types": URL_CONSTANTS.RULE_ENGINE.CONTAINER_TYPES,
|
||||
"wagon-types": URL_CONSTANTS.RULE_ENGINE.WAGON_TYPES,
|
||||
"priority-rules": URL_CONSTANTS.RULE_ENGINE.PRIORITY_RULES,
|
||||
"service-types": URL_CONSTANTS.RULE_ENGINE.SERVICE_TYPES,
|
||||
"surcharge-types": URL_CONSTANTS.RULE_ENGINE.SURCHARGE_TYPES,
|
||||
@@ -35,6 +36,8 @@ const byIdPath = (resource: RuleEngineResourceSlug, id: string): string => {
|
||||
return URL_CONSTANTS.RULE_ENGINE.CARGO_TYPE_BY_ID(id);
|
||||
case "container-types":
|
||||
return URL_CONSTANTS.RULE_ENGINE.CONTAINER_TYPE_BY_ID(id);
|
||||
case "wagon-types":
|
||||
return URL_CONSTANTS.RULE_ENGINE.WAGON_TYPE_BY_ID(id);
|
||||
case "priority-rules":
|
||||
return URL_CONSTANTS.RULE_ENGINE.PRIORITY_RULE_BY_ID(id);
|
||||
case "service-types":
|
||||
|
||||
@@ -7,7 +7,9 @@ const asList = <T>(payload: ListResponse<T>): T[] =>
|
||||
|
||||
export const wagonTypesService = {
|
||||
async getWagonTypes() {
|
||||
const response = await api.get<ListResponse<unknown>>('/wagon-types');
|
||||
const response = await api.get<ListResponse<unknown>>('/wagon-types', {
|
||||
params: { isActive: true, pageSize: 500 },
|
||||
});
|
||||
return asList(response.data);
|
||||
},
|
||||
};
|
||||
|
||||
40
apps/edr-freight-web/backoffice/src/theme/freight-brand.ts
Normal file
40
apps/edr-freight-web/backoffice/src/theme/freight-brand.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { createTheme, type MantineColorsTuple } from "@mantine/core";
|
||||
|
||||
/** EDR Freight primary brand green */
|
||||
export const FREIGHT_BRAND = "#15803d";
|
||||
export const FREIGHT_BRAND_DARK = "#166534";
|
||||
export const FREIGHT_BRAND_LIGHT = "#22c55e";
|
||||
|
||||
export const freightBrand = {
|
||||
primary: FREIGHT_BRAND,
|
||||
primaryDark: FREIGHT_BRAND_DARK,
|
||||
primaryLight: FREIGHT_BRAND_LIGHT,
|
||||
gradient: `linear-gradient(135deg, ${FREIGHT_BRAND} 0%, ${FREIGHT_BRAND_DARK} 100%)`,
|
||||
shadow: "0 4px 12px rgba(21, 128, 61, 0.28)",
|
||||
shadowSm: "0 2px 6px rgba(21, 128, 61, 0.22)",
|
||||
ring: "rgba(21, 128, 61, 0.2)",
|
||||
mutedBg: "#f0fdf4",
|
||||
mutedBorder: "#bbf7d0",
|
||||
} as const;
|
||||
|
||||
/** Mantine green scale with #15803d at index 6 (filled buttons, nav active). */
|
||||
const freightGreen: MantineColorsTuple = [
|
||||
"#f0fdf4",
|
||||
"#dcfce7",
|
||||
"#bbf7d0",
|
||||
"#86efac",
|
||||
"#4ade80",
|
||||
"#22c55e",
|
||||
FREIGHT_BRAND,
|
||||
FREIGHT_BRAND_DARK,
|
||||
"#14532d",
|
||||
"#052e16",
|
||||
];
|
||||
|
||||
export const freightMantineTheme = createTheme({
|
||||
primaryColor: "green",
|
||||
colors: {
|
||||
green: freightGreen,
|
||||
},
|
||||
fontFamily: "'Outfit', var(--font-sans)",
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
export type RuleEngineResourceSlug =
|
||||
| "cargo-types"
|
||||
| "container-types"
|
||||
| "wagon-types"
|
||||
| "priority-rules"
|
||||
| "service-types"
|
||||
| "surcharge-types"
|
||||
|
||||
@@ -56,13 +56,15 @@ export interface LocomotiveRecord {
|
||||
code: string;
|
||||
name?: string | null;
|
||||
maxPullWeightTons: number;
|
||||
status: 'AVAILABLE' | 'ASSIGNED' | 'MAINTENANCE' | 'INACTIVE';
|
||||
availableFrom?: string | null;
|
||||
maxTrainLengthMeters: number;
|
||||
status: 'AVAILABLE' | 'ASSIGNED' | 'MAINTENANCE' | 'OUT_OF_SERVICE';
|
||||
locomotiveType?: 'DIESEL' | 'ELECTRIC';
|
||||
}
|
||||
|
||||
export interface TrainScheduleListItem {
|
||||
id: string;
|
||||
scheduleDate: string;
|
||||
routeName?: string | null;
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
locomotive:
|
||||
@@ -82,6 +84,10 @@ export interface TrainScheduleListItem {
|
||||
export interface TrainScheduleDetail {
|
||||
id: string;
|
||||
status: string;
|
||||
route?: {
|
||||
id: string;
|
||||
name: string;
|
||||
} | null;
|
||||
scheduledDepartureDate: string;
|
||||
scheduledArrivalDate?: string | null;
|
||||
originStation?: {
|
||||
@@ -100,13 +106,14 @@ export interface TrainScheduleDetail {
|
||||
wagonCount: number;
|
||||
totalWeightTons: number;
|
||||
totalLengthMeters: number;
|
||||
locomotive?: {
|
||||
id: string;
|
||||
code: string;
|
||||
name?: string | null;
|
||||
status: string;
|
||||
maxPullWeightTons: number;
|
||||
} | null;
|
||||
locomotive?: {
|
||||
id: string;
|
||||
code: string;
|
||||
name?: string | null;
|
||||
status: string;
|
||||
maxPullWeightTons: number;
|
||||
maxTrainLengthMeters?: number;
|
||||
} | null;
|
||||
wagons: Array<{
|
||||
id: string;
|
||||
sequenceNo: number;
|
||||
@@ -139,7 +146,6 @@ export interface TrainScheduleFilters {
|
||||
originStationId?: string;
|
||||
destinationStationId?: string;
|
||||
scheduleDate?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface TrainSchedulePreviewPayload {
|
||||
@@ -149,6 +155,8 @@ export interface TrainSchedulePreviewPayload {
|
||||
destinationStationId: string;
|
||||
}
|
||||
|
||||
export interface CreateTrainSchedulePayload extends TrainSchedulePreviewPayload {
|
||||
export interface CreateTrainSchedulePayload {
|
||||
routeId: string;
|
||||
scheduleDate: string;
|
||||
locomotiveId: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user