This commit is contained in:
marshal
2026-06-06 10:08:55 +03:00
105 changed files with 6873 additions and 357 deletions

0
WagonForm.tsx Normal file
View File

View File

@@ -24,8 +24,8 @@
"@nestjs/platform-express": "^11.0.0", "@nestjs/platform-express": "^11.0.0",
"@nestjs/swagger": "^11.4.2", "@nestjs/swagger": "^11.4.2",
"@nestjs/typeorm": "^11.0.1", "@nestjs/typeorm": "^11.0.1",
"@tria-plc/api-common": "^0.1.4", "@tria-plc/api-common": "^1.4.0",
"@tria-plc/iamapi-common": "^0.1.6", "@tria-plc/iamapi-common": "^0.5.1",
"amqp-connection-manager": "^5.0.0", "amqp-connection-manager": "^5.0.0",
"amqplib": "^2.0.1", "amqplib": "^2.0.1",
"axios": "^1.16.1", "axios": "^1.16.1",
@@ -34,8 +34,8 @@
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"handlebars": "^4.7.9", "handlebars": "^4.7.9",
"minio": "7.1.3", "minio": "7.1.3",
"puppeteer": "^24.2.0",
"pg": "^8.13.0", "pg": "^8.13.0",
"puppeteer": "^24.2.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1" "rxjs": "^7.8.1"
}, },
@@ -52,6 +52,7 @@
"@types/jest": "^29.5.13", "@types/jest": "^29.5.13",
"@types/multer": "^2.1.0", "@types/multer": "^2.1.0",
"@types/node": "^20.14.0", "@types/node": "^20.14.0",
"@types/pg": "^8.6.7",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"jest": "^29.7.0", "jest": "^29.7.0",
"supertest": "^7.0.0", "supertest": "^7.0.0",

View File

@@ -75,7 +75,6 @@ import { CargoesModule } from './modules/cargoes/cargoes.module';
BookingsModule, BookingsModule,
FilesModule, FilesModule,
ConsignmentsModule, ConsignmentsModule,
TrainsModule,
LocomotivesModule, LocomotivesModule,
WagonTypesModule, WagonTypesModule,
TrainSetsModule, TrainSetsModule,

View File

@@ -0,0 +1,21 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddCompanyContactColumns1750000000000 implements MigrationInterface {
name = 'AddCompanyContactColumns1750000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE freight.companies ADD COLUMN IF NOT EXISTS contact_person_name VARCHAR(100);`);
await queryRunner.query(`ALTER TABLE freight.companies ADD COLUMN IF NOT EXISTS contact_person_phone VARCHAR(20);`);
await queryRunner.query(`ALTER TABLE freight.companies ADD COLUMN IF NOT EXISTS general_manager_name VARCHAR(100);`);
await queryRunner.query(`ALTER TABLE freight.companies ADD COLUMN IF NOT EXISTS general_manager_email VARCHAR(150);`);
await queryRunner.query(`ALTER TABLE freight.companies ADD COLUMN IF NOT EXISTS general_manager_phone VARCHAR(20);`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE freight.companies DROP COLUMN IF EXISTS general_manager_phone;`);
await queryRunner.query(`ALTER TABLE freight.companies DROP COLUMN IF EXISTS general_manager_email;`);
await queryRunner.query(`ALTER TABLE freight.companies DROP COLUMN IF EXISTS general_manager_name;`);
await queryRunner.query(`ALTER TABLE freight.companies DROP COLUMN IF EXISTS contact_person_phone;`);
await queryRunner.query(`ALTER TABLE freight.companies DROP COLUMN IF EXISTS contact_person_name;`);
}
}

View File

@@ -0,0 +1,127 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateFleetCrudTables1750100000000 implements MigrationInterface {
name = 'CreateFleetCrudTables1750100000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE SCHEMA IF NOT EXISTS freight`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.wagons (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
wagon_number VARCHAR NOT NULL UNIQUE,
wagon_type_id UUID NOT NULL,
train_id UUID,
sequence_number INT,
tare_weight NUMERIC(10, 2) NOT NULL,
max_payload_weight NUMERIC(10, 2) NOT NULL,
status VARCHAR NOT NULL DEFAULT 'AVAILABLE',
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.containers (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
container_number VARCHAR NOT NULL UNIQUE,
container_type_id UUID NOT NULL,
wagon_id UUID,
position INT,
tare_weight NUMERIC(10, 2) NOT NULL,
max_gross_weight NUMERIC(10, 2) NOT NULL,
seal_number VARCHAR,
status VARCHAR NOT NULL DEFAULT 'AVAILABLE',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.cargoes (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
cargo_reference VARCHAR NOT NULL UNIQUE,
shipment_id UUID NOT NULL,
container_id UUID NOT NULL,
cargo_type_id UUID,
description TEXT,
quantity NUMERIC(12, 3) NOT NULL,
weight NUMERIC(10, 2) NOT NULL,
volume NUMERIC(10, 2),
status VARCHAR NOT NULL DEFAULT 'PENDING',
loaded_at TIMESTAMP,
unloaded_at TIMESTAMP,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_wagons_train_id" ON freight.wagons (train_id)`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_containers_wagon_id" ON freight.containers (wagon_id)`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_cargoes_container_id" ON freight.cargoes (container_id)`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.wagons
ADD CONSTRAINT "FK_wagons_train_id"
FOREIGN KEY (train_id) REFERENCES freight.trains(id) ON DELETE SET NULL;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.wagons
ADD CONSTRAINT "FK_wagons_wagon_type_id"
FOREIGN KEY (wagon_type_id) REFERENCES freight.wagon_types(id);
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.containers
ADD CONSTRAINT "FK_containers_wagon_id"
FOREIGN KEY (wagon_id) REFERENCES freight.wagons(id) ON DELETE SET NULL;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.containers
ADD CONSTRAINT "FK_containers_container_type_id"
FOREIGN KEY (container_type_id) REFERENCES freight.container_types(id);
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.cargoes
ADD CONSTRAINT "FK_cargoes_container_id"
FOREIGN KEY (container_id) REFERENCES freight.containers(id) ON DELETE RESTRICT;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.cargoes
ADD CONSTRAINT "FK_cargoes_cargo_type_id"
FOREIGN KEY (cargo_type_id) REFERENCES freight.cargo_types(id);
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.cargoes CASCADE`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.containers CASCADE`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.wagons CASCADE`);
}
}

View File

@@ -0,0 +1,46 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class SeedDefaultWagonTypes1750200000000 implements MigrationInterface {
name = 'SeedDefaultWagonTypes1750200000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
INSERT INTO freight.wagon_types (
code,
name,
capacity_tons,
length_meters,
max_wagons_per_train,
supported_load_types,
is_active
)
VALUES
('NW7', 'Double deck sedan wagon', 22, 26.066, NULL, ARRAY['vehicles', 'sedan'], true),
('NW5', 'Flat wagon (container)', 70, 14.000, 53, ARRAY['container', 'steel', 'machinery'], true),
('PW2', 'Box wagon', 70, 17.066, 18, ARRAY['general cargo', 'break bulk'], true),
('GW2', 'Tank wagon', 70, 12.228, 37, ARRAY['liquid', 'fuel'], true),
('CW4', 'Gondola covered wagon', 70, 13.976, 37, ARRAY['covered bulk cargo'], true),
('CW3', 'Gondola open wagon', 70, 13.976, NULL, ARRAY['open bulk cargo'], true),
('KW2', 'Hopper covered wagon', 69, 16.466, NULL, ARRAY['bulk grains'], true),
('KW3', 'Hopper open wagon', 70, 14.400, NULL, ARRAY['coal', 'bulk cargo'], true),
('NW6', 'Flat wagon (long cargo)', 70, 18.560, NULL, ARRAY['long cargo'], true),
('BW1', 'Refrigerated wagon', 38, 21.996, NULL, ARRAY['refrigerated cargo'], true)
ON CONFLICT (code) DO UPDATE SET
name = EXCLUDED.name,
capacity_tons = EXCLUDED.capacity_tons,
length_meters = EXCLUDED.length_meters,
max_wagons_per_train = EXCLUDED.max_wagons_per_train,
supported_load_types = EXCLUDED.supported_load_types,
is_active = true,
deleted_at = NULL,
updated_at = now();
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DELETE FROM freight.wagon_types
WHERE code IN ('NW7', 'NW5', 'PW2', 'GW2', 'CW4', 'CW3', 'KW2', 'KW3', 'NW6', 'BW1');
`);
}
}

View File

@@ -7,6 +7,7 @@ import {
ParseUUIDPipe, ParseUUIDPipe,
Patch, Patch,
Post, Post,
Query,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateCargoDto } from './dto/create-cargo.dto'; import { CreateCargoDto } from './dto/create-cargo.dto';
@@ -28,8 +29,8 @@ export class CargoesController {
@Get() @Get()
@ApiOperation({ summary: 'List all cargoes' }) @ApiOperation({ summary: 'List all cargoes' })
findAll() { findAll(@Query() query: Record<string, string | undefined>) {
return this.cargoesService.findAll(); return this.cargoesService.findAll(query);
} }
@Get(':id') @Get(':id')
@@ -67,4 +68,4 @@ export class CargoesController {
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto?: DeliverCargoDto) { deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto?: DeliverCargoDto) {
return this.cargoesService.deliverCargo(id, dto); return this.cargoesService.deliverCargo(id, dto);
} }
} }

View File

@@ -2,13 +2,14 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { Cargo } from './entities/cargoes.entity'; import { Cargo } from './entities/cargoes.entity';
import { Container } from '../container-management/entities/container.entity'; import { Container } from '../container-management/entities/container.entity';
import { CargoType } from '../rule-engine/entities/cargo-type.entity';
import { CargoesController } from './cargoes.controller'; import { CargoesController } from './cargoes.controller';
import { CargoesService } from './cargoes.service'; import { CargoesService } from './cargoes.service';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([Cargo, Container])], imports: [TypeOrmModule.forFeature([Cargo, Container, CargoType])],
controllers: [CargoesController], controllers: [CargoesController],
providers: [CargoesService], providers: [CargoesService],
exports: [CargoesService], exports: [CargoesService],
}) })
export class CargoesModule {} export class CargoesModule {}

View File

@@ -1,12 +1,13 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { FindOptionsOrder, FindOptionsWhere, ILike, Repository } from 'typeorm';
import { CreateCargoDto } from './dto/create-cargo.dto'; import { CreateCargoDto } from './dto/create-cargo.dto';
import { UpdateCargoDto } from './dto/update-cargo.dto'; import { UpdateCargoDto } from './dto/update-cargo.dto';
import { LoadCargoDto } from './dto/load-cargo.dto'; import { LoadCargoDto } from './dto/load-cargo.dto';
import { DeliverCargoDto } from './dto/deliver-cargo.dto'; import { DeliverCargoDto } from './dto/deliver-cargo.dto';
import { Cargo } from './entities/cargoes.entity'; import { Cargo } from './entities/cargoes.entity';
import { Container } from '../container-management/entities/container.entity'; import { Container } from '../container-management/entities/container.entity';
import { CargoType } from '../rule-engine/entities/cargo-type.entity';
@Injectable() @Injectable()
export class CargoesService { export class CargoesService {
@@ -15,15 +16,64 @@ export class CargoesService {
private readonly cargoRepo: Repository<Cargo>, private readonly cargoRepo: Repository<Cargo>,
@InjectRepository(Container) @InjectRepository(Container)
private readonly containerRepo: Repository<Container>, private readonly containerRepo: Repository<Container>,
@InjectRepository(CargoType)
private readonly cargoTypeRepo: Repository<CargoType>,
) {} ) {}
async create(dto: CreateCargoDto): Promise<Cargo> { async create(dto: CreateCargoDto): Promise<Cargo> {
const existing = await this.cargoRepo.findOne({
where: { cargoReference: dto.cargoReference },
});
if (existing) {
throw new ConflictException(`Cargo reference "${dto.cargoReference}" already exists`);
}
const container = await this.containerRepo.findOne({ where: { id: dto.containerId } });
if (!container) {
throw new NotFoundException(`Container ${dto.containerId} not found`);
}
if (dto.cargoTypeId) {
const cargoType = await this.cargoTypeRepo.findOne({
where: { id: dto.cargoTypeId, isActive: true },
});
if (!cargoType) throw new NotFoundException(`Cargo type ${dto.cargoTypeId} not found`);
}
const cargo = this.cargoRepo.create(dto); const cargo = this.cargoRepo.create(dto);
return this.cargoRepo.save(cargo); return this.cargoRepo.save(cargo);
} }
async findAll(): Promise<Cargo[]> { async findAll(query: Record<string, string | undefined> = {}): Promise<Cargo[]> {
return this.cargoRepo.find({ order: { cargoReference: 'ASC' } }); const where: FindOptionsWhere<Cargo>[] | FindOptionsWhere<Cargo> = [];
const search = query.search?.trim();
const status = query.status?.trim();
const containerId = query.containerId?.trim();
if (search) {
where.push({
cargoReference: ILike(`%${search}%`),
...(status ? { status } : {}),
...(containerId ? { containerId } : {}),
});
where.push({
description: ILike(`%${search}%`),
...(status ? { status } : {}),
...(containerId ? { containerId } : {}),
});
}
const sortBy = ['cargoReference', 'quantity', 'weight', 'volume', 'status'].includes(query.sortBy ?? '')
? (query.sortBy as keyof Cargo)
: 'cargoReference';
const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
return this.cargoRepo.find({
where: search ? where : { ...(status ? { status } : {}), ...(containerId ? { containerId } : {}) },
order: { [sortBy]: sortOrder } as FindOptionsOrder<Cargo>,
skip: query.page && query.limit ? (Number(query.page) - 1) * Number(query.limit) : undefined,
take: query.limit ? Number(query.limit) : undefined,
});
} }
async findById(id: string): Promise<Cargo> { async findById(id: string): Promise<Cargo> {
@@ -34,6 +84,24 @@ export class CargoesService {
async update(id: string, dto: UpdateCargoDto): Promise<Cargo> { async update(id: string, dto: UpdateCargoDto): Promise<Cargo> {
const cargo = await this.findById(id); const cargo = await this.findById(id);
if (dto.cargoReference && dto.cargoReference !== cargo.cargoReference) {
const existing = await this.cargoRepo.findOne({
where: { cargoReference: dto.cargoReference },
});
if (existing) {
throw new ConflictException(`Cargo reference "${dto.cargoReference}" already exists`);
}
}
if (dto.containerId) {
const container = await this.containerRepo.findOne({ where: { id: dto.containerId } });
if (!container) throw new NotFoundException(`Container ${dto.containerId} not found`);
}
if (dto.cargoTypeId) {
const cargoType = await this.cargoTypeRepo.findOne({
where: { id: dto.cargoTypeId, isActive: true },
});
if (!cargoType) throw new NotFoundException(`Cargo type ${dto.cargoTypeId} not found`);
}
Object.assign(cargo, dto); Object.assign(cargo, dto);
return this.cargoRepo.save(cargo); return this.cargoRepo.save(cargo);
} }
@@ -101,4 +169,4 @@ export class CargoesService {
return this.cargoRepo.save(cargo); return this.cargoRepo.save(cargo);
} }
} }

View File

@@ -53,6 +53,21 @@ export class Company extends BaseEntity {
@Column({ name: 'email', type: 'varchar', length: 150, nullable: true }) @Column({ name: 'email', type: 'varchar', length: 150, nullable: true })
email?: string | null; email?: string | null;
@Column({ name: 'contact_person_name', type: 'varchar', length: 100, nullable: true })
contactPersonName?: string | null;
@Column({ name: 'contact_person_phone', type: 'varchar', length: 20, nullable: true })
contactPersonPhone?: string | null;
@Column({ name: 'general_manager_name', type: 'varchar', length: 100, nullable: true })
generalManagerName?: string | null;
@Column({ name: 'general_manager_email', type: 'varchar', length: 150, nullable: true })
generalManagerEmail?: string | null;
@Column({ name: 'general_manager_phone', type: 'varchar', length: 20, nullable: true })
generalManagerPhone?: string | null;
@Column({ name: 'website', type: 'varchar', length: 200, nullable: true }) @Column({ name: 'website', type: 'varchar', length: 200, nullable: true })
website?: string | null; website?: string | null;

View File

@@ -7,6 +7,7 @@ import {
ParseUUIDPipe, ParseUUIDPipe,
Patch, Patch,
Post, Post,
Query,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateContainerDto } from './dto/create-container.dto'; import { CreateContainerDto } from './dto/create-container.dto';
@@ -27,8 +28,8 @@ export class ContainersController {
@Get() @Get()
@ApiOperation({ summary: 'List all containers' }) @ApiOperation({ summary: 'List all containers' })
findAll() { findAll(@Query() query: Record<string, string | undefined>) {
return this.containersService.findAll(); return this.containersService.findAll(query);
} }
@Get(':id') @Get(':id')
@@ -60,4 +61,4 @@ export class ContainersController {
unassignFromWagon(@Param('id', ParseUUIDPipe) id: string) { unassignFromWagon(@Param('id', ParseUUIDPipe) id: string) {
return this.containersService.unassignFromWagon(id); return this.containersService.unassignFromWagon(id);
} }
} }

View File

@@ -3,12 +3,13 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { Container } from './entities/container.entity'; import { Container } from './entities/container.entity';
import { Wagon } from '../wagons/entities/wagon.entity'; import { Wagon } from '../wagons/entities/wagon.entity';
import { ContainerType } from '../rule-engine/entities/container-type.entity';
import { ContainersController } from './containers.controller'; import { ContainersController } from './containers.controller';
import { ContainersService } from './containers.service'; import { ContainersService } from './containers.service';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([Container, Wagon])], // ✅ add Wagon imports: [TypeOrmModule.forFeature([Container, Wagon, ContainerType])],
controllers: [ContainersController], controllers: [ContainersController],
providers: [ContainersService], providers: [ContainersService],
}) })
export class ContainersModule {} export class ContainersModule {}

View File

@@ -1,12 +1,13 @@
// apps/edr-freight-api/src/modules/container-management/containers.service.ts // apps/edr-freight-api/src/modules/container-management/containers.service.ts
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { FindOptionsOrder, FindOptionsWhere, ILike, Repository } from 'typeorm';
import { CreateContainerDto } from './dto/create-container.dto'; import { CreateContainerDto } from './dto/create-container.dto';
import { UpdateContainerDto } from './dto/update-container.dto'; import { UpdateContainerDto } from './dto/update-container.dto';
import { AssignContainerToWagonDto } from './dto/assign-container-to-wagon.dto'; import { AssignContainerToWagonDto } from './dto/assign-container-to-wagon.dto';
import { Container } from './entities/container.entity'; import { Container } from './entities/container.entity';
import { Wagon } from '../wagons/entities/wagon.entity'; import { Wagon } from '../wagons/entities/wagon.entity';
import { ContainerType } from '../rule-engine/entities/container-type.entity';
@Injectable() @Injectable()
export class ContainersService { export class ContainersService {
@@ -15,17 +16,61 @@ export class ContainersService {
private readonly containerRepo: Repository<Container>, private readonly containerRepo: Repository<Container>,
@InjectRepository(Wagon) @InjectRepository(Wagon)
private readonly wagonRepo: Repository<Wagon>, // ✅ use raw repository private readonly wagonRepo: Repository<Wagon>, // ✅ use raw repository
@InjectRepository(ContainerType)
private readonly containerTypeRepo: Repository<ContainerType>,
) {} ) {}
async create(dto: CreateContainerDto): Promise<Container> { async create(dto: CreateContainerDto): Promise<Container> {
const existing = await this.containerRepo.findOne({
where: { containerNumber: dto.containerNumber },
});
if (existing) {
throw new ConflictException(`Container number "${dto.containerNumber}" already exists`);
}
const containerType = await this.containerTypeRepo.findOne({
where: { id: dto.containerTypeId, isActive: true },
});
if (!containerType) {
throw new NotFoundException(`Container type ${dto.containerTypeId} not found`);
}
if (dto.wagonId) {
const wagon = await this.wagonRepo.findOne({ where: { id: dto.wagonId } });
if (!wagon) throw new NotFoundException(`Wagon ${dto.wagonId} not found`);
}
const container = this.containerRepo.create(dto); const container = this.containerRepo.create(dto);
if (dto.wagonId === undefined) container.wagonId = null; if (dto.wagonId === undefined) container.wagonId = null;
if (dto.position === undefined) container.position = null; if (dto.position === undefined) container.position = null;
return this.containerRepo.save(container); return this.containerRepo.save(container);
} }
async findAll(): Promise<Container[]> { async findAll(query: Record<string, string | undefined> = {}): Promise<Container[]> {
return this.containerRepo.find({ order: { containerNumber: 'ASC' } }); const where: FindOptionsWhere<Container>[] | FindOptionsWhere<Container> = [];
const search = query.search?.trim();
const status = query.status?.trim();
const wagonId = query.wagonId?.trim();
if (search) {
where.push({
containerNumber: ILike(`%${search}%`),
...(status ? { status } : {}),
...(wagonId ? { wagonId } : {}),
});
}
const sortBy = ['containerNumber', 'tareWeight', 'maxGrossWeight', 'status', 'position'].includes(query.sortBy ?? '')
? (query.sortBy as keyof Container)
: 'containerNumber';
const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
return this.containerRepo.find({
where: search ? where : { ...(status ? { status } : {}), ...(wagonId ? { wagonId } : {}) },
order: { [sortBy]: sortOrder } as FindOptionsOrder<Container>,
skip: query.page && query.limit ? (Number(query.page) - 1) * Number(query.limit) : undefined,
take: query.limit ? Number(query.limit) : undefined,
});
} }
async findById(id: string): Promise<Container> { async findById(id: string): Promise<Container> {
@@ -36,9 +81,27 @@ export class ContainersService {
async update(id: string, dto: UpdateContainerDto): Promise<Container> { async update(id: string, dto: UpdateContainerDto): Promise<Container> {
const container = await this.findById(id); const container = await this.findById(id);
if (dto.containerNumber && dto.containerNumber !== container.containerNumber) {
const existing = await this.containerRepo.findOne({
where: { containerNumber: dto.containerNumber },
});
if (existing) {
throw new ConflictException(`Container number "${dto.containerNumber}" already exists`);
}
}
if (dto.containerTypeId) {
const containerType = await this.containerTypeRepo.findOne({
where: { id: dto.containerTypeId, isActive: true },
});
if (!containerType) {
throw new NotFoundException(`Container type ${dto.containerTypeId} not found`);
}
}
if (dto.wagonId) {
const wagon = await this.wagonRepo.findOne({ where: { id: dto.wagonId } });
if (!wagon) throw new NotFoundException(`Wagon ${dto.wagonId} not found`);
}
Object.assign(container, dto); Object.assign(container, dto);
if (dto.wagonId === undefined) container.wagonId = null;
if (dto.position === undefined) container.position = null;
return this.containerRepo.save(container); return this.containerRepo.save(container);
} }
@@ -82,4 +145,4 @@ export class ContainersService {
container.status = 'AVAILABLE'; container.status = 'AVAILABLE';
return this.containerRepo.save(container); return this.containerRepo.save(container);
} }
} }

View File

@@ -61,10 +61,10 @@ export class CustomersController {
return this.customersService.findById(id); return this.customersService.findById(id);
} }
@Get("user/:userId") // @Get("user/:userId")
findByUserId(@Param("userId", ParseUUIDPipe) userId: string): Promise<any> { // findByUserId(@Param("userId", ParseUUIDPipe) userId: string): Promise<any> {
return this.customersService.findByUserId(userId); // return this.customersService.findByUserId(userId);
} // }
@Patch(":id") @Patch(":id")
@ApiOperation({ summary: "Update a customer" }) @ApiOperation({ summary: "Update a customer" })

View File

@@ -52,15 +52,15 @@ export class CustomersService {
return customer; return customer;
} }
async findByUserId(userId: string): Promise<Customer> { // async findByUserId(userId: string): Promise<Customer> {
const customer = await this.customersRepository.findByUserId(userId); // const customer = await this.customersRepository.findByUserId(userId);
if (!customer) { // if (!customer) {
throw new NotFoundException(`Customer with ID ${userId} not found`); // throw new NotFoundException(`Customer with ID ${userId} not found`);
} // }
return customer; // return customer;
} //}
/** Get customer by email */ /** Get customer by email */
async findByEmail(email: string): Promise<Customer> { async findByEmail(email: string): Promise<Customer> {
@@ -100,16 +100,16 @@ export class CustomersService {
throw new BadRequestException("VAT number must be exactly 10 digits"); throw new BadRequestException("VAT number must be exactly 10 digits");
} }
// Check email conflict // // Check email conflict
if (dto.email) { // if (dto.email) {
const existing = await this.customersRepository.findByEmail(dto.email); // const existing = await this.customersRepository.findByEmail(dto.email);
if (existing && existing.userId !== id) { // // if (existing && existing.userId !== id) {
throw new ConflictException( // // throw new ConflictException(
`Customer with email "${dto.email}" already exists`, // // `Customer with email "${dto.email}" already exists`,
); // // );
} // // }
} // }
const updated = await this.customersRepository.update(id, dto); const updated = await this.customersRepository.update(id, dto);

View File

@@ -2,7 +2,7 @@
import { Customer } from '../entities/customer.entity'; import { Customer } from '../entities/customer.entity';
export class ResponseCustomerDto { export class ResponseCustomerDto {
UserId: string; //UserId: string;
firstName: string; firstName: string;
lastName: string; lastName: string;
email: string; email: string;
@@ -30,7 +30,7 @@ export class ResponseCustomerDto {
updatedAt: Date; updatedAt: Date;
constructor(customer: Customer) { constructor(customer: Customer) {
this.UserId = customer.userId; //this.UserId = customer.userId;
this.firstName = customer.firstName; this.firstName = customer.firstName;
this.lastName = customer.lastName; this.lastName = customer.lastName;
this.email = customer.email; this.email = customer.email;

View File

@@ -3,12 +3,12 @@ import { Column, Entity, Index } from 'typeorm';
@Entity({ schema: 'freight', name: 'customers' }) @Entity({ schema: 'freight', name: 'customers' })
@Index(['email']) @Index(['email'])
@Index(['userId']) //@Index(['userId'])
@Index(['tinNumber']) @Index(['tinNumber'])
@Index(['fanNumber']) @Index(['fanNumber'])
export class Customer extends BaseEntity { export class Customer extends BaseEntity {
@Column({ name: 'user_id', type: 'uuid' }) //@Column({ name: 'user_id', type: 'uuid' })
userId!: string; //userId!: string;
@Column({ name: 'first_name', type: 'varchar', length: 100 }) @Column({ name: 'first_name', type: 'varchar', length: 100 })
firstName!: string; firstName!: string;

View File

@@ -0,0 +1,6 @@
import { IsString } from "class-validator";
export class InitiateBookingPayment {
@IsString()
bookingId!: string;
}

View File

@@ -4,11 +4,6 @@ import { Public } from "@edr/api-common";
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import { Response } from "express" import { Response } from "express"
// import * as fs from 'fs';
// import * as path from 'path';
// import Handlebars from 'handlebars';
@Public() @Public()
@Controller("payments") @Controller("payments")
export class PaymentController { export class PaymentController {
@@ -21,13 +16,12 @@ export class PaymentController {
return res.send(filled) return res.send(filled)
} }
@Post("/initiate") @Post("/initiate/booking")
async initiatePayment() { async initiatePayment() {
//Only for testing.. //Only for testing..
const redirectBaseURL = "http://localhost:3004/payment"
const description = "booking" const description = "booking"
const data = await this.paymentService.pay(redirectBaseURL, 20, "ETB", "telebirr", description, (_) => { const data = await this.paymentService.pay(20, "ETB", "telebirr", description, "booking", (_) => {
return new Promise((resp, _) => { return new Promise((resp, _) => {
resp({ resp({
id: randomUUID(), id: randomUUID(),
@@ -64,26 +58,4 @@ export class PaymentController {
`); `);
} }
}
// @Get("test")
// handleTest(@Res() res: Response) {
// const filePath = path.join(__dirname, "templates", "payment.hbs");
// console.log(filePath)
// console.log(__dirname)
// if (fs.existsSync(filePath)) {
// const source = fs.readFileSync(filePath, "utf8");
// const template = Handlebars.compile(source);
// const html = template({
// url: "https://example.com"
// });
// res.send(html)
// }
// }
}

View File

@@ -28,7 +28,7 @@ export class PaymentService {
]) ])
} }
async pay(redirectBaseURL: string, amount: number, currency: CurrencyType, method: PaymentMethod, reason: string, cb: (qr: QueryRunner) => Promise<{ id: string, type: PaymentEntity["type"] }>, payform: PaymentPlatform = "web"): Promise<{ async pay(amount: number, currency: CurrencyType, method: PaymentMethod, reason: string, type: PaymentEntity["type"], cb: (qr: QueryRunner) => Promise<{ id: string, type: PaymentEntity["type"] }>, payform: PaymentPlatform = "web"): Promise<{
refId: string, refId: string,
clientAction: ClientAction, clientAction: ClientAction,
status: PaymentEntity["status"], status: PaymentEntity["status"],
@@ -43,9 +43,16 @@ export class PaymentService {
throw new NotFoundException("strategy not found") throw new NotFoundException("strategy not found")
} }
const orderId = `freigh${Date.now()}${crypto.randomBytes(4).toString('hex')}` //todo: make it dynamic const orderId = `${Date.now()}${crypto.randomBytes(4).toString('hex')}` //todo: make it dynamic
let redirectUrl: string;
switch (type) {
case "booking":
redirectUrl = `http://localhost:3001/api/payments/receipts/${orderId}/html`
break;
}
const paymentResp = await strategy.pay({ const paymentResp = await strategy.pay({
redirectBaseURL, redirectUrl,
amountMinor: amount, amountMinor: amount,
currency: currency, currency: currency,
merchantOrderId: orderId, merchantOrderId: orderId,
@@ -104,7 +111,7 @@ export class PaymentService {
throw new BadRequestException() throw new BadRequestException()
} }
const filePath = path.join(__dirname, "templates", "ceiepts.hbs"); const filePath = path.join(__dirname, "templates", "receipt.hbs");
if (!fs.existsSync(filePath)) { if (!fs.existsSync(filePath)) {
throw new InternalServerErrorException() throw new InternalServerErrorException()
} }

View File

@@ -22,8 +22,7 @@ export class PaymentTelebirrStrategy implements PaymentStrategy {
async pay(data: ProviderInitiationInput): Promise<any> { async pay(data: ProviderInitiationInput): Promise<any> {
// const refId = randomUUID() // const refId = randomUUID()
// const orderId = createMerchantOrderId() // const orderId = createMerchantOrderId()
const redirectURL = `${data.redirectBaseURL}/${data.merchantOrderId}` const resp = await this.initiate(data)
const resp = await this.initiate(redirectURL, data)
return resp; return resp;
} }
@@ -45,9 +44,9 @@ export class PaymentTelebirrStrategy implements PaymentStrategy {
}); });
} }
async initiate(redirectURL: string, input: ProviderInitiationInput): Promise<ProviderInitiationResult> { async initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult> {
const fabricToken = await this.applyFabricToken(); const fabricToken = await this.applyFabricToken();
const requestBody = this.buildCreateOrderRequest(redirectURL, input); const requestBody = this.buildCreateOrderRequest(input);
const response = await this.requestCreateOrder(fabricToken, requestBody); const response = await this.requestCreateOrder(fabricToken, requestBody);
const prepayId = response.biz_content?.prepay_id; const prepayId = response.biz_content?.prepay_id;
@@ -177,7 +176,7 @@ export class PaymentTelebirrStrategy implements PaymentStrategy {
); );
} }
private buildCreateOrderRequest(redirectURL: string, input: ProviderInitiationInput): CreateOrderRequest { private buildCreateOrderRequest(input: ProviderInitiationInput): CreateOrderRequest {
// const totalAmount = String(input.amountMinor / 100); // const totalAmount = String(input.amountMinor / 100);
const totalAmount = String(input.amountMinor) const totalAmount = String(input.amountMinor)
const req = { const req = {
@@ -188,7 +187,7 @@ export class PaymentTelebirrStrategy implements PaymentStrategy {
biz_content: { biz_content: {
notify_url: this.notifyUrl, notify_url: this.notifyUrl,
appid: this.merchantAppId, appid: this.merchantAppId,
redirect_url: redirectURL, redirect_url: input.redirectUrl,
merch_code: this.merchantCode, merch_code: this.merchantCode,
merch_order_id: input.merchantOrderId, merch_order_id: input.merchantOrderId,
trade_type: 'Checkout' as const, trade_type: 'Checkout' as const,

View File

@@ -10,7 +10,7 @@ export type ClientAction =
| { type: 'LAUNCH_APP'; prepayId: string; receiveCode?: string; shortCode: string }; | { type: 'LAUNCH_APP'; prepayId: string; receiveCode?: string; shortCode: string };
export interface ProviderInitiationInput { export interface ProviderInitiationInput {
redirectBaseURL: string; redirectUrl: string;
merchantOrderId: string; merchantOrderId: string;
// bookingRef: string; // bookingRef: string;
amountMinor: number; amountMinor: number;

View File

@@ -1,5 +1,7 @@
import { ApiPropertyOptional } from '@nestjs/swagger'; import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsDateString, IsOptional, IsUUID } from 'class-validator'; import { IsDateString, IsIn, IsOptional, IsUUID } from 'class-validator';
import { BOOKING_STATUSES } from '../../bookings/entities/booking.entity';
export class GetEligibleContainerBookingsDto { export class GetEligibleContainerBookingsDto {
@ApiPropertyOptional({ format: 'uuid' }) @ApiPropertyOptional({ format: 'uuid' })
@@ -19,5 +21,6 @@ export class GetEligibleContainerBookingsDto {
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptional()
@IsIn(BOOKING_STATUSES)
status?: string; status?: string;
} }

View File

@@ -1,14 +1,18 @@
import { import {
Body, Body,
Controller, Controller,
Delete,
Get, Get,
Param, Param,
ParseUUIDPipe, ParseUUIDPipe,
Patch,
Post, Post,
Query,
} from "@nestjs/common"; } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger"; import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { CreateTrainDto } from "./dto/create-train.dto"; import { CreateTrainDto } from "./dto/create-train.dto";
import { UpdateTrainDto } from "./dto/update-train.dto";
import { TrainsService } from "./trains.service"; import { TrainsService } from "./trains.service";
@ApiTags("trains") @ApiTags("trains")
@@ -24,8 +28,8 @@ export class TrainsController {
@Get() @Get()
@ApiOperation({ summary: "List all trains" }) @ApiOperation({ summary: "List all trains" })
findAll() { findAll(@Query() query: Record<string, string | undefined>) {
return this.trainsService.findAll(); return this.trainsService.findAll(query);
} }
@Get(":id") @Get(":id")
@@ -33,4 +37,16 @@ export class TrainsController {
findOne(@Param("id", ParseUUIDPipe) id: string) { findOne(@Param("id", ParseUUIDPipe) id: string) {
return this.trainsService.findById(id); return this.trainsService.findById(id);
} }
@Patch(":id")
@ApiOperation({ summary: "Update a train" })
update(@Param("id", ParseUUIDPipe) id: string, @Body() dto: UpdateTrainDto) {
return this.trainsService.update(id, dto);
}
@Delete(":id")
@ApiOperation({ summary: "Delete a train" })
remove(@Param("id", ParseUUIDPipe) id: string) {
return this.trainsService.remove(id);
}
} }

View File

@@ -1,6 +1,6 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { FindOptionsOrder, FindOptionsWhere, ILike, Repository } from 'typeorm';
import { CreateTrainDto } from './dto/create-train.dto'; import { CreateTrainDto } from './dto/create-train.dto';
import { UpdateTrainDto } from './dto/update-train.dto'; import { UpdateTrainDto } from './dto/update-train.dto';
import { Train } from './entities/train.entity'; import { Train } from './entities/train.entity';
@@ -17,8 +17,28 @@ export class TrainsService {
return this.trainRepo.save(train); return this.trainRepo.save(train);
} }
findAll(): Promise<Train[]> { findAll(query: Record<string, string | undefined> = {}): Promise<Train[]> {
return this.trainRepo.find({ order: { code: 'ASC' } }); const where: FindOptionsWhere<Train>[] | FindOptionsWhere<Train> = [];
const search = query.search?.trim();
const status = query.status?.trim();
if (search) {
where.push({ code: ILike(`%${search}%`), ...(status ? { status: status as Train['status'] } : {}) });
where.push({ trainNumber: ILike(`%${search}%`), ...(status ? { status: status as Train['status'] } : {}) });
where.push({ trainName: ILike(`%${search}%`), ...(status ? { status: status as Train['status'] } : {}) });
}
const sortBy = ['code', 'trainNumber', 'trainName', 'capacityTons', 'status'].includes(query.sortBy ?? '')
? (query.sortBy as keyof Train)
: 'code';
const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
return this.trainRepo.find({
where: search ? where : status ? { status: status as Train['status'] } : {},
order: { [sortBy]: sortOrder } as FindOptionsOrder<Train>,
skip: query.page && query.limit ? (Number(query.page) - 1) * Number(query.limit) : undefined,
take: query.limit ? Number(query.limit) : undefined,
});
} }
async findById(id: string): Promise<Train> { async findById(id: string): Promise<Train> {
@@ -38,4 +58,4 @@ export class TrainsService {
const train = await this.findById(id); const train = await this.findById(id);
await this.trainRepo.remove(train); await this.trainRepo.remove(train);
} }
} }

View File

@@ -0,0 +1,16 @@
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';
@ApiTags('Wagon Types')
@Controller('wagon-types')
export class WagonTypesController {
constructor(private readonly wagonTypesService: WagonTypesService) {}
@Get()
@ApiOperation({ summary: 'Get all active wagon types' })
async findAll(): Promise<WagonType[]> {
return this.wagonTypesService.findAll();
}
}

View File

@@ -2,11 +2,13 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { WagonType } from './entities/wagon-type.entity'; import { WagonType } from './entities/wagon-type.entity';
import { WagonTypesController } from './wagon-types.controller';
import { WagonTypesRepository } from './wagon-types.repository'; import { WagonTypesRepository } from './wagon-types.repository';
import { WagonTypesService } from './wagon-types.service'; import { WagonTypesService } from './wagon-types.service';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([WagonType])], imports: [TypeOrmModule.forFeature([WagonType])],
controllers: [WagonTypesController],
providers: [WagonTypesRepository, WagonTypesService], providers: [WagonTypesRepository, WagonTypesService],
exports: [WagonTypesRepository, WagonTypesService], exports: [WagonTypesRepository, WagonTypesService],
}) })

View File

@@ -7,6 +7,13 @@ import { WagonTypesRepository } from './wagon-types.repository';
export class WagonTypesService { export class WagonTypesService {
constructor(private readonly wagonTypesRepository: WagonTypesRepository) {} constructor(private readonly wagonTypesRepository: WagonTypesRepository) {}
async findAll(): Promise<WagonType[]> {
return this.wagonTypesRepository.findAll({
where: { isActive: true },
order: { code: 'ASC' },
});
}
async findByCode(code: string): Promise<WagonType> { async findByCode(code: string): Promise<WagonType> {
const [wagonType] = await this.wagonTypesRepository.findAll({ where: { code } }); const [wagonType] = await this.wagonTypesRepository.findAll({ where: { code } });

View File

@@ -7,6 +7,7 @@ import {
ParseUUIDPipe, ParseUUIDPipe,
Patch, Patch,
Post, Post,
Query,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateWagonDto } from './dto/create-wagon.dto'; import { CreateWagonDto } from './dto/create-wagon.dto';
@@ -28,8 +29,8 @@ export class WagonsController {
@Get() @Get()
@ApiOperation({ summary: 'List all wagons' }) @ApiOperation({ summary: 'List all wagons' })
findAll() { findAll(@Query() query: Record<string, string | undefined>) {
return this.wagonsService.findAll(); return this.wagonsService.findAll(query);
} }
@Get(':id') @Get(':id')
@@ -73,4 +74,4 @@ export class TrainWagonsReorderController {
reorder(@Param('trainId', ParseUUIDPipe) trainId: string, @Body() dto: ReorderWagonsDto) { reorder(@Param('trainId', ParseUUIDPipe) trainId: string, @Body() dto: ReorderWagonsDto) {
return this.wagonsService.reorderWagons(trainId, dto); return this.wagonsService.reorderWagons(trainId, dto);
} }
} }

View File

@@ -1,6 +1,6 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm'; import { Repository, DataSource, FindOptionsOrder, FindOptionsWhere, ILike } from 'typeorm';
import { CreateWagonDto } from './dto/create-wagon.dto'; import { CreateWagonDto } from './dto/create-wagon.dto';
import { UpdateWagonDto } from './dto/update-wagon.dto'; import { UpdateWagonDto } from './dto/update-wagon.dto';
import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.dto'; import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.dto';
@@ -26,8 +26,31 @@ export class WagonsService {
return this.wagonRepo.save(wagon); return this.wagonRepo.save(wagon);
} }
async findAll(): Promise<Wagon[]> { async findAll(query: Record<string, string | undefined> = {}): Promise<Wagon[]> {
return this.wagonRepo.find({ order: { wagonNumber: 'ASC' } }); const where: FindOptionsWhere<Wagon>[] | FindOptionsWhere<Wagon> = [];
const search = query.search?.trim();
const status = query.status?.trim();
const trainId = query.trainId?.trim();
if (search) {
where.push({
wagonNumber: ILike(`%${search}%`),
...(status ? { status } : {}),
...(trainId ? { trainId } : {}),
});
}
const sortBy = ['wagonNumber', 'tareWeight', 'maxPayloadWeight', 'status', 'sequenceNumber'].includes(query.sortBy ?? '')
? (query.sortBy as keyof Wagon)
: 'wagonNumber';
const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
return this.wagonRepo.find({
where: search ? where : { ...(status ? { status } : {}), ...(trainId ? { trainId } : {}) },
order: { [sortBy]: sortOrder } as FindOptionsOrder<Wagon>,
skip: query.page && query.limit ? (Number(query.page) - 1) * Number(query.limit) : undefined,
take: query.limit ? Number(query.limit) : undefined,
});
} }
async findById(id: string): Promise<Wagon> { async findById(id: string): Promise<Wagon> {
@@ -39,8 +62,6 @@ export class WagonsService {
async update(id: string, dto: UpdateWagonDto): Promise<Wagon> { async update(id: string, dto: UpdateWagonDto): Promise<Wagon> {
const wagon = await this.findById(id); const wagon = await this.findById(id);
Object.assign(wagon, dto); Object.assign(wagon, dto);
if (dto.trainId === undefined) wagon.trainId = null;
if (dto.sequenceNumber === undefined) wagon.sequenceNumber = null;
return this.wagonRepo.save(wagon); return this.wagonRepo.save(wagon);
} }
@@ -98,4 +119,4 @@ export class WagonsService {
await queryRunner.release(); await queryRunner.release();
} }
} }
} }

View File

@@ -0,0 +1,25 @@
const { Client } = require('pg');
(async function createSchema(){
const client = new Client({
host: 'localhost',
port: 5432,
user: 'postgres',
password: '',
database: 'edr_freight',
});
try {
console.log('Connecting to Postgres...');
await client.connect();
console.log('Creating schema freight if not exists...');
await client.query('CREATE SCHEMA IF NOT EXISTS freight');
console.log('Schema ensured.');
await client.end();
process.exit(0);
} catch (err) {
console.error('Failed to create schema:', err);
try { await client.end(); } catch {}
process.exit(1);
}
})();

View File

@@ -0,0 +1,27 @@
import { Client } from 'pg';
async function createSchema() {
const client = new Client({
host: 'localhost',
port: 5432,
user: 'postgres',
password: '',
database: 'edr_freight',
});
try {
console.log('Connecting to Postgres...');
await client.connect();
console.log('Creating schema freight if not exists...');
await client.query('CREATE SCHEMA IF NOT EXISTS freight');
console.log('Schema ensured.');
await client.end();
process.exit(0);
} catch (err) {
console.error('Failed to create schema:', err);
try { await client.end(); } catch {}
process.exit(1);
}
}
createSchema();

View File

@@ -0,0 +1,21 @@
import { AppDataSource } from '../data-source';
async function runMigrations() {
try {
console.log('Initializing datasource...');
await AppDataSource.initialize();
console.log('Datasource initialized. Running migrations...');
const migrations = await AppDataSource.runMigrations();
console.log(`Applied ${migrations.length} migrations.`);
await AppDataSource.destroy();
process.exit(0);
} catch (err) {
console.error('Migration run failed:', err);
try {
await AppDataSource.destroy();
} catch {}
process.exit(1);
}
}
runMigrations();

View File

@@ -4,7 +4,11 @@ import { DataSource } from "typeorm";
import { BookingContainer } from "../modules/bookings/entities/booking-container.entity"; import { BookingContainer } from "../modules/bookings/entities/booking-container.entity";
import { Booking } from "../modules/bookings/entities/booking.entity"; import { Booking } from "../modules/bookings/entities/booking.entity";
import { Customer } from "../modules/customers/entities/customer.entity"; import {
Company,
CompanyStatus,
CompanyType,
} from "../modules/companies/entities/company.entity";
import { Locomotive } from "../modules/locomotives/entities/locomotive.entity"; import { Locomotive } from "../modules/locomotives/entities/locomotive.entity";
import { ServiceType } from "../modules/rule-engine/entities/service-type.entity"; import { ServiceType } from "../modules/rule-engine/entities/service-type.entity";
import { Yard } from "../modules/rule-engine/entities/yard.entity"; import { Yard } from "../modules/rule-engine/entities/yard.entity";
@@ -14,7 +18,8 @@ import { ContainerType } from "../modules/rule-engine/entities/container-type.en
const SEED_FLAG = "SEED_DEMO_BOOKINGS"; const SEED_FLAG = "SEED_DEMO_BOOKINGS";
const SERVICE_TYPE_CODE = "RAIL_CONTAINER"; const SERVICE_TYPE_CODE = "RAIL_CONTAINER";
const CUSTOMER_EMAIL = "train-scheduling-demo@edr.local"; const COMPANY_EMAIL = "train-scheduling-demo@edr.local";
const COMPANY_TIN = "1234567890";
const YARDS = [ const YARDS = [
{ code: "DJIBOUTI", label: "Djibouti", country: "Djibouti", displayOrder: 1 }, { code: "DJIBOUTI", label: "Djibouti", country: "Djibouti", displayOrder: 1 },
@@ -183,39 +188,35 @@ export class DemoBookingsSeeder {
{ conflictPaths: { code: true } }, { conflictPaths: { code: true } },
); );
await manager.getRepository(Customer).upsert( await manager.getRepository(Company).upsert(
{ {
userId: "00000000-0000-0000-0000-000000000111", name: "Train Scheduling Demo Customer",
firstName: "Train", type: CompanyType.Customer,
lastName: "Scheduling", status: CompanyStatus.Active,
email: CUSTOMER_EMAIL, tin: COMPANY_TIN,
phone: "251900000001",
companyName: "Train Scheduling Demo Customer",
companyEmail: CUSTOMER_EMAIL,
companyPhone: "251900000001",
companyLocation: "Addis Ababa",
companyAddress: "Demo Address",
customerType: "DEMO",
status: "ACTIVE",
contactPersonName: "Train Scheduling",
contactPersonPhone: "251900000001",
tinNumber: "1234567890",
vatNumber: "1234567890", vatNumber: "1234567890",
fanNumber: "1234567890123456", fanNumber: "1234567890123456",
country: "Ethiopia",
address: "Demo Address",
phone: "251900000001",
email: COMPANY_EMAIL,
website: null,
contactPersonName: "Train Scheduling",
contactPersonPhone: "251900000001",
generalManagerName: "Demo Manager", generalManagerName: "Demo Manager",
generalManagerEmail: CUSTOMER_EMAIL, generalManagerEmail: COMPANY_EMAIL,
generalManagerPhone: "251900000001", generalManagerPhone: "251900000001",
}, },
{ conflictPaths: { email: true } }, { conflictPaths: { tin: true } },
); );
const [serviceType, customer, yards, containerTypes] = await Promise.all([ const [serviceType, company, yards, containerTypes] = await Promise.all([
manager manager
.getRepository(ServiceType) .getRepository(ServiceType)
.findOneByOrFail({ code: SERVICE_TYPE_CODE }), .findOneByOrFail({ code: SERVICE_TYPE_CODE }),
manager manager
.getRepository(Customer) .getRepository(Company)
.findOneByOrFail({ email: CUSTOMER_EMAIL }), .findOneByOrFail({ tin: COMPANY_TIN }),
manager.getRepository(Yard).find(), manager.getRepository(Yard).find(),
manager.getRepository(ContainerType).find(), manager.getRepository(ContainerType).find(),
]); ]);
@@ -247,7 +248,7 @@ export class DemoBookingsSeeder {
await manager.getRepository(Booking).upsert( await manager.getRepository(Booking).upsert(
{ {
reference: demoBooking.reference, reference: demoBooking.reference,
companyId: customer.id, companyId: company.id,
status: "APPROVED", status: "APPROVED",
scheduledDate: new Date(demoBooking.scheduledDate), scheduledDate: new Date(demoBooking.scheduledDate),
totalAmount: 0, totalAmount: 0,

View File

@@ -17,8 +17,9 @@
"@mantine/core": "^9.3.0", "@mantine/core": "^9.3.0",
"@mantine/hooks": "^9.3.0", "@mantine/hooks": "^9.3.0",
"@tabler/icons-react": "^3.44.0", "@tabler/icons-react": "^3.44.0",
"@hello-pangea/dnd": "^18.0.1",
"@tanstack/react-query": "^5.100.11", "@tanstack/react-query": "^5.100.11",
"@tria-plc/iamui-common": "1.1.1", "@tria-plc/iamui-common": "1.1.2",
"axios": "^1.7.7", "axios": "^1.7.7",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",

View File

@@ -24,7 +24,7 @@ import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page"; import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page"; import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page";
import OverviewPage from "./pages/dashboard/OverviewPage"; import OverviewPage from "./pages/dashboard/OverviewPage";
import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage"; //import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage";
import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage"; import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage";
import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage"; import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage";
import RolesPage from "./pages/dashboard/user-management/RolesPage"; import RolesPage from "./pages/dashboard/user-management/RolesPage";
@@ -34,13 +34,15 @@ import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage"; import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect"; import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage"; import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
// import TrainsPage from "./pages/trains/TrainsPage"; import TrainsPage from "./pages/trains/TrainsPage";
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"; import {
//import TrainsPage from "./pages/trains/TrainsPage"; CargoesCrudPage,
import TrainDetailPage from "./pages/trains/TrainDetailPage"; ContainersCrudPage,
import WagonsPage from "./pages/wagons/WagonsPage"; TrainMasterDataPage,
import ContainersPage from "./pages/containers_management/ContainersPage"; WagonsCrudPage,
import CargoesPage from "./pages/cargoes/CargoesPage"; } from "./pages/fleet/FleetCrudPages";
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
import TrainDetailPage from "./pages/trains/TrainDetailPage";
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
{ {
@@ -239,14 +241,13 @@ const App = () => {
<Route <Route
path="booking-requests/:id/contract" path="booking-requests/:id/contract"
element={<BookingContractPage />} element={<BookingContractPage />}
/> />
{/* <Route path="operations/train-scheduling" element={<TrainsPage />} /> <Route path="operations/train-scheduling" element={<TrainsPage />} />
<Route path="trains" element={<TrainMasterDataPage />} />
<Route path="trains" element={<TrainsPage />} /> */} <Route path="trains/:id" element={<TrainDetailPage />} />
<Route path="trains/:id" element={<TrainDetailPage />} /> <Route path="wagons" element={<WagonsCrudPage />} />
<Route path="wagons" element={<WagonsPage />} /> <Route path="containers" element={<ContainersCrudPage />} />
<Route path="containers" element={<ContainersPage />} /> <Route path="cargoes" element={<CargoesCrudPage />} />
<Route path="cargoes" element={<CargoesPage />} />
<Route path="user-management" element={<UserManagementPage />} /> <Route path="user-management" element={<UserManagementPage />} />
<Route path="user-management/users" element={<UsersPage />} /> <Route path="user-management/users" element={<UsersPage />} />

View File

@@ -1,12 +1,10 @@
/** Shared surfaces for booking list & detail — frosted glass, neutral accents. */ /** Shared surfaces for booking list & detail — frosted glass, neutral accents. */
export const bookingGlass = { export const bookingGlass = {
card: card: "border border-border/50 bg-card/75 shadow-sm backdrop-blur-md supports-[backdrop-filter]:bg-card/60",
"border border-border/50 bg-card/75 shadow-sm backdrop-blur-md supports-[backdrop-filter]:bg-card/60",
panel: panel:
"border border-border/50 bg-card/80 shadow-sm backdrop-blur-md supports-[backdrop-filter]:bg-card/65", "border border-border/50 bg-card/80 shadow-sm backdrop-blur-md supports-[backdrop-filter]:bg-card/65",
rail: rail: "border border-border/40 bg-muted/20 backdrop-blur-sm supports-[backdrop-filter]:bg-muted/15",
"border border-border/40 bg-muted/20 backdrop-blur-sm supports-[backdrop-filter]:bg-muted/15",
iconWell: iconWell:
"border border-border/50 bg-background/70 text-foreground/75 shadow-sm backdrop-blur-sm supports-[backdrop-filter]:bg-background/50", "border border-border/50 bg-background/70 text-foreground/75 shadow-sm backdrop-blur-sm supports-[backdrop-filter]:bg-background/50",
iconWellHero: iconWellHero:
@@ -22,9 +20,8 @@ export const bookingGlass = {
} as const; } as const;
export const bookingSurface = { export const bookingSurface = {
page: page: "min-h-screen bg-background ",
"min-h-screen bg-gradient-to-b from-muted/30 via-background to-background", pageInner: "mx-auto max-w-[1600px] space-y-5 ",
pageInner: "mx-auto max-w-[1600px] space-y-5 p-6 lg:p-8",
hero: `relative overflow-hidden rounded-2xl ${bookingGlass.card}`, hero: `relative overflow-hidden rounded-2xl ${bookingGlass.card}`,
heroGlow: heroGlow:
"pointer-events-none absolute -right-24 -top-24 size-72 rounded-full bg-muted/40 blur-3xl", "pointer-events-none absolute -right-24 -top-24 size-72 rounded-full bg-muted/40 blur-3xl",

View File

@@ -0,0 +1,238 @@
import { useState, useEffect } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import axios from 'axios';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Loader2 } from 'lucide-react';
interface Cargo {
id: string;
cargoReference: string;
description: string;
quantity: number;
weight: number;
status: 'PENDING' | 'LOADED' | 'IN_TRANSIT' | 'DELIVERED' | 'CANCELLED';
remarks?: string;
}
interface CargoFormDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
cargo?: Cargo | null;
onSuccess?: () => void;
}
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
export default function CargoFormDialog({
open,
onOpenChange,
cargo,
onSuccess,
}: CargoFormDialogProps) {
const queryClient = useQueryClient();
const [formData, setFormData] = useState<Partial<Cargo>>({
cargoReference: '',
description: '',
quantity: 0,
weight: 0,
status: 'PENDING',
remarks: '',
});
useEffect(() => {
if (cargo) {
setFormData(cargo);
} else {
setFormData({
cargoReference: '',
description: '',
quantity: 0,
weight: 0,
status: 'PENDING',
remarks: '',
});
}
}, [cargo, open]);
const createMutation = useMutation({
mutationFn: (data: Partial<Cargo>) =>
axios.post(`${API_BASE_URL}/api/cargoes`, data),
onSuccess: () => {
toast.success('Cargo created successfully');
onOpenChange(false);
queryClient.invalidateQueries({ queryKey: ['cargoes'] });
onSuccess?.();
},
onError: (error) => {
const message = axios.isAxiosError(error)
? error.response?.data?.message || 'Failed to create cargo'
: 'Failed to create cargo';
toast.error(message);
},
});
const updateMutation = useMutation({
mutationFn: (data: Partial<Cargo>) =>
axios.patch(`${API_BASE_URL}/api/cargoes/${cargo?.id}`, data),
onSuccess: () => {
toast.success('Cargo updated successfully');
onOpenChange(false);
queryClient.invalidateQueries({ queryKey: ['cargoes'] });
onSuccess?.();
},
onError: (error) => {
const message = axios.isAxiosError(error)
? error.response?.data?.message || 'Failed to update cargo'
: 'Failed to update cargo';
toast.error(message);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!formData.cargoReference || !formData.description) {
toast.error('Please fill in all required fields');
return;
}
if (cargo?.id) {
updateMutation.mutate(formData);
} else {
createMutation.mutate(formData);
}
};
const isLoading = createMutation.isPending || updateMutation.isPending;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>
{cargo ? 'Edit Cargo' : 'Create New Cargo'}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="cargoReference">Cargo Reference *</Label>
<Input
id="cargoReference"
value={formData.cargoReference || ''}
onChange={(e) =>
setFormData({ ...formData, cargoReference: e.target.value })
}
placeholder="e.g., CRG001"
required
/>
</div>
<div>
<Label htmlFor="status">Status</Label>
<select
id="status"
value={formData.status || 'PENDING'}
onChange={(e) =>
setFormData({ ...formData, status: e.target.value as any })
}
className="flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="PENDING">Pending</option>
<option value="LOADED">Loaded</option>
<option value="IN_TRANSIT">In Transit</option>
<option value="DELIVERED">Delivered</option>
<option value="CANCELLED">Cancelled</option>
</select>
</div>
</div>
<div>
<Label htmlFor="description">Description *</Label>
<Textarea
id="description"
value={formData.description || ''}
onChange={(e) =>
setFormData({ ...formData, description: e.target.value })
}
placeholder="Describe the cargo contents..."
rows={3}
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="quantity">Quantity *</Label>
<Input
id="quantity"
type="number"
value={formData.quantity || ''}
onChange={(e) =>
setFormData({
...formData,
quantity: parseFloat(e.target.value) || 0,
})
}
placeholder="0"
required
/>
</div>
<div>
<Label htmlFor="weight">Weight (kg) *</Label>
<Input
id="weight"
type="number"
value={formData.weight || ''}
onChange={(e) =>
setFormData({
...formData,
weight: parseFloat(e.target.value) || 0,
})
}
placeholder="0"
required
/>
</div>
</div>
<div>
<Label htmlFor="remarks">Remarks</Label>
<Textarea
id="remarks"
value={formData.remarks || ''}
onChange={(e) =>
setFormData({ ...formData, remarks: e.target.value })
}
placeholder="Add any additional notes..."
rows={2}
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isLoading}
>
Cancel
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{cargo ? 'Update' : 'Create'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,10 +1,16 @@
import { useState } from 'react'; import { useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@edr/ui-common';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { useContainers, useAssignContainerToWagon } from '@/hooks/useContainers'; import { useContainers, useAssignContainerToWagon } from './use-containers';
import { useToast } from '@/hooks/use-toast'; import { useToast } from '@/hooks/use-toast';
import { Plus } from 'lucide-react'; import { Plus } from 'lucide-react';

View File

@@ -0,0 +1,125 @@
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@edr/ui-common';
import { Textarea } from '@/components/ui/textarea';
import { useCargoTypes } from './use-cargo-types';
import { useCargoMutations } from './use-cargoes';
import { Loader2 } from 'lucide-react';
interface Cargo {
id: string;
cargoNumber: string;
cargoTypeId: string;
weight: number;
remarks?: string;
}
interface CargoFormDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
cargo?: Cargo | null;
onSuccess?: () => void;
}
export default function CargoFormDialog({
open,
onOpenChange,
cargo,
onSuccess,
}: CargoFormDialogProps) {
const { data: cargoTypes } = useCargoTypes();
const { createCargo, updateCargo } = useCargoMutations();
const [formData, setFormData] = useState<Partial<Cargo>>({
cargoNumber: '',
cargoTypeId: '',
weight: 0,
remarks: '',
});
useEffect(() => {
if (cargo) {
setFormData(cargo);
} else {
setFormData({
cargoNumber: '',
cargoTypeId: '',
weight: 0,
remarks: '',
});
}
}, [cargo, open]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (cargo?.id) {
updateCargo.mutate(
{ id: cargo.id, data: formData },
{ onSuccess: () => { onOpenChange(false); onSuccess?.(); } }
);
} else {
createCargo.mutate(formData, {
onSuccess: () => { onOpenChange(false); onSuccess?.(); }
});
}
};
const isLoading = createCargo.isPending || updateCargo.isPending;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader><DialogTitle>{cargo ? 'Edit Cargo' : 'Create New Cargo'}</DialogTitle></DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Cargo Number *</Label>
<Input value={formData.cargoNumber} onChange={e => setFormData({...formData, cargoNumber: e.target.value})} required />
</div>
<div>
<Label>Cargo Type *</Label>
<Select
value={formData.cargoTypeId || ''}
onValueChange={(val) => setFormData({ ...formData, cargoTypeId: val })}
>
<SelectTrigger>
<SelectValue placeholder="Select cargo type..." />
</SelectTrigger>
<SelectContent>
{cargoTypes?.map((type: any) => (
<SelectItem key={type.id} value={type.id}>{type.cargo_type_name || type.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div>
<Label>Weight (kg) *</Label>
<Input type="number" value={formData.weight} onChange={e => setFormData({...formData, weight: parseFloat(e.target.value)})} required />
</div>
<div>
<Label>Remarks</Label>
<Textarea value={formData.remarks} onChange={e => setFormData({...formData, remarks: e.target.value})} rows={3} />
</div>
<DialogFooter>
<Button type="submit" disabled={isLoading}>{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}{cargo ? 'Update' : 'Create'}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,246 @@
import { useState, useEffect } from 'react';
import { toast } from 'sonner';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@edr/ui-common';
import { Textarea } from '@/components/ui/textarea';
import { useContainerTypes } from './use-container-types';
import { useContainerMutations } from './use-containers';
import { Loader2 } from 'lucide-react';
interface Container {
id: string;
containerNumber: string;
containerTypeId: string;
wagonId?: string;
status: 'AVAILABLE' | 'IN_USE' | 'MAINTENANCE' | 'RETIRED';
capacity: number;
weight: number;
remarks?: string;
}
interface Wagon {
id: string;
wagonNumber: string;
}
interface ContainerFormDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
container?: Container | null;
wagons: Wagon[];
onSuccess?: () => void;
}
export default function ContainerFormDialog({
open,
onOpenChange,
container,
wagons = [],
onSuccess,
}: ContainerFormDialogProps) {
const { data: containerTypes } = useContainerTypes();
const { createContainer, updateContainer } = useContainerMutations();
const [formData, setFormData] = useState<Partial<Container>>({
containerNumber: '',
containerTypeId: '',
status: 'AVAILABLE',
capacity: 0,
weight: 0,
remarks: '',
});
useEffect(() => {
if (container) {
setFormData(container);
} else {
setFormData({
containerNumber: '',
containerTypeId: '',
status: 'AVAILABLE',
capacity: 0,
weight: 0,
remarks: '',
});
}
}, [container, open]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!formData.containerNumber || !formData.containerTypeId) {
toast.error('Please fill in all required fields');
return;
}
if (container?.id) {
updateContainer.mutate(
{ id: container.id, data: formData },
{ onSuccess: () => { onOpenChange(false); onSuccess?.(); } }
);
} else {
createContainer.mutate(formData, {
onSuccess: () => { onOpenChange(false); onSuccess?.(); }
});
}
};
const isLoading = createContainer.isPending || updateContainer.isPending;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>
{container ? 'Edit Container' : 'Create New Container'}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="containerNumber">Container Number *</Label>
<Input
id="containerNumber"
value={formData.containerNumber || ''}
onChange={(e) =>
setFormData({ ...formData, containerNumber: e.target.value })
}
placeholder="e.g., CNT001"
required
/>
</div>
<div>
<Label htmlFor="containerTypeId">Container Type *</Label>
<Select
value={formData.containerTypeId || ''}
onValueChange={(val:any) => setFormData({ ...formData, containerTypeId: val })}
>
<SelectTrigger id="containerTypeId">
<SelectValue placeholder="Select container type..." />
</SelectTrigger>
<SelectContent>
{containerTypes?.map((type: any) => (
<SelectItem key={type.id} value={type.id}>{type.name || type.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="wagonId">Wagon (Optional)</Label>
<Select
value={formData.wagonId || 'none'}
onValueChange={(val:any) => setFormData({ ...formData, wagonId: val === 'none' ? undefined : val })}
>
<SelectTrigger id="wagonId">
<SelectValue placeholder="Select a wagon..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{wagons.map((wagon) => (
<SelectItem key={wagon.id} value={wagon.id}>
{wagon.wagonNumber}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="status">Status</Label>
<Select
value={formData.status || 'AVAILABLE'}
onValueChange={(val:any) => setFormData({ ...formData, status: val })}
>
<SelectTrigger id="status">
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="AVAILABLE">Available</SelectItem>
<SelectItem value="IN_USE">In Use</SelectItem>
<SelectItem value="MAINTENANCE">Maintenance</SelectItem>
<SelectItem value="RETIRED">Retired</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="capacity">Capacity *</Label>
<Input
id="capacity"
type="number"
value={formData.capacity || ''}
onChange={(e) =>
setFormData({
...formData,
capacity: parseFloat(e.target.value) || 0,
})
}
placeholder="0"
required
/>
</div>
<div>
<Label htmlFor="weight">Weight (kg)</Label>
<Input
id="weight"
type="number"
value={formData.weight || ''}
onChange={(e) =>
setFormData({
...formData,
weight: parseFloat(e.target.value) || 0,
})
}
placeholder="0"
/>
</div>
</div>
<div>
<Label htmlFor="remarks">Remarks</Label>
<Textarea
id="remarks"
value={formData.remarks || ''}
onChange={(e) =>
setFormData({ ...formData, remarks: e.target.value })
}
placeholder="Add any additional notes..."
rows={3}
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isLoading}
>
Cancel
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{container ? 'Update' : 'Create'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,7 +1,7 @@
import { useContainersByWagon, useUnassignContainer } from '@/hooks/useContainers'; import { useContainersByWagon, useUnassignContainer } from './use-containers';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Trash2 } from 'lucide-react'; import { Trash2 } from 'lucide-react';
import type { Container } from '@/services/containerService'; import type { Container } from './container.service';
export function ContainersTable({ wagonId }: { wagonId: string }) { export function ContainersTable({ wagonId }: { wagonId: string }) {
const { data: containers, refetch } = useContainersByWagon(wagonId); const { data: containers, refetch } = useContainersByWagon(wagonId);

View File

@@ -0,0 +1,15 @@
import { api } from '../../auth/http';
type ListResponse<T> = T[] | { data: T[] };
const asList = <T>(payload: ListResponse<T>): T[] =>
Array.isArray(payload) ? payload : payload.data;
export const cargoTypesService = {
async getCargoTypes() {
const response = await api.get<ListResponse<unknown>>('/cargo-types', {
params: { isActive: true, pageSize: 500 },
});
return asList(response.data);
},
};

View File

@@ -0,0 +1,19 @@
import { api } from "../../auth/http";
export const cargoService = {
async getCargoes() {
const response = await api.get('/cargoes');
return response.data;
},
async createCargo(data: any) {
const response = await api.post('/cargoes', data);
return response.data;
},
async updateCargo(id: string, data: any) {
const response = await api.patch(`/cargoes/${id}`, data);
return response.data;
},
async deleteCargo(id: string) {
await api.delete(`/cargoes/${id}`);
},
};

View File

@@ -0,0 +1,15 @@
import { api } from '../../auth/http';
type ListResponse<T> = T[] | { data: T[] };
const asList = <T>(payload: ListResponse<T>): T[] =>
Array.isArray(payload) ? payload : payload.data;
export const containerTypesService = {
async getContainerTypes() {
const response = await api.get<ListResponse<unknown>>('/container-types', {
params: { isActive: true, pageSize: 500 },
});
return asList(response.data);
},
};

View File

@@ -0,0 +1,31 @@
import { api } from "../../auth/http";
export const containerService = {
async getContainers() {
const response = await api.get('/containers');
return response.data;
},
async getContainersByWagon(wagonId: string) {
const response = await api.get('/containers', { params: { wagonId } });
return response.data;
},
async createContainer(data: any) {
const response = await api.post('/containers', data);
return response.data;
},
async updateContainer(id: string, data: any) {
const response = await api.patch(`/containers/${id}`, data);
return response.data;
},
async deleteContainer(id: string) {
await api.delete(`/containers/${id}`);
},
async assignToWagon(containerId: string, wagonId: string, position?: number) {
const response = await api.post(`/containers/${containerId}/assign-wagon`, { wagonId, position });
return response.data;
},
async unassignFromWagon(containerId: string) {
const response = await api.post(`/containers/${containerId}/unassign-wagon`);
return response.data;
},
};

View File

@@ -0,0 +1,12 @@
import { useQuery } from '@tanstack/react-query';
import { cargoTypesService } from './cargo-types.service';
export const CARGO_TYPES_QUERY_KEY = ['cargo-types'];
export function useCargoTypes() {
return useQuery({
queryKey: CARGO_TYPES_QUERY_KEY,
queryFn: () => cargoTypesService.getCargoTypes(),
staleTime: Infinity,
});
}

View File

@@ -0,0 +1,42 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { cargoService } from './cargo.service';
import { toast } from 'sonner';
export const CARGOES_QUERY_KEY = ['cargoes'];
export function useCargoes() {
return useQuery({
queryKey: CARGOES_QUERY_KEY,
queryFn: () => cargoService.getCargoes(),
});
}
export function useCargoMutations() {
const queryClient = useQueryClient();
const createCargo = useMutation({
mutationFn: (data: any) => cargoService.createCargo(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: CARGOES_QUERY_KEY });
toast.success('Cargo created successfully');
},
});
const updateCargo = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => cargoService.updateCargo(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: CARGOES_QUERY_KEY });
toast.success('Cargo updated successfully');
},
});
const deleteCargo = useMutation({
mutationFn: (id: string) => cargoService.deleteCargo(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: CARGOES_QUERY_KEY });
toast.success('Cargo deleted successfully');
},
});
return { createCargo, updateCargo, deleteCargo };
}

View File

@@ -0,0 +1,12 @@
import { useQuery } from '@tanstack/react-query';
import { containerTypesService } from './container-types.service';
export const CONTAINER_TYPES_QUERY_KEY = ['container-types'];
export function useContainerTypes() {
return useQuery({
queryKey: CONTAINER_TYPES_QUERY_KEY,
queryFn: () => containerTypesService.getContainerTypes(),
staleTime: Infinity,
});
}

View File

@@ -0,0 +1,73 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { containerService } from './container.service';
import { toast } from 'sonner';
export const CONTAINERS_QUERY_KEY = ['containers'];
export function useContainers() {
return useQuery({
queryKey: CONTAINERS_QUERY_KEY,
queryFn: () => containerService.getContainers(),
});
}
export function useContainersByWagon(wagonId: string) {
return useQuery({
queryKey: [...CONTAINERS_QUERY_KEY, 'wagon', wagonId],
queryFn: () => containerService.getContainersByWagon(wagonId),
enabled: !!wagonId,
});
}
export function useContainerMutations() {
const queryClient = useQueryClient();
const createContainer = useMutation({
mutationFn: (data: any) => containerService.createContainer(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: CONTAINERS_QUERY_KEY });
toast.success('Container created successfully');
},
});
const updateContainer = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => containerService.updateContainer(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: CONTAINERS_QUERY_KEY });
toast.success('Container updated successfully');
},
});
const deleteContainer = useMutation({
mutationFn: (id: string) => containerService.deleteContainer(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: CONTAINERS_QUERY_KEY });
toast.success('Container deleted successfully');
},
});
return { createContainer, updateContainer, deleteContainer };
}
export function useUnassignContainer() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => containerService.unassignFromWagon(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: CONTAINERS_QUERY_KEY });
toast.success('Container unassigned from wagon');
},
});
}
export function useAssignContainerToWagon() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ containerId, wagonId, position }: { containerId: string; wagonId: string; position?: number }) =>
containerService.assignToWagon(containerId, wagonId, position),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: CONTAINERS_QUERY_KEY });
toast.success('Container assigned to wagon');
},
});
}

View File

@@ -0,0 +1,12 @@
import { useQuery } from '@tanstack/react-query';
import { wagonTypesService } from './wagon-types.service';
export const WAGON_TYPES_QUERY_KEY = ['wagon-types'];
export function useWagonTypes() {
return useQuery({
queryKey: WAGON_TYPES_QUERY_KEY,
queryFn: () => wagonTypesService.getWagonTypes(),
staleTime: Infinity,
});
}

View File

@@ -0,0 +1,48 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { wagonService } from './wagon.service';
import { toast } from 'sonner';
export const WAGONS_QUERY_KEY = ['wagons'];
export function useWagons() {
return useQuery({
queryKey: WAGONS_QUERY_KEY,
queryFn: () => wagonService.getWagons(),
});
}
export function useWagonMutations() {
const queryClient = useQueryClient();
const createWagon = useMutation({
mutationFn: (data: any) => wagonService.createWagon(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: WAGONS_QUERY_KEY });
toast.success('Wagon created successfully');
},
onError: (error: any) => {
toast.error(error.response?.data?.message || 'Failed to create wagon');
},
});
const updateWagon = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => wagonService.updateWagon(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: WAGONS_QUERY_KEY });
toast.success('Wagon updated successfully');
},
onError: (error: any) => {
toast.error(error.response?.data?.message || 'Failed to update wagon');
},
});
const deleteWagon = useMutation({
mutationFn: (id: string) => wagonService.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: WAGONS_QUERY_KEY });
toast.success('Wagon deleted successfully');
},
});
return { createWagon, updateWagon, deleteWagon };
}

View File

@@ -0,0 +1,13 @@
import { api } from '../../auth/http';
type ListResponse<T> = T[] | { data: T[] };
const asList = <T>(payload: ListResponse<T>): T[] =>
Array.isArray(payload) ? payload : payload.data;
export const wagonTypesService = {
async getWagonTypes() {
const response = await api.get<ListResponse<unknown>>('/wagon-types');
return asList(response.data);
},
};

View File

@@ -0,0 +1,23 @@
import { api } from "../../auth/http";
export const wagonService = {
async getWagons() {
const response = await api.get('/wagons');
return response.data;
},
async getWagonById(id: string) {
const response = await api.get(`/wagons/${id}`);
return response.data;
},
async createWagon(data: any) {
const response = await api.post('/wagons', data);
return response.data;
},
async updateWagon(id: string, data: any) {
const response = await api.patch(`/wagons/${id}`, data);
return response.data;
},
async deleteWagon(id: string) {
await api.delete(`/wagons/${id}`);
},
};

View File

@@ -56,7 +56,10 @@ const FreightDashboardHeader = ({
if (!isUserMenuOpen) return; if (!isUserMenuOpen) return;
const handlePointerDown = (event: MouseEvent) => { const handlePointerDown = (event: MouseEvent) => {
if (userMenuRef.current && !userMenuRef.current.contains(event.target as Node)) { if (
userMenuRef.current &&
!userMenuRef.current.contains(event.target as Node)
) {
setIsUserMenuOpen(false); setIsUserMenuOpen(false);
} }
}; };
@@ -74,10 +77,14 @@ const FreightDashboardHeader = ({
}, [isUserMenuOpen]); }, [isUserMenuOpen]);
return ( return (
<header className="flex h-[82px] shrink-0 items-center justify-between gap-4 px-6"> <header className="flex h-20 shrink-0 items-center justify-between gap-4 px-6">
<div className="min-w-0"> <div className="min-w-0">
<h1 className="truncate text-xl font-bold tracking-tight text-gray-900">{pageMeta.title}</h1> <h1 className="truncate text-xl font-bold tracking-tight text-foreground">
<p className="mt-0.5 truncate text-sm text-gray-500">{pageMeta.subtitle}</p> {pageMeta.title}
</h1>
<p className="mt-0.5 truncate text-sm text-secondary-foreground">
{pageMeta.subtitle}
</p>
</div> </div>
<div className="flex shrink-0 items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
@@ -85,14 +92,24 @@ const FreightDashboardHeader = ({
<button <button
type="button" type="button"
onClick={onToggleTheme} onClick={onToggleTheme}
aria-label={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"} aria-label={
theme === "dark" ? "Switch to light mode" : "Switch to dark mode"
}
className={iconButtonClass} className={iconButtonClass}
> >
{theme === "dark" ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />} {theme === "dark" ? (
<Sun className="h-5 w-5" />
) : (
<Moon className="h-5 w-5" />
)}
</button> </button>
) : null} ) : null}
<button type="button" aria-label="Change language" className={iconButtonClass}> <button
type="button"
aria-label="Change language"
className={iconButtonClass}
>
<Languages className="h-5 w-5" /> <Languages className="h-5 w-5" />
</button> </button>
@@ -101,7 +118,11 @@ const FreightDashboardHeader = ({
<span className="absolute right-2 top-2 h-2 w-2 rounded-full bg-red-500 ring-2 ring-white" /> <span className="absolute right-2 top-2 h-2 w-2 rounded-full bg-red-500 ring-2 ring-white" />
</button> </button>
<button type="button" aria-label="Notifications" className={iconButtonClass}> <button
type="button"
aria-label="Notifications"
className={iconButtonClass}
>
<Bell className="h-5 w-5" /> <Bell className="h-5 w-5" />
<span className="absolute right-2 top-2 h-2 w-2 rounded-full bg-red-500 ring-2 ring-white" /> <span className="absolute right-2 top-2 h-2 w-2 rounded-full bg-red-500 ring-2 ring-white" />
</button> </button>
@@ -136,8 +157,12 @@ const FreightDashboardHeader = ({
className="absolute right-0 top-full z-50 mt-2 w-52 overflow-hidden rounded-xl border border-gray-200 bg-white py-1 shadow-lg" className="absolute right-0 top-full z-50 mt-2 w-52 overflow-hidden rounded-xl border border-gray-200 bg-white py-1 shadow-lg"
> >
<div className="border-b border-gray-100 px-4 py-3"> <div className="border-b border-gray-100 px-4 py-3">
<p className="text-sm font-semibold text-gray-900">{userName}</p> <p className="text-sm font-semibold text-gray-900">
{userEmail ? <p className="text-xs text-gray-500">{userEmail}</p> : null} {userName}
</p>
{userEmail ? (
<p className="text-xs text-gray-500">{userEmail}</p>
) : null}
</div> </div>
<a <a
href="#profile" href="#profile"

View File

@@ -12,7 +12,9 @@ function getInitialTheme(): Theme {
if (typeof window === "undefined") return "light"; if (typeof window === "undefined") return "light";
const stored = window.localStorage.getItem(THEME_STORAGE_KEY); const stored = window.localStorage.getItem(THEME_STORAGE_KEY);
if (stored === "dark" || stored === "light") return stored; if (stored === "dark" || stored === "light") return stored;
return window.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light"; return window.matchMedia?.("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
} }
export interface FreightDashboardLayoutProps { export interface FreightDashboardLayoutProps {
@@ -29,7 +31,7 @@ export interface FreightDashboardLayoutProps {
} }
const panelClass = const panelClass =
"rounded-2xl border border-gray-200/80 bg-white shadow-[0_1px_3px_rgba(15,23,42,0.06)]"; "rounded-lg border border-gray-200/80 bg-white shadow-[0_1px_3px_rgba(15,23,42,0.06)]";
const FreightDashboardLayout = ({ const FreightDashboardLayout = ({
sidebarSections, sidebarSections,
@@ -59,7 +61,8 @@ const FreightDashboardLayout = ({
window.localStorage.setItem(THEME_STORAGE_KEY, theme); window.localStorage.setItem(THEME_STORAGE_KEY, theme);
}, [theme, enableThemeToggle]); }, [theme, enableThemeToggle]);
const toggleTheme = () => setTheme((current) => (current === "dark" ? "light" : "dark")); const toggleTheme = () =>
setTheme((current) => (current === "dark" ? "light" : "dark"));
return ( return (
<> <>
@@ -71,17 +74,17 @@ const FreightDashboardLayout = ({
/> />
<div <div
className="flex h-[100dvh] overflow-hidden bg-[#eceef2] p-3 antialiased md:p-4" className="flex h-[100dvh] overflow-hidden bg-[#eceef2] p-2 antialiased"
style={{ fontFamily: "'Outfit', var(--font-sans)" }} style={{ fontFamily: "'Outfit', var(--font-sans)" }}
> >
<div className="flex h-full min-h-0 w-full gap-3 md:gap-3"> <div className="flex h-full min-h-0 w-full gap-2">
<FreightSidebar <FreightSidebar
sections={sidebarSections} sections={sidebarSections}
activeHref={activeHref} activeHref={activeHref}
onNavigate={onNavigate} onNavigate={onNavigate}
/> />
<div className="flex h-full min-h-0 min-w-0 flex-1 flex-col gap-3 md:gap-3"> <div className="flex h-full min-h-0 min-w-0 flex-1 flex-col gap-2">
<div className={`shrink-0 ${panelClass}`}> <div className={`shrink-0 ${panelClass}`}>
<FreightDashboardHeader <FreightDashboardHeader
pageMeta={pageMeta} pageMeta={pageMeta}

View File

@@ -1,4 +1,10 @@
import { type MouseEvent, useCallback, useEffect, useMemo, useState } from "react"; import {
type MouseEvent,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { ChevronDown, ChevronRight } from "lucide-react"; import { ChevronDown, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -20,21 +26,28 @@ const collectSidebarHrefs = (items: SidebarItem[]): string[] =>
items.flatMap((item) => { items.flatMap((item) => {
const hrefs: string[] = []; const hrefs: string[] = [];
if (item.href) hrefs.push(item.href.toLowerCase()); if (item.href) hrefs.push(item.href.toLowerCase());
if (item.children?.length) hrefs.push(...collectSidebarHrefs(item.children)); if (item.children?.length)
hrefs.push(...collectSidebarHrefs(item.children));
return hrefs; return hrefs;
}); });
const flattenSectionItems = (sections: SidebarSection[]) => const flattenSectionItems = (sections: SidebarSection[]) =>
sections.flatMap((section) => section.items); sections.flatMap((section) => section.items);
const FreightSidebar = ({ sections, activeHref, onNavigate }: FreightSidebarProps) => { const FreightSidebar = ({
sections,
activeHref,
onNavigate,
}: FreightSidebarProps) => {
const items = useMemo(() => flattenSectionItems(sections), [sections]); const items = useMemo(() => flattenSectionItems(sections), [sections]);
const activePath = activeHref?.toLowerCase() ?? ""; const activePath = activeHref?.toLowerCase() ?? "";
const isHrefActive = useCallback( const isHrefActive = useCallback(
(href: string) => { (href: string) => {
const normalized = href.toLowerCase(); const normalized = href.toLowerCase();
return activePath === normalized || activePath.startsWith(`${normalized}/`); return (
activePath === normalized || activePath.startsWith(`${normalized}/`)
);
}, },
[activePath], [activePath],
); );
@@ -72,7 +85,8 @@ const FreightSidebar = ({ sections, activeHref, onNavigate }: FreightSidebarProp
return acc; return acc;
}, [activePath, branchContainsActive, isHrefActive, items]); }, [activePath, branchContainsActive, isHrefActive, items]);
const [expanded, setExpanded] = useState<Record<string, boolean>>(defaultExpanded); const [expanded, setExpanded] =
useState<Record<string, boolean>>(defaultExpanded);
useEffect(() => { useEffect(() => {
setExpanded((current) => ({ ...defaultExpanded, ...current })); setExpanded((current) => ({ ...defaultExpanded, ...current }));
@@ -108,7 +122,11 @@ const FreightSidebar = ({ sections, activeHref, onNavigate }: FreightSidebarProp
: "text-gray-900", : "text-gray-900",
); );
const renderNavBranch = (children: SidebarItem[], depth: number, parentKey: string) => const renderNavBranch = (
children: SidebarItem[],
depth: number,
parentKey: string,
) =>
children.map((child) => { children.map((child) => {
const key = sidebarItemKey(child, parentKey); const key = sidebarItemKey(child, parentKey);
const isGroup = Boolean(child.children?.length) && !child.href; const isGroup = Boolean(child.children?.length) && !child.href;
@@ -181,7 +199,9 @@ const FreightSidebar = ({ sections, activeHref, onNavigate }: FreightSidebarProp
const hasChildren = Boolean(item.children?.length); const hasChildren = Boolean(item.children?.length);
const itemHref = item.href.toLowerCase(); const itemHref = item.href.toLowerCase();
const childActive = hasChildren ? branchContainsActive(item.children!) : false; const childActive = hasChildren
? branchContainsActive(item.children!)
: false;
const isCurrentItem = hasChildren const isCurrentItem = hasChildren
? activePath === itemHref ? activePath === itemHref
: isHrefActive(itemHref); : isHrefActive(itemHref);
@@ -206,10 +226,12 @@ const FreightSidebar = ({ sections, activeHref, onNavigate }: FreightSidebarProp
href={item.href} href={item.href}
onClick={(event) => navigateTo(event, item.href!)} onClick={(event) => navigateTo(event, item.href!)}
aria-current={isCurrentItem ? "page" : undefined} aria-current={isCurrentItem ? "page" : undefined}
className="flex min-w-0 flex-1 items-center gap-3 px-3 py-2.5 text-base font-medium leading-snug" className="flex min-w-0 flex-1 items-center gap-3 px-3 py-2.5 text-sm leading-snug"
> >
{item.icon ? ( {item.icon ? (
<span className={iconClass(leafActive, isActive)}>{item.icon}</span> <span className={iconClass(leafActive, isActive)}>
{item.icon}
</span>
) : null} ) : null}
<span className="truncate">{item.label}</span> <span className="truncate">{item.label}</span>
</a> </a>
@@ -257,10 +279,12 @@ const FreightSidebar = ({ sections, activeHref, onNavigate }: FreightSidebarProp
}; };
return ( return (
<aside className="flex h-full max-h-full w-[340px] shrink-0 flex-col overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm"> <aside className="flex h-full max-h-full w-[280px] shrink-0 flex-col overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm">
<div className="flex shrink-0 items-center gap-2.5 border-b border-gray-100 px-5 py-5"> <div className="flex shrink-0 items-center gap-2.5 border-b border-gray-100 px-5 py-5">
<img src={EDR_LOGO} alt="EDR Freight" className="h-9 w-auto" /> <img src={EDR_LOGO} alt="EDR Freight" className="h-9 w-auto" />
<span className="text-lg font-semibold tracking-tight text-gray-900">EDR Freight</span> <span className="text-lg font-semibold tracking-tight text-gray-900">
EDR Freight
</span>
</div> </div>
<nav className="flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto overscroll-contain px-3 py-4"> <nav className="flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto overscroll-contain px-3 py-4">
@@ -270,7 +294,7 @@ const FreightSidebar = ({ sections, activeHref, onNavigate }: FreightSidebarProp
className={cn( className={cn(
"px-3 pb-1 text-xs font-semibold uppercase tracking-wide", "px-3 pb-1 text-xs font-semibold uppercase tracking-wide",
// Use a very light gray for ALL section titles, not just when mutedTitle is specified // Use a very light gray for ALL section titles, not just when mutedTitle is specified
"text-gray-400" "text-sidebar-secondary-foreground",
)} )}
> >
{section.title} {section.title}

View File

@@ -0,0 +1 @@
export { Card, CardHeader, CardTitle, CardDescription, CardFooter, CardAction, CardContent } from '@edr/ui-common';

View File

@@ -4,6 +4,8 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@edr/ui-common';
import { useWagonTypes } from '@/hooks/use-wagon-types';
import { useCreateWagon, useUpdateWagon } from '@/hooks/useWagons'; import { useCreateWagon, useUpdateWagon } from '@/hooks/useWagons';
import { useToast } from '@/hooks/use-toast'; import { useToast } from '@/hooks/use-toast';
@@ -25,6 +27,7 @@ export function WagonFormDialog({ trigger, wagon, onSuccess }: WagonFormDialogPr
}); });
const createWagon = useCreateWagon(); const createWagon = useCreateWagon();
const updateWagon = useUpdateWagon(); const updateWagon = useUpdateWagon();
const { data: wagonTypes = [], isLoading: wagonTypesLoading } = useWagonTypes();
const { toast } = useToast(); const { toast } = useToast();
useEffect(() => { useEffect(() => {
@@ -40,6 +43,10 @@ export function WagonFormDialog({ trigger, wagon, onSuccess }: WagonFormDialogPr
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!form.wagonNumber || !form.wagonTypeId) {
toast({ title: 'Missing required field', description: 'Please select a wagon type.', variant: 'destructive' });
return;
}
try { try {
if (wagon) await updateWagon.mutateAsync({ id: wagon.id, data: form }); if (wagon) await updateWagon.mutateAsync({ id: wagon.id, data: form });
else await createWagon.mutateAsync(form); else await createWagon.mutateAsync(form);
@@ -57,10 +64,37 @@ export function WagonFormDialog({ trigger, wagon, onSuccess }: WagonFormDialogPr
<DialogContent> <DialogContent>
<DialogHeader><DialogTitle>{wagon ? 'Edit Wagon' : 'Create Wagon'}</DialogTitle></DialogHeader> <DialogHeader><DialogTitle>{wagon ? 'Edit Wagon' : 'Create Wagon'}</DialogTitle></DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div><Label>Wagon Number*</Label><Input required value={form.wagonNumber} onChange={e => setForm({...form, wagonNumber: e.target.value})} /></div> <div><Label>Wagon Number*</Label><Input value={form.wagonNumber} onChange={e => setForm({...form, wagonNumber: e.target.value})} /></div>
<div><Label>Wagon Type ID*</Label><Input required value={form.wagonTypeId} onChange={e => setForm({...form, wagonTypeId: e.target.value})} /></div> <div>
<div><Label>Tare Weight (kg)*</Label><Input type="number" required value={form.tareWeight} onChange={e => setForm({...form, tareWeight: parseFloat(e.target.value)})} /></div> <Label>Wagon Type*</Label>
<div><Label>Max Payload (kg)*</Label><Input type="number" required value={form.maxPayloadWeight} onChange={e => setForm({...form, maxPayloadWeight: parseFloat(e.target.value)})} /></div> <Select
value={form.wagonTypeId}
disabled={wagonTypesLoading}
onValueChange={(value) => {
const selectedType = wagonTypes.find((type: any) => type.id === value);
setForm((current) => ({
...current,
wagonTypeId: value,
maxPayloadWeight: current.maxPayloadWeight > 0
? current.maxPayloadWeight
: Number(selectedType?.capacityTons ?? current.maxPayloadWeight),
}));
}}
>
<SelectTrigger>
<SelectValue placeholder="Select wagon type" />
</SelectTrigger>
<SelectContent>
{wagonTypes.map((type: any) => (
<SelectItem key={type.id} value={type.id}>
{type.code} - {type.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div><Label>Tare Weight (kg)*</Label><Input type="number" value={form.tareWeight} onChange={e => setForm({...form, tareWeight: Number(e.target.value)})} /></div>
<div><Label>Max Payload (kg)*</Label><Input type="number" value={form.maxPayloadWeight} onChange={e => setForm({...form, maxPayloadWeight: Number(e.target.value)})} /></div>
<div><Label>Status</Label><Input value={form.status} onChange={e => setForm({...form, status: e.target.value})} /></div> <div><Label>Status</Label><Input value={form.status} onChange={e => setForm({...form, status: e.target.value})} /></div>
<div><Label>Notes</Label><Input value={form.notes} onChange={e => setForm({...form, notes: e.target.value})} /></div> <div><Label>Notes</Label><Input value={form.notes} onChange={e => setForm({...form, notes: e.target.value})} /></div>
<Button type="submit" disabled={createWagon.isPending || updateWagon.isPending}>Save</Button> <Button type="submit" disabled={createWagon.isPending || updateWagon.isPending}>Save</Button>
@@ -68,4 +102,4 @@ export function WagonFormDialog({ trigger, wagon, onSuccess }: WagonFormDialogPr
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
} }

View File

@@ -0,0 +1,282 @@
import { useState, useEffect } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import axios from 'axios';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@edr/ui-common';
import { Textarea } from '@/components/ui/textarea';
import { Loader2 } from 'lucide-react';
import { useWagonTypes } from '@/hooks/use-wagon-types';
interface Wagon {
id: string;
wagonNumber: string;
wagonTypeId: string;
trainId?: string;
status: 'AVAILABLE' | 'IN_USE' | 'MAINTENANCE' | 'RETIRED';
capacity: number;
emptyWeight: number;
remarks?: string;
}
interface Train {
id: string;
trainNumber: string;
}
interface WagonFormDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
wagon?: Wagon | null;
trains: Train[];
onSuccess?: () => void;
}
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
export default function WagonFormDialog({
open,
onOpenChange,
wagon,
trains = [],
onSuccess,
}: WagonFormDialogProps) {
const queryClient = useQueryClient();
const { data: wagonTypes = [], isLoading: wagonTypesLoading } = useWagonTypes();
const [formData, setFormData] = useState<Partial<Wagon>>({
wagonNumber: '',
wagonTypeId: '',
status: 'AVAILABLE',
capacity: 0,
emptyWeight: 0,
remarks: '',
});
useEffect(() => {
if (wagon) {
setFormData(wagon);
} else {
setFormData({
wagonNumber: '',
wagonTypeId: '',
status: 'AVAILABLE',
capacity: 0,
emptyWeight: 0,
remarks: '',
});
}
}, [wagon, open]);
const createMutation = useMutation({
mutationFn: (data: Partial<Wagon>) =>
axios.post(`${API_BASE_URL}/api/wagons`, data),
onSuccess: () => {
toast.success('Wagon created successfully');
onOpenChange(false);
queryClient.invalidateQueries({ queryKey: ['wagons'] });
onSuccess?.();
},
onError: (error) => {
const message = axios.isAxiosError(error)
? error.response?.data?.message || 'Failed to create wagon'
: 'Failed to create wagon';
toast.error(message);
},
});
const updateMutation = useMutation({
mutationFn: (data: Partial<Wagon>) =>
axios.patch(`${API_BASE_URL}/api/wagons/${wagon?.id}`, data),
onSuccess: () => {
toast.success('Wagon updated successfully');
onOpenChange(false);
queryClient.invalidateQueries({ queryKey: ['wagons'] });
onSuccess?.();
},
onError: (error) => {
const message = axios.isAxiosError(error)
? error.response?.data?.message || 'Failed to update wagon'
: 'Failed to update wagon';
toast.error(message);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!formData.wagonNumber || !formData.wagonTypeId) {
toast.error('Please fill in all required fields');
return;
}
if (wagon?.id) {
updateMutation.mutate(formData);
} else {
createMutation.mutate(formData);
}
};
const isLoading = createMutation.isPending || updateMutation.isPending;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>
{wagon ? 'Edit Wagon' : 'Create New Wagon'}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="wagonNumber">Wagon Number *</Label>
<Input
id="wagonNumber"
value={formData.wagonNumber || ''}
onChange={(e) =>
setFormData({ ...formData, wagonNumber: e.target.value })
}
placeholder="e.g., W001"
required
/>
</div>
<div>
<Label htmlFor="wagonTypeId">Type *</Label>
<Select
value={formData.wagonTypeId || ''}
disabled={wagonTypesLoading}
onValueChange={(value) => {
const selectedType = wagonTypes.find((type: any) => type.id === value);
setFormData({
...formData,
wagonTypeId: value,
capacity: formData.capacity && formData.capacity > 0
? formData.capacity
: Number(selectedType?.capacityTons ?? 0),
});
}}
>
<SelectTrigger id="wagonTypeId">
<SelectValue placeholder="Select wagon type" />
</SelectTrigger>
<SelectContent>
{wagonTypes.map((type: any) => (
<SelectItem key={type.id} value={type.id}>
{type.code} - {type.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="trainId">Train (Optional)</Label>
<select
id="trainId"
value={formData.trainId || ''}
onChange={(e) =>
setFormData({ ...formData, trainId: e.target.value || undefined })
}
className="flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Select a train...</option>
{trains.map(train => (
<option key={train.id} value={train.id}>
{train.trainNumber}
</option>
))}
</select>
</div>
<div>
<Label htmlFor="status">Status</Label>
<select
id="status"
value={formData.status || 'AVAILABLE'}
onChange={(e) =>
setFormData({ ...formData, status: e.target.value as any })
}
className="flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="AVAILABLE">Available</option>
<option value="IN_USE">In Use</option>
<option value="MAINTENANCE">Maintenance</option>
<option value="RETIRED">Retired</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="capacity">Capacity *</Label>
<Input
id="capacity"
type="number"
value={formData.capacity || ''}
onChange={(e) =>
setFormData({
...formData,
capacity: parseFloat(e.target.value) || 0,
})
}
placeholder="0"
required
/>
</div>
<div>
<Label htmlFor="emptyWeight">Empty Weight (kg)</Label>
<Input
id="emptyWeight"
type="number"
value={formData.emptyWeight || ''}
onChange={(e) =>
setFormData({
...formData,
emptyWeight: parseFloat(e.target.value) || 0,
})
}
placeholder="0"
/>
</div>
</div>
<div>
<Label htmlFor="remarks">Remarks</Label>
<Textarea
id="remarks"
value={formData.remarks || ''}
onChange={(e) =>
setFormData({ ...formData, remarks: e.target.value })
}
placeholder="Add any additional notes..."
rows={3}
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isLoading}
>
Cancel
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{wagon ? 'Update' : 'Create'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,7 +1,7 @@
import { useWagonsByTrain, useUnassignWagon, useReorderWagons } from '@/hooks/useWagons'; import { useWagonsByTrain, useUnassignWagon, useReorderWagons } from '@/hooks/useWagons';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; //import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Button } from '@/components/ui/button'; //import { Button } from '@/components/ui/button';
import { Trash2, GripVertical } from 'lucide-react'; //import { Trash2, GripVertical } from 'lucide-react';
// import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd'; // import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
export function WagonsTable({ trainId }: { trainId: string }) { export function WagonsTable({ trainId }: { trainId: string }) {

View File

@@ -77,6 +77,7 @@ export const URL_CONSTANTS = {
BOOKINGS: { BOOKINGS: {
BASE: "/bookings", BASE: "/bookings",
REFERENCE_DATA: "/bookings/reference-data",
LIST_SUMMARY: "/bookings/list-summary", LIST_SUMMARY: "/bookings/list-summary",
BY_ID: (id: string) => `/bookings/${id}`, BY_ID: (id: string) => `/bookings/${id}`,
QUEUE: (queue: string) => `/bookings/queues/${queue}`, QUEUE: (queue: string) => `/bookings/queues/${queue}`,

View File

@@ -0,0 +1,12 @@
import { useQuery } from '@tanstack/react-query';
import { cargoTypesService } from '@/services/cargo-types.service';
export const CARGO_TYPES_QUERY_KEY = ['cargo-types'];
export function useCargoTypes() {
return useQuery({
queryKey: CARGO_TYPES_QUERY_KEY,
queryFn: () => cargoTypesService.getCargoTypes(),
staleTime: Infinity,
});
}

View File

@@ -0,0 +1 @@
export * from '@/components/container_management/use-cargoes';

View File

@@ -0,0 +1,12 @@
import { useQuery } from '@tanstack/react-query';
import { containerTypesService } from '@/services/container-types.service';
export const CONTAINER_TYPES_QUERY_KEY = ['container-types'];
export function useContainerTypes() {
return useQuery({
queryKey: CONTAINER_TYPES_QUERY_KEY,
queryFn: () => containerTypesService.getContainerTypes(),
staleTime: Infinity,
});
}

View File

@@ -0,0 +1 @@
export * from '@/components/container_management/use-containers';

View File

@@ -0,0 +1,12 @@
import { useQuery } from '@tanstack/react-query';
import { wagonTypesService } from '@/services/wagon-types.service';
export const WAGON_TYPES_QUERY_KEY = ['wagon-types'];
export function useWagonTypes() {
return useQuery({
queryKey: WAGON_TYPES_QUERY_KEY,
queryFn: () => wagonTypesService.getWagonTypes(),
staleTime: Infinity,
});
}

View File

@@ -4,16 +4,44 @@ import { cargoService } from '@/services/cargoService';
export const cargoKeys = { export const cargoKeys = {
all: ['cargoes'] as const, all: ['cargoes'] as const,
byContainer: (containerId: string) => [...cargoKeys.all, 'container', containerId] as const, byContainer: (containerId: string) => [...cargoKeys.all, 'container', containerId] as const,
details: () => [...cargoKeys.all, 'detail'] as const,
detail: (id: string) => [...cargoKeys.details(), id] as const,
}; };
export function useCargoes() { export function useCargoes() {
return useQuery({ queryKey: cargoKeys.all, queryFn: () => cargoService.getAll().then(res => res.data) }); return useQuery({ queryKey: cargoKeys.all, queryFn: () => cargoService.getAll().then(res => res.data) });
} }
export const useGetCargoes = useCargoes;
export function useCargoesByContainer(containerId: string) { export function useCargoesByContainer(containerId: string) {
return useQuery({ queryKey: cargoKeys.byContainer(containerId), queryFn: () => cargoService.getByContainer(containerId).then(res => res.data), enabled: !!containerId }); return useQuery({ queryKey: cargoKeys.byContainer(containerId), queryFn: () => cargoService.getByContainer(containerId).then(res => res.data), enabled: !!containerId });
} }
export function useCargo(id: string) {
return useQuery({ queryKey: cargoKeys.detail(id), queryFn: () => cargoService.getById(id).then(res => res.data), enabled: !!id });
}
export const useGetCargo = useCargo;
export function useCreateCargo() {
const qc = useQueryClient();
return useMutation({ mutationFn: cargoService.create, onSuccess: () => qc.invalidateQueries({ queryKey: cargoKeys.all }) });
}
export function useUpdateCargo() {
const qc = useQueryClient();
return useMutation({ mutationFn: ({ id, data }: any) => cargoService.update(id, data), onSuccess: (_, { id }) => {
qc.invalidateQueries({ queryKey: cargoKeys.all });
qc.invalidateQueries({ queryKey: cargoKeys.detail(id) });
} });
}
export function useDeleteCargo() {
const qc = useQueryClient();
return useMutation({ mutationFn: cargoService.delete, onSuccess: () => qc.invalidateQueries({ queryKey: cargoKeys.all }) });
}
export function useLoadCargo() { export function useLoadCargo() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
@@ -36,4 +64,4 @@ export function useUnloadCargo() {
mutationFn: (id: string) => cargoService.unload(id), mutationFn: (id: string) => cargoService.unload(id),
onSuccess: () => qc.invalidateQueries({ queryKey: cargoKeys.all }) onSuccess: () => qc.invalidateQueries({ queryKey: cargoKeys.all })
}); });
} }

View File

@@ -4,16 +4,44 @@ import { containerService } from '@/services/containerService';
export const containerKeys = { export const containerKeys = {
all: ['containers'] as const, all: ['containers'] as const,
byWagon: (wagonId: string) => [...containerKeys.all, 'wagon', wagonId] as const, byWagon: (wagonId: string) => [...containerKeys.all, 'wagon', wagonId] as const,
details: () => [...containerKeys.all, 'detail'] as const,
detail: (id: string) => [...containerKeys.details(), id] as const,
}; };
export function useContainers() { export function useContainers() {
return useQuery({ queryKey: containerKeys.all, queryFn: () => containerService.getAll().then(res => res.data) }); return useQuery({ queryKey: containerKeys.all, queryFn: () => containerService.getAll().then(res => res.data) });
} }
export const useGetContainers = useContainers;
export function useContainersByWagon(wagonId: string) { export function useContainersByWagon(wagonId: string) {
return useQuery({ queryKey: containerKeys.byWagon(wagonId), queryFn: () => containerService.getByWagon(wagonId).then(res => res.data), enabled: !!wagonId }); return useQuery({ queryKey: containerKeys.byWagon(wagonId), queryFn: () => containerService.getByWagon(wagonId).then(res => res.data), enabled: !!wagonId });
} }
export function useContainer(id: string) {
return useQuery({ queryKey: containerKeys.detail(id), queryFn: () => containerService.getById(id).then(res => res.data), enabled: !!id });
}
export const useGetContainer = useContainer;
export function useCreateContainer() {
const qc = useQueryClient();
return useMutation({ mutationFn: containerService.create, onSuccess: () => qc.invalidateQueries({ queryKey: containerKeys.all }) });
}
export function useUpdateContainer() {
const qc = useQueryClient();
return useMutation({ mutationFn: ({ id, data }: any) => containerService.update(id, data), onSuccess: (_, { id }) => {
qc.invalidateQueries({ queryKey: containerKeys.all });
qc.invalidateQueries({ queryKey: containerKeys.detail(id) });
} });
}
export function useDeleteContainer() {
const qc = useQueryClient();
return useMutation({ mutationFn: containerService.delete, onSuccess: () => qc.invalidateQueries({ queryKey: containerKeys.all }) });
}
export function useAssignContainerToWagon() { export function useAssignContainerToWagon() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
@@ -28,4 +56,4 @@ export function useUnassignContainer() {
mutationFn: containerService.unassign, mutationFn: containerService.unassign,
onSuccess: () => qc.invalidateQueries({ queryKey: containerKeys.all }) onSuccess: () => qc.invalidateQueries({ queryKey: containerKeys.all })
}); });
} }

View File

@@ -12,10 +12,14 @@ export function useTrains() {
return useQuery({ queryKey: trainKeys.lists(), queryFn: () => trainService.getAll().then(res => res.data) }); return useQuery({ queryKey: trainKeys.lists(), queryFn: () => trainService.getAll().then(res => res.data) });
} }
export const useGetTrains = useTrains;
export function useTrain(id: string) { export function useTrain(id: string) {
return useQuery({ queryKey: trainKeys.detail(id), queryFn: () => trainService.getById(id).then(res => res.data), enabled: !!id }); return useQuery({ queryKey: trainKeys.detail(id), queryFn: () => trainService.getById(id).then(res => res.data), enabled: !!id });
} }
export const useGetTrain = useTrain;
export function useCreateTrain() { export function useCreateTrain() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ mutationFn: trainService.create, onSuccess: () => qc.invalidateQueries({ queryKey: trainKeys.lists() }) }); return useMutation({ mutationFn: trainService.create, onSuccess: () => qc.invalidateQueries({ queryKey: trainKeys.lists() }) });
@@ -32,4 +36,4 @@ export function useUpdateTrain() {
export function useDeleteTrain() { export function useDeleteTrain() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ mutationFn: trainService.delete, onSuccess: () => qc.invalidateQueries({ queryKey: trainKeys.lists() }) }); return useMutation({ mutationFn: trainService.delete, onSuccess: () => qc.invalidateQueries({ queryKey: trainKeys.lists() }) });
} }

View File

@@ -5,16 +5,25 @@ export const wagonKeys = {
all: ['wagons'] as const, all: ['wagons'] as const,
byTrain: (trainId: string) => [...wagonKeys.all, 'train', trainId] as const, byTrain: (trainId: string) => [...wagonKeys.all, 'train', trainId] as const,
details: () => [...wagonKeys.all, 'detail'] as const, details: () => [...wagonKeys.all, 'detail'] as const,
detail: (id: string) => [...wagonKeys.details(), id] as const,
}; };
export function useWagons() { export function useWagons() {
return useQuery({ queryKey: wagonKeys.all, queryFn: () => wagonService.getAll().then(res => res.data) }); return useQuery({ queryKey: wagonKeys.all, queryFn: () => wagonService.getAll().then(res => res.data) });
} }
export const useGetWagons = useWagons;
export function useWagonsByTrain(trainId: string) { export function useWagonsByTrain(trainId: string) {
return useQuery({ queryKey: wagonKeys.byTrain(trainId), queryFn: () => wagonService.getByTrain(trainId).then(res => res.data), enabled: !!trainId }); return useQuery({ queryKey: wagonKeys.byTrain(trainId), queryFn: () => wagonService.getByTrain(trainId).then(res => res.data), enabled: !!trainId });
} }
export function useWagon(id: string) {
return useQuery({ queryKey: wagonKeys.detail(id), queryFn: () => wagonService.getById(id).then(res => res.data), enabled: !!id });
}
export const useGetWagon = useWagon;
export function useAssignWagonToTrain() { export function useAssignWagonToTrain() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ mutationFn: ({ wagonId, trainId, sequenceNumber }: any) => wagonService.assignToTrain(wagonId, trainId, sequenceNumber), onSuccess: (_, { trainId }) => qc.invalidateQueries({ queryKey: wagonKeys.byTrain(trainId) }) }); return useMutation({ mutationFn: ({ wagonId, trainId, sequenceNumber }: any) => wagonService.assignToTrain(wagonId, trainId, sequenceNumber), onSuccess: (_, { trainId }) => qc.invalidateQueries({ queryKey: wagonKeys.byTrain(trainId) }) });
@@ -37,5 +46,13 @@ export function useCreateWagon() {
export function useUpdateWagon() { export function useUpdateWagon() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ mutationFn: ({ id, data }: any) => wagonService.update(id, data), onSuccess: () => qc.invalidateQueries({ queryKey: wagonKeys.all }) }); return useMutation({ mutationFn: ({ id, data }: any) => wagonService.update(id, data), onSuccess: (_, { id }) => {
} qc.invalidateQueries({ queryKey: wagonKeys.all });
qc.invalidateQueries({ queryKey: wagonKeys.detail(id) });
} });
}
export function useDeleteWagon() {
const qc = useQueryClient();
return useMutation({ mutationFn: wagonService.delete, onSuccess: () => qc.invalidateQueries({ queryKey: wagonKeys.all }) });
}

View File

@@ -0,0 +1,314 @@
import { useState, useMemo } from 'react';
import { useCargoes } from '@/hooks/useCargoes';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Trash2, Edit, Plus, Search, AlertCircle } from 'lucide-react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import axios from 'axios';
import CargoFormDialog from '@/components/cargoes/CargoFormDialog';
interface Cargo {
id: string;
cargoReference: string;
description: string;
quantity: number;
weight: number;
status: 'PENDING' | 'LOADED' | 'IN_TRANSIT' | 'DELIVERED' | 'CANCELLED';
remarks?: string;
createdAt: Date;
updatedAt: Date;
}
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
export default function CargoesPageEnhanced() {
const { data: cargoes = [], isLoading, refetch } = useCargoes();
const queryClient = useQueryClient();
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('');
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [isFormOpen, setIsFormOpen] = useState(false);
const [editingCargo, setEditingCargo] = useState<Cargo | null>(null);
const deleteMutation = useMutation({
mutationFn: (cargoId: string) =>
axios.delete(`${API_BASE_URL}/api/cargoes/${cargoId}`),
onSuccess: () => {
toast.success('Cargo deleted successfully');
refetch();
queryClient.invalidateQueries({ queryKey: ['cargoes'] });
},
onError: (error) => {
const message = axios.isAxiosError(error)
? error.response?.data?.message || 'Failed to delete cargo'
: 'Failed to delete cargo';
toast.error(message);
},
});
const bulkDeleteMutation = useMutation({
mutationFn: (ids: string[]) =>
Promise.all(ids.map(id => axios.delete(`${API_BASE_URL}/api/cargoes/${id}`))),
onSuccess: () => {
toast.success('Cargoes deleted successfully');
setSelectedIds(new Set());
refetch();
queryClient.invalidateQueries({ queryKey: ['cargoes'] });
},
onError: (error) => {
const message = axios.isAxiosError(error)
? error.response?.data?.message || 'Failed to delete cargoes'
: 'Failed to delete cargoes';
toast.error(message);
},
});
const filteredCargoes = useMemo(() => {
let result = cargoes;
if (searchTerm) {
const lower = searchTerm.toLowerCase();
result = result.filter(
cargo =>
cargo.cargoReference?.toLowerCase().includes(lower) ||
cargo.description?.toLowerCase().includes(lower)
);
}
if (statusFilter) {
result = result.filter(cargo => cargo.status === statusFilter);
}
return result;
}, [cargoes, searchTerm, statusFilter]);
const toggleSelect = (cargoId: string) => {
const newSelected = new Set(selectedIds);
if (newSelected.has(cargoId)) {
newSelected.delete(cargoId);
} else {
newSelected.add(cargoId);
}
setSelectedIds(newSelected);
};
const toggleSelectAll = () => {
if (selectedIds.size === filteredCargoes.length && filteredCargoes.length > 0) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(filteredCargoes.map(c => c.id)));
}
};
const handleFormSuccess = () => {
setIsFormOpen(false);
setEditingCargo(null);
refetch();
queryClient.invalidateQueries({ queryKey: ['cargoes'] });
};
const handleEdit = (cargo: Cargo) => {
setEditingCargo(cargo);
setIsFormOpen(true);
};
const handleDelete = (cargoId: string) => {
if (window.confirm('Are you sure you want to delete this cargo?')) {
deleteMutation.mutate(cargoId);
}
};
const handleBulkDelete = () => {
if (selectedIds.size === 0) {
toast.error('Please select at least one cargo');
return;
}
if (window.confirm(`Delete ${selectedIds.size} cargo(s)?`)) {
bulkDeleteMutation.mutate(Array.from(selectedIds));
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'PENDING':
return 'bg-gray-100 text-gray-800';
case 'LOADED':
return 'bg-blue-100 text-blue-800';
case 'IN_TRANSIT':
return 'bg-purple-100 text-purple-800';
case 'DELIVERED':
return 'bg-green-100 text-green-800';
case 'CANCELLED':
return 'bg-red-100 text-red-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
const statuses = ['PENDING', 'LOADED', 'IN_TRANSIT', 'DELIVERED', 'CANCELLED'];
if (isLoading) {
return <div className="p-6">Loading cargoes...</div>;
}
return (
<div className="space-y-6 p-6">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold">Cargoes Management</h1>
<Button onClick={() => {
setEditingCargo(null);
setIsFormOpen(true);
}}>
<Plus className="mr-2 h-4 w-4" />
New Cargo
</Button>
</div>
{/* Filters and Search */}
<Card>
<CardContent className="pt-6">
<div className="space-y-4">
<div className="flex gap-4 items-end">
<div className="flex-1">
<label className="text-sm font-medium mb-1 block">Search</label>
<div className="relative">
<Search className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<Input
placeholder="Search by reference or description..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
</div>
<div className="w-48">
<label className="text-sm font-medium mb-1 block">Status</label>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">All statuses</option>
{statuses.map(status => (
<option key={status} value={status}>
{status}
</option>
))}
</select>
</div>
</div>
{selectedIds.size > 0 && (
<div className="flex items-center gap-2 bg-blue-50 p-3 rounded-md">
<span className="text-sm text-gray-600">{selectedIds.size} selected</span>
<Button
variant="destructive"
size="sm"
onClick={handleBulkDelete}
disabled={bulkDeleteMutation.isPending}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete Selected
</Button>
</div>
)}
</div>
</CardContent>
</Card>
{/* Cargoes Table */}
<Card>
<CardHeader>
<CardTitle>All Cargoes ({filteredCargoes.length})</CardTitle>
</CardHeader>
<CardContent>
{filteredCargoes.length === 0 ? (
<div className="flex items-center justify-center py-12 text-gray-500">
<AlertCircle className="mr-2 h-5 w-5" />
No cargoes found
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10">
<input
type="checkbox"
checked={selectedIds.size === filteredCargoes.length && filteredCargoes.length > 0}
onChange={toggleSelectAll}
className="rounded"
/>
</TableHead>
<TableHead>Cargo Reference</TableHead>
<TableHead>Description</TableHead>
<TableHead>Quantity</TableHead>
<TableHead>Weight (kg)</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredCargoes.map((cargo) => (
<TableRow key={cargo.id}>
<TableCell>
<input
type="checkbox"
checked={selectedIds.has(cargo.id)}
onChange={() => toggleSelect(cargo.id)}
className="rounded"
/>
</TableCell>
<TableCell className="font-medium">{cargo.cargoReference}</TableCell>
<TableCell className="max-w-xs truncate">{cargo.description}</TableCell>
<TableCell>{cargo.quantity}</TableCell>
<TableCell>{cargo.weight}</TableCell>
<TableCell>
<Badge className={getStatusColor(cargo.status)}>
{cargo.status}
</Badge>
</TableCell>
<TableCell>
{new Date(cargo.createdAt).toLocaleDateString()}
</TableCell>
<TableCell className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleEdit(cargo)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => handleDelete(cargo.id)}
disabled={deleteMutation.isPending}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
{/* Form Dialog */}
<CargoFormDialog
open={isFormOpen}
onOpenChange={setIsFormOpen}
cargo={editingCargo}
onSuccess={handleFormSuccess}
/>
</div>
);
}

View File

@@ -0,0 +1,633 @@
import { FormEvent, ReactNode, useMemo, useState } from 'react';
import { Edit, Eye, Plus, Search, Trash2 } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@edr/ui-common';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { useCargoTypes } from '@/hooks/use-cargo-types';
import { useContainerTypes } from '@/hooks/use-container-types';
import { useWagonTypes } from '@/hooks/use-wagon-types';
import { useToast } from '@/hooks/use-toast';
import { useCreateCargo, useDeleteCargo, useCargoes, useUpdateCargo } from '@/hooks/useCargoes';
import {
useContainers,
useCreateContainer,
useDeleteContainer,
useUpdateContainer,
} from '@/hooks/useContainers';
import { useCreateTrain, useDeleteTrain, useTrains, useUpdateTrain } from '@/hooks/useTrains';
import { useCreateWagon, useDeleteWagon, useUpdateWagon, useWagons } from '@/hooks/useWagons';
import type { Cargo } from '@/services/cargoService';
import type { Container } from '@/services/containerService';
import type { Train } from '@/services/trains.service';
import type { Wagon } from '@/services/wagon.service';
type FormValue = string | number;
type Field = {
key: string;
label: string;
type?: 'text' | 'number' | 'select';
required?: boolean;
options?: { value: string; label: string }[];
placeholder?: string;
onValueChange?: (
value: string,
current: Record<string, FormValue>,
) => Partial<Record<string, FormValue>>;
};
type Column<T> = {
key: keyof T | string;
label: string;
render?: (item: T) => ReactNode;
};
type FleetCrudPageProps<T extends { id: string }> = {
title: string;
description: string;
addLabel: string;
data?: T[];
isLoading: boolean;
columns: Column<T>[];
fields: Field[];
emptyValues: Record<string, FormValue>;
searchText: (item: T) => 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 };
};
const normalizePayload = (values: Record<string, FormValue>) =>
Object.fromEntries(
Object.entries(values)
.map(([key, value]) => [key, typeof value === 'string' ? value.trim() : value])
.filter(([, value]) => value !== ''),
);
const extractBackendErrors = (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;
const rawErrors = data?.errors;
const fieldErrors: Record<string, string> = {};
if (rawErrors && typeof rawErrors === 'object' && !Array.isArray(rawErrors)) {
Object.entries(rawErrors as Record<string, unknown>).forEach(([field, value]) => {
fieldErrors[field] = Array.isArray(value) ? value.join(', ') : String(value);
});
}
const message = Array.isArray(rawMessage)
? rawMessage.join(', ')
: rawMessage
? String(rawMessage)
: 'Save failed';
return { message, fieldErrors };
};
const validateForm = (fields: Field[], values: Record<string, FormValue>) => {
const errors: Record<string, string> = {};
fields.forEach((field) => {
const value = values[field.key];
const stringValue = typeof value === 'string' ? value.trim() : String(value ?? '');
if (field.required && stringValue === '') {
errors[field.key] = `${field.label} is required`;
return;
}
if (field.type === 'number' && stringValue !== '' && !Number.isFinite(Number(value))) {
errors[field.key] = `${field.label} must be a valid number`;
}
});
return errors;
};
function FleetCrudPage<T extends { id: string }>({
title,
description,
addLabel,
data,
isLoading,
columns,
fields,
emptyValues,
searchText,
create,
update,
remove,
}: FleetCrudPageProps<T>) {
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [sortKey, setSortKey] = useState<string>('');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<T | null>(null);
const [viewing, setViewing] = useState<T | null>(null);
const [form, setForm] = useState(emptyValues);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const { toast } = useToast();
const filtered = useMemo(() => {
const query = search.trim().toLowerCase();
if (!query) return data ?? [];
return (data ?? []).filter((item) => searchText(item).toLowerCase().includes(query));
}, [data, search, searchText]);
const sorted = useMemo(() => {
if (!sortKey) return filtered;
return [...filtered].sort((a, b) => {
const left = (a as Record<string, unknown>)[sortKey];
const right = (b as Record<string, unknown>)[sortKey];
const result = String(left ?? '').localeCompare(String(right ?? ''), undefined, { numeric: true });
return sortDirection === 'asc' ? result : -result;
});
}, [filtered, sortDirection, sortKey]);
const pageSize = 10;
const pageCount = Math.max(1, Math.ceil(sorted.length / pageSize));
const paged = sorted.slice((page - 1) * pageSize, page * pageSize);
const toggleSort = (key: string) => {
setPage(1);
if (sortKey === key) {
setSortDirection((current) => (current === 'asc' ? 'desc' : 'asc'));
return;
}
setSortKey(key);
setSortDirection('asc');
};
const openCreate = () => {
setEditing(null);
setForm(emptyValues);
setFieldErrors({});
setFormOpen(true);
};
const openEdit = (item: T) => {
setEditing(item);
setForm(
Object.fromEntries(
Object.keys(emptyValues).map((key) => [key, (item as Record<string, string | number | null | undefined>)[key] ?? '']),
),
);
setFieldErrors({});
setFormOpen(true);
};
const closeForm = () => {
setFormOpen(false);
setEditing(null);
setForm(emptyValues);
setFieldErrors({});
};
const handleSubmit = async (event: FormEvent) => {
event.preventDefault();
const validationErrors = validateForm(fields, form);
if (Object.keys(validationErrors).length > 0) {
setFieldErrors(validationErrors);
toast({
title: 'Save failed',
description: Object.values(validationErrors)[0],
variant: 'destructive',
});
return;
}
const payload = normalizePayload(form);
setFieldErrors({});
try {
if (editing) {
await update.mutateAsync({ id: editing.id, data: payload });
toast({ title: `${title.slice(0, -1)} updated` });
} else {
await create.mutateAsync(payload);
toast({ title: `${title.slice(0, -1)} created` });
}
closeForm();
} catch (error) {
const { message, fieldErrors: backendFieldErrors } = extractBackendErrors(error);
setFieldErrors(backendFieldErrors);
toast({ title: 'Save failed', description: message, variant: 'destructive' });
}
};
const handleDelete = async (item: T) => {
if (!window.confirm(`Delete this ${title.slice(0, -1).toLowerCase()}?`)) return;
try {
await remove.mutateAsync(item.id);
toast({ title: `${title.slice(0, -1)} deleted` });
} catch {
toast({ title: 'Delete failed', description: 'This record may still be referenced.', variant: 'destructive' });
}
};
const isSaving = create.isPending || update.isPending;
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">{title}</h1>
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
</div>
<Button onClick={openCreate}>
<Plus className="size-4" />
{addLabel}
</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 ${title.toLowerCase()}`}
value={search}
onChange={(event) => {
setSearch(event.target.value);
setPage(1);
}}
/>
</div>
<div className="overflow-hidden rounded-lg border bg-card">
<Table>
<TableHeader>
<TableRow>
{columns.map((column) => (
<TableHead key={String(column.key)}>
<button
type="button"
className="inline-flex items-center gap-1 font-medium"
onClick={() => toggleSort(String(column.key))}
>
{column.label}
{sortKey === column.key ? (sortDirection === 'asc' ? 'ASC' : 'DESC') : null}
</button>
</TableHead>
))}
<TableHead className="w-[150px] text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paged.map((item) => (
<TableRow key={item.id}>
{columns.map((column) => (
<TableCell key={String(column.key)}>
{column.render ? column.render(item) : String((item as Record<string, unknown>)[column.key] ?? '-')}
</TableCell>
))}
<TableCell>
<div className="flex justify-end gap-1">
<Button variant="ghost" size="icon" onClick={() => setViewing(item)} title="View">
<Eye className="size-4" />
</Button>
<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">
<Trash2 className="size-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
{!isLoading && filtered.length === 0 ? (
<TableRow>
<TableCell colSpan={columns.length + 1} className="h-28 text-center text-muted-foreground">
No records found.
</TableCell>
</TableRow>
) : null}
{isLoading ? (
<TableRow>
<TableCell colSpan={columns.length + 1} className="h-28 text-center text-muted-foreground">
Loading...
</TableCell>
</TableRow>
) : null}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>
Showing {sorted.length === 0 ? 0 : (page - 1) * pageSize + 1}-{Math.min(page * pageSize, sorted.length)} of {sorted.length}
</span>
<div className="flex gap-2">
<Button variant="outline" size="sm" disabled={page === 1} onClick={() => setPage((current) => current - 1)}>
Previous
</Button>
<Button variant="outline" size="sm" disabled={page === pageCount} onClick={() => setPage((current) => current + 1)}>
Next
</Button>
</div>
</div>
<Dialog open={formOpen} onOpenChange={(open) => (!open ? closeForm() : setFormOpen(true))}>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editing ? `Edit ${title.slice(0, -1)}` : addLabel}</DialogTitle>
</DialogHeader>
<form className="space-y-4" onSubmit={handleSubmit}>
{fields.map((field) => {
const value = form[field.key] ?? '';
const inputValue = field.type === 'number' && value !== '' && !Number.isFinite(Number(value))
? ''
: value;
return (
<div key={field.key} className="space-y-2">
<Label htmlFor={field.key}>{field.label}</Label>
{field.type === 'select' ? (
<Select
value={String(value)}
onValueChange={(selectedValue) =>
setForm((current) => ({
...current,
[field.key]: selectedValue,
...(field.onValueChange?.(selectedValue, current) ?? {}),
}))
}
>
<SelectTrigger id={field.key}>
<SelectValue placeholder={field.placeholder ?? `Select ${field.label.toLowerCase()}`} />
</SelectTrigger>
<SelectContent>
{field.options?.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
id={field.key}
type={field.type ?? 'text'}
value={inputValue}
onChange={(event) =>
setForm((current) => ({
...current,
[field.key]: field.type === 'number' && event.target.value !== ''
? Number(event.target.value)
: event.target.value,
}))
}
/>
)}
{fieldErrors[field.key] ? (
<p className="text-sm text-destructive">{fieldErrors[field.key]}</p>
) : null}
</div>
);
})}
<DialogFooter>
<Button type="button" variant="outline" onClick={closeForm}>
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>{title.slice(0, -1)} details</DialogTitle>
</DialogHeader>
<div className="grid gap-3 text-sm">
{viewing
? Object.entries(viewing).map(([key, value]) => (
<div key={key} className="grid grid-cols-[150px,1fr] gap-3 border-b pb-2">
<span className="font-medium">{key}</span>
<span className="break-all text-muted-foreground">{value == null ? '-' : String(value)}</span>
</div>
))
: null}
</div>
</DialogContent>
</Dialog>
</div>
);
}
const statusBadge = (status?: string) => <Badge variant="outline">{status ?? '-'}</Badge>;
const optionLabel = (options: { value: string; label: string }[], value?: string | null) =>
options.find((option) => option.value === value)?.label ?? value ?? '-';
export function TrainMasterDataPage() {
const query = useTrains();
return (
<FleetCrudPage<Train>
title="Trains"
description="Manage train master data independently from train scheduling."
addLabel="Add Train"
data={query.data}
isLoading={query.isLoading}
create={useCreateTrain()}
update={useUpdateTrain()}
remove={useDeleteTrain()}
searchText={(train) => [train.code, train.trainNumber, train.trainName, train.status].join(' ')}
columns={[
{ key: 'code', label: 'Code' },
{ key: 'trainNumber', label: 'Number', render: (train) => train.trainNumber || '-' },
{ key: 'trainName', label: 'Name', render: (train) => train.trainName || '-' },
{ key: 'capacityTons', label: 'Capacity (tons)' },
{ key: 'status', label: 'Status', render: (train) => statusBadge(train.status) },
]}
fields={[
{ key: 'code', label: 'Code', required: true },
{ key: 'capacityTons', label: 'Capacity (tons)', type: 'number', required: true },
{ key: 'trainNumber', label: 'Train number' },
{ key: 'trainName', label: 'Train name' },
{ key: 'locomotiveNumber', label: 'Locomotive number' },
{ key: 'status', label: 'Status' },
{ key: 'notes', label: 'Notes' },
{ key: 'remarks', label: 'Remarks' },
]}
emptyValues={{ code: '', capacityTons: 0, trainNumber: '', trainName: '', locomotiveNumber: '', status: 'AVAILABLE', notes: '', remarks: '' }}
/>
);
}
export function WagonsCrudPage() {
const query = useWagons();
const { data: wagonTypes = [] } = useWagonTypes();
const wagonTypeOptions = wagonTypes.map((type: any) => ({
value: type.id,
label: `${type.code} - ${type.name}`,
}));
return (
<FleetCrudPage<Wagon>
title="Wagons"
description="Manage wagon master data. Booking-based train assignment is handled in train scheduling."
addLabel="Add Wagon"
data={query.data}
isLoading={query.isLoading}
create={useCreateWagon()}
update={useUpdateWagon()}
remove={useDeleteWagon()}
searchText={(wagon) => [wagon.wagonNumber, wagon.wagonTypeId, wagon.trainId, wagon.status].join(' ')}
columns={[
{ key: 'wagonNumber', label: 'Number' },
{ key: 'wagonTypeId', label: 'Type', render: (wagon) => optionLabel(wagonTypeOptions, wagon.wagonTypeId) },
{ key: 'maxPayloadWeight', label: 'Max payload' },
{ key: 'status', label: 'Status', render: (wagon) => statusBadge(wagon.status) },
]}
fields={[
{ key: 'wagonNumber', label: 'Wagon number', required: true },
{
key: 'wagonTypeId',
label: 'Wagon type',
type: 'select',
required: true,
options: wagonTypeOptions,
onValueChange: (value, current) => {
const selectedType = wagonTypes.find((type: any) => type.id === value);
if (!selectedType || Number(current.maxPayloadWeight) > 0) return {};
return { maxPayloadWeight: Number(selectedType.capacityTons) };
},
},
{ key: 'tareWeight', label: 'Tare weight', type: 'number', required: true },
{ key: 'maxPayloadWeight', label: 'Max payload weight', type: 'number', required: true },
{ key: 'status', label: 'Status' },
{ key: 'notes', label: 'Notes' },
]}
emptyValues={{ wagonNumber: '', wagonTypeId: '', tareWeight: 0, maxPayloadWeight: 0, status: 'AVAILABLE', notes: '' }}
/>
);
}
export function ContainersCrudPage() {
const query = useContainers();
const { data: containerTypes = [] } = useContainerTypes();
const { data: wagons = [] } = useWagons();
const containerTypeOptions = containerTypes.map((type: any) => ({
value: type.id,
label: type.label ?? type.name ?? type.code,
}));
const wagonOptions = wagons.map((wagon: Wagon) => ({
value: wagon.id,
label: wagon.wagonNumber,
}));
return (
<FleetCrudPage<Container>
title="Containers"
description="Manage container master data and wagon assignments."
addLabel="Add Container"
data={query.data}
isLoading={query.isLoading}
create={useCreateContainer()}
update={useUpdateContainer()}
remove={useDeleteContainer()}
searchText={(container) => [container.containerNumber, container.containerTypeId, container.wagonId, container.status].join(' ')}
columns={[
{ key: 'containerNumber', label: 'Number' },
{ key: 'containerTypeId', label: 'Type', render: (container) => optionLabel(containerTypeOptions, container.containerTypeId) },
{ key: 'wagonId', label: 'Wagon', render: (container) => optionLabel(wagonOptions, container.wagonId) },
{ key: 'maxGrossWeight', label: 'Max gross' },
{ key: 'status', label: 'Status', render: (container) => statusBadge(container.status) },
]}
fields={[
{ key: 'containerNumber', label: 'Container number', required: true },
{
key: 'containerTypeId',
label: 'Container type',
type: 'select',
required: true,
options: containerTypeOptions,
},
{
key: 'wagonId',
label: 'Wagon',
type: 'select',
options: [{ value: 'none', label: 'Unassigned' }, ...wagonOptions],
onValueChange: (value) => (value === 'none' ? { wagonId: '' } : {}),
},
{ key: 'position', label: 'Position', type: 'number' },
{ key: 'tareWeight', label: 'Tare weight', type: 'number', required: true },
{ key: 'maxGrossWeight', label: 'Max gross weight', type: 'number', required: true },
{ key: 'sealNumber', label: 'Seal number' },
{ key: 'status', label: 'Status' },
]}
emptyValues={{ containerNumber: '', containerTypeId: '', wagonId: '', position: '', tareWeight: 0, maxGrossWeight: 0, sealNumber: '', status: 'AVAILABLE' }}
/>
);
}
export function CargoesCrudPage() {
const query = useCargoes();
const { data: cargoTypes = [] } = useCargoTypes();
const { data: containers = [] } = useContainers();
const cargoTypeOptions = cargoTypes.map((type: any) => ({
value: type.id,
label: type.cargoTypeName ?? type.cargo_type_name ?? type.name ?? type.code,
}));
const containerOptions = containers.map((container: Container) => ({
value: container.id,
label: container.containerNumber,
}));
return (
<FleetCrudPage<Cargo>
title="Cargoes"
description="Manage cargo records linked to containers."
addLabel="Add Cargo"
data={query.data}
isLoading={query.isLoading}
create={useCreateCargo()}
update={useUpdateCargo()}
remove={useDeleteCargo()}
searchText={(cargo) => [cargo.cargoReference, cargo.description, cargo.containerId, cargo.status].join(' ')}
columns={[
{ key: 'cargoReference', label: 'Reference' },
{ key: 'cargoTypeId', label: 'Cargo type', render: (cargo) => optionLabel(cargoTypeOptions, cargo.cargoTypeId) },
{ key: 'containerId', label: 'Container', render: (cargo) => optionLabel(containerOptions, cargo.containerId) },
{ key: 'quantity', label: 'Quantity' },
{ key: 'weight', label: 'Weight' },
{ key: 'status', label: 'Status', render: (cargo) => statusBadge(cargo.status) },
]}
fields={[
{ key: 'cargoReference', label: 'Cargo reference', required: true },
{ key: 'shipmentId', label: 'Shipment ID', required: true },
{
key: 'containerId',
label: 'Container',
type: 'select',
required: true,
options: containerOptions,
},
{
key: 'cargoTypeId',
label: 'Cargo type',
type: 'select',
options: cargoTypeOptions,
},
{ key: 'description', label: 'Description' },
{ key: 'quantity', label: 'Quantity', type: 'number', required: true },
{ key: 'weight', label: 'Weight', type: 'number', required: true },
{ key: 'volume', label: 'Volume', type: 'number' },
{ key: 'status', label: 'Status' },
]}
emptyValues={{ cargoReference: '', shipmentId: '', containerId: '', cargoTypeId: '', description: '', quantity: 0, weight: 0, volume: '', status: 'PENDING' }}
/>
);
}

View File

@@ -360,17 +360,25 @@ const TrainsPage = () => {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">Booking status</label> <label className="text-sm font-medium">Booking status</label>
<input <Select
className={inputClassName} value={filters.status ?? '__all__'}
placeholder="APPROVED" onValueChange={(value) =>
value={filters.status ?? ''}
onChange={(event) =>
setFilters((current) => ({ setFilters((current) => ({
...current, ...current,
status: event.target.value || undefined, 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>
</div> </div>
</section> </section>
@@ -757,6 +765,7 @@ const TrainsPage = () => {
</div> </div>
); );
}; };
export default TrainsPage;
// export default function TrainsPage() { // export default function TrainsPage() {
// const { data: trains, isLoading } = useTrains(); // const { data: trains, isLoading } = useTrains();
@@ -795,4 +804,4 @@ const TrainsPage = () => {
// </CardContent> // </CardContent>
// </Card> // </Card>
// ); // );
// } // }

View File

@@ -0,0 +1,109 @@
import React, { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@edr/ui-common';
import { useWagonTypes } from '@/hooks/use-wagon-types';
const wagonSchema = z.object({
wagonNumber: z.string().min(1, 'Required'),
wagonTypeId: z.string().min(1, 'Required'),
maxPayloadWeight: z.coerce.number().min(0),
});
type WagonFormValues = z.infer<typeof wagonSchema>;
interface WagonFormProps {
initialValues?: Partial<WagonFormValues>;
onSubmit: (values: WagonFormValues) => void;
}
export function WagonForm({ initialValues, onSubmit }: WagonFormProps) {
const { data: wagonTypes, isLoading: loadingTypes } = useWagonTypes();
const form = useForm<WagonFormValues>({
resolver: zodResolver(wagonSchema),
defaultValues: {
wagonNumber: initialValues?.wagonNumber || '',
wagonTypeId: initialValues?.wagonTypeId || '',
maxPayloadWeight: initialValues?.maxPayloadWeight || 0,
},
});
const selectedTypeId = form.watch('wagonTypeId');
// Autofill maxPayloadWeight when type changes
useEffect(() => {
if (selectedTypeId && wagonTypes) {
const type = wagonTypes.find((t) => t.id === selectedTypeId);
if (type) {
// Only autofill if it's a new selection and field is at default or empty
const currentWeight = form.getValues('maxPayloadWeight');
if (!initialValues?.wagonTypeId || selectedTypeId !== initialValues.wagonTypeId) {
form.setValue('maxPayloadWeight', Number(type.capacityTons));
}
}
}
}, [selectedTypeId, wagonTypes, form, initialValues?.wagonTypeId]);
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="wagonNumber"
render={({ field }) => (
<FormItem>
<FormLabel>Wagon Number</FormLabel>
<FormControl>
<Input placeholder="e.g. W12345" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="wagonTypeId"
render={({ field }) => (
<FormItem>
<FormLabel>Wagon Type</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value} disabled={loadingTypes}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select wagon type" />
</SelectTrigger>
</FormControl>
<SelectContent>
{wagonTypes?.map((type) => (
<SelectItem key={type.id} value={type.id}>
{type.code} - {type.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="maxPayloadWeight"
render={({ field }) => (
<FormItem>
<FormLabel>Max Payload Weight (Tons)</FormLabel>
<FormControl>
<Input type="number" step="0.001" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
);
}

View File

@@ -0,0 +1,15 @@
import { api } from "../auth/http";
type ListResponse<T> = T[] | { data: T[] };
const asList = <T>(payload: ListResponse<T>): T[] =>
Array.isArray(payload) ? payload : payload.data;
export const cargoTypesService = {
async getCargoTypes() {
const response = await api.get<ListResponse<unknown>>('/cargo-types', {
params: { isActive: true, pageSize: 500 },
});
return asList(response.data);
},
};

View File

@@ -19,8 +19,11 @@ export interface Cargo {
export const cargoService = { export const cargoService = {
getAll: () => apiClient.get<Cargo[]>('/cargoes'), getAll: () => apiClient.get<Cargo[]>('/cargoes'),
getById: (id: string) => apiClient.get<Cargo>(`/cargoes/${id}`),
getByContainer: (containerId: string) => apiClient.get<Cargo[]>(`/cargoes?containerId=${containerId}`), getByContainer: (containerId: string) => apiClient.get<Cargo[]>(`/cargoes?containerId=${containerId}`),
create: (data: any) => apiClient.post('/cargoes', data), create: (data: Partial<Cargo>) => apiClient.post('/cargoes', data),
update: (id: string, data: Partial<Cargo>) => apiClient.patch(`/cargoes/${id}`, data),
delete: (id: string) => apiClient.delete(`/cargoes/${id}`),
load: (cargoId: string, quantity: number, weight: number, volume?: number) => load: (cargoId: string, quantity: number, weight: number, volume?: number) =>
apiClient.post(`/cargoes/${cargoId}/load`, { quantity, weight, volume }), apiClient.post(`/cargoes/${cargoId}/load`, { quantity, weight, volume }),
deliver: (cargoId: string) => apiClient.post(`/cargoes/${cargoId}/deliver`), deliver: (cargoId: string) => apiClient.post(`/cargoes/${cargoId}/deliver`),

View File

@@ -0,0 +1,15 @@
import { api } from "../auth/http";
type ListResponse<T> = T[] | { data: T[] };
const asList = <T>(payload: ListResponse<T>): T[] =>
Array.isArray(payload) ? payload : payload.data;
export const containerTypesService = {
async getContainerTypes() {
const response = await api.get<ListResponse<unknown>>('/container-types', {
params: { isActive: true, pageSize: 500 },
});
return asList(response.data);
},
};

View File

@@ -16,8 +16,12 @@ export interface Container {
export const containerService = { export const containerService = {
getAll: () => apiClient.get<Container[]>('/containers'), getAll: () => apiClient.get<Container[]>('/containers'),
getById: (id: string) => apiClient.get<Container>(`/containers/${id}`),
getByWagon: (wagonId: string) => apiClient.get<Container[]>(`/containers?wagonId=${wagonId}`), getByWagon: (wagonId: string) => apiClient.get<Container[]>(`/containers?wagonId=${wagonId}`),
create: (data: Partial<Container>) => apiClient.post('/containers', data),
update: (id: string, data: Partial<Container>) => apiClient.patch(`/containers/${id}`, data),
delete: (id: string) => apiClient.delete(`/containers/${id}`),
assignToWagon: (containerId: string, wagonId: string, position?: number) => assignToWagon: (containerId: string, wagonId: string, position?: number) =>
apiClient.post(`/containers/${containerId}/assign-wagon`, { wagonId, position }), apiClient.post(`/containers/${containerId}/assign-wagon`, { wagonId, position }),
unassign: (containerId: string) => apiClient.post(`/containers/${containerId}/unassign-wagon`), unassign: (containerId: string) => apiClient.post(`/containers/${containerId}/unassign-wagon`),
}; };

View File

@@ -14,7 +14,7 @@ import type {
} from '@/types/trainScheduling'; } from '@/types/trainScheduling';
interface BookingReferenceDataResponse { interface BookingReferenceDataResponse {
yard?: YardOption[]; yard?: Array<YardOption & { label?: string }>;
} }
export const trainSchedulingService = { export const trainSchedulingService = {
@@ -82,6 +82,11 @@ export const trainSchedulingService = {
URL_CONSTANTS.BOOKINGS.REFERENCE_DATA, URL_CONSTANTS.BOOKINGS.REFERENCE_DATA,
); );
const data = unwrap(response.data); const data = unwrap(response.data);
return data.yard ?? []; return (data.yard ?? []).map((yard) => ({
id: yard.id,
name: yard.name ?? yard.label ?? yard.code,
code: yard.code,
country: yard.country,
}));
}, },
}; };

View File

@@ -24,5 +24,5 @@ export const trainService = {
create: (data: Partial<Train>) => apiClient.post('/trains', data), create: (data: Partial<Train>) => apiClient.post('/trains', data),
update: (id: string, data: Partial<Train>) => apiClient.patch(`/trains/${id}`, data), update: (id: string, data: Partial<Train>) => apiClient.patch(`/trains/${id}`, data),
delete: (id: string) => apiClient.delete(`/trains/${id}`), delete: (id: string) => apiClient.delete(`/trains/${id}`),
getDetails: (id: string) => apiClient.get(`/trains/${id}/details`), getDetails: (id: string) => apiClient .get(`/trains/${id}/details`),
}; };

View File

@@ -0,0 +1,13 @@
import { api } from "../auth/http";
type ListResponse<T> = T[] | { data: T[] };
const asList = <T>(payload: ListResponse<T>): T[] =>
Array.isArray(payload) ? payload : payload.data;
export const wagonTypesService = {
async getWagonTypes() {
const response = await api.get<ListResponse<unknown>>('/wagon-types');
return asList(response.data);
},
};

View File

@@ -14,13 +14,14 @@ export interface Wagon {
export const wagonService = { export const wagonService = {
getAll: () => apiClient.get<Wagon[]>('/wagons'), getAll: () => apiClient.get<Wagon[]>('/wagons'),
getById: (id: string) => apiClient.get<Wagon>(`/wagons/${id}`),
getByTrain: (trainId: string) => apiClient.get<Wagon[]>(`/wagons?trainId=${trainId}`), getByTrain: (trainId: string) => apiClient.get<Wagon[]>(`/wagons?trainId=${trainId}`),
assignToTrain: (wagonId: string, trainId: string, sequenceNumber?: number) => assignToTrain: (wagonId: string, trainId: string, sequenceNumber?: number) =>
apiClient.post(`/wagons/${wagonId}/assign-train`, { trainId, sequenceNumber }), apiClient.post(`/wagons/${wagonId}/assign-train`, { trainId, sequenceNumber }),
unassign: (wagonId: string) => apiClient.post(`/wagons/${wagonId}/unassign-train`), unassign: (wagonId: string) => apiClient.post(`/wagons/${wagonId}/unassign-train`),
reorder: (trainId: string, wagonIds: string[]) => reorder: (trainId: string, wagonIds: string[]) =>
apiClient.post(`/trains/${trainId}/reorder-wagons`, { wagonIds }), apiClient.post(`/trains/${trainId}/reorder-wagons`, { wagonIds }),
create: (data: Partial<Wagon>) => apiClient.post('/wagons', data), create: (data: Partial<Wagon>) => apiClient.post('/wagons', data),
update: (id: string, data: Partial<Wagon>) => apiClient.patch(`/wagons/${id}`, data), update: (id: string, data: Partial<Wagon>) => apiClient.patch(`/wagons/${id}`, data),
delete: (id: string) => apiClient.delete(`/wagons/${id}`),
}; };

View File

@@ -88,12 +88,12 @@ export const URL_CONSTANTS = {
BOOKINGS: { BOOKINGS: {
BASE: "/bookings", BASE: "/bookings",
BY_ID: (id: string | number) => `/bookings/${id}`, BY_ID: (id: string | number) => `/api/bookings/${id}`,
CONTRACT_VIEW: (id: string) => `/bookings/${id}/contract/view`, CONTRACT_VIEW: (id: string) => `/api/bookings/${id}/contract/view`,
CONTRACT_DOCUMENT: (id: string) => `/bookings/${id}/contract/document`, CONTRACT_DOCUMENT: (id: string) => `/api/bookings/${id}/contract/document`,
CONTRACT_SIGN: (id: string) => `/bookings/${id}/contract/sign`, CONTRACT_SIGN: (id: string) => `/api/bookings/${id}/contract/sign`,
CONTRACT_DOWNLOAD: (id: string) => `/bookings/${id}/contract`, CONTRACT_DOWNLOAD: (id: string) => `/api/bookings/${id}/contract`,
CANCEL: (id: string | number) => `/bookings/${id}/cancel`, CANCEL: (id: string | number) => `/api/bookings/${id}/cancel`,
CONFIRM: (id: string | number) => `/bookings/${id}/confirm`, CONFIRM: (id: string | number) => `/api/bookings/${id}/confirm`,
}, },
}; };

View File

@@ -172,7 +172,6 @@ const useAuth = () => {
}); });
localStorage.clear(); localStorage.clear();
queryClient.clear(); queryClient.clear();
window.location.href = "/login";
}; };
return { return {

View File

@@ -1,4 +1,4 @@
import { useCallback, useState } from "react"; import { useCallback, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
@@ -21,6 +21,7 @@ export default function BookingContractPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const qc = useQueryClient(); const qc = useQueryClient();
const iframeRef = useRef<HTMLIFrameElement>(null);
const [signOpen, setSignOpen] = useState(false); const [signOpen, setSignOpen] = useState(false);
const [signerName, setSignerName] = useState(""); const [signerName, setSignerName] = useState("");
const [signatureData, setSignatureData] = useState<string | null>(null); const [signatureData, setSignatureData] = useState<string | null>(null);
@@ -58,6 +59,12 @@ export default function BookingContractPage() {
} }
}, [id, data?.reference]); }, [id, data?.reference]);
const handlePrint = useCallback(() => {
if (iframeRef.current?.contentWindow) {
iframeRef.current.contentWindow.print();
}
}, []);
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex min-h-[40vh] items-center justify-center"> <div className="flex min-h-[40vh] items-center justify-center">
@@ -77,8 +84,6 @@ export default function BookingContractPage() {
); );
} }
const bodyHtml = extractBodyHtml(data.html);
return ( return (
<div className="min-h-screen bg-muted/30 p-4 md:p-8"> <div className="min-h-screen bg-muted/30 p-4 md:p-8">
<div className="mx-auto max-w-4xl"> <div className="mx-auto max-w-4xl">
@@ -88,7 +93,7 @@ export default function BookingContractPage() {
Back to booking Back to booking
</Button> </Button>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={() => window.print()}> <Button variant="outline" size="sm" onClick={handlePrint}>
<Printer className="mr-2 size-4" /> <Printer className="mr-2 size-4" />
Print Print
</Button> </Button>
@@ -105,9 +110,12 @@ export default function BookingContractPage() {
</div> </div>
</div> </div>
<article <iframe
className="contract-document rounded-lg border bg-white p-6 shadow-sm print:shadow-none md:p-10" ref={iframeRef}
dangerouslySetInnerHTML={{ __html: bodyHtml }} srcDoc={data.html}
className="w-full rounded-lg border bg-white shadow-sm"
style={{ minHeight: "80vh" }}
title="Contract document"
/> />
</div> </div>
@@ -158,8 +166,3 @@ export default function BookingContractPage() {
</div> </div>
); );
} }
function extractBodyHtml(fullHtml: string): string {
const match = fullHtml.match(/<body[^>]*>([\s\S]*)<\/body>/i);
return match ? match[1] : fullHtml;
}

View File

@@ -844,28 +844,7 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
</CardHeader> </CardHeader>
</Card> </Card>
{(booking.status === "CONTRACT_READY" || {renderContractCard(booking, navigate)}
booking.status === "SIGNED_CUSTOMER" ||
booking.status === "FULLY_EXECUTED") && (
<Card className="border-primary/30 bg-primary/5">
<CardContent className="flex flex-col gap-4 p-6 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="font-semibold text-foreground">Contract ready</p>
<p className="text-sm text-muted-foreground">
Review the agreement and apply your digital signature.
</p>
</div>
<button
type="button"
className="inline-flex items-center justify-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground"
onClick={() => navigate(`/bookings/${booking.id}/contract`)}
>
<FileSignature className="size-4" />
View &amp; sign contract
</button>
</CardContent>
</Card>
)}
<Card> <Card>
<CardHeader> <CardHeader>
@@ -1218,6 +1197,78 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
); );
} }
function renderContractCard(
booking: Freight.IBooking,
navigate: ReturnType<typeof useNavigate>,
) {
const s = booking.status;
if (
s !== "APPROVED_PENDING_SIGNATURE" &&
s !== "CONTRACT_READY" &&
s !== "SIGNED_CUSTOMER" &&
s !== "FULLY_EXECUTED"
) {
return null;
}
const config: Record<
string,
{ title: string; description: string; buttonLabel?: string }
> = {
APPROVED_PENDING_SIGNATURE: {
title: "Contract being prepared",
description:
"Your booking has been approved. The contract is being generated and will be available shortly.",
},
CONTRACT_READY: {
title: "Contract ready for signature",
description:
"Review the agreement and apply your digital signature.",
buttonLabel: "View & sign contract",
},
SIGNED_CUSTOMER: {
title: "You have signed the contract",
description:
"Your signature has been submitted. Awaiting staff signature to finalize.",
buttonLabel: "View contract",
},
FULLY_EXECUTED: {
title: "Contract fully executed",
description:
"The contract has been fully signed and executed by all parties.",
buttonLabel: "View contract",
},
};
const c = config[s];
return (
<Card
className={cn(
"border-primary/30 bg-primary/5",
s === "FULLY_EXECUTED" && "border-emerald-300 bg-emerald-50",
)}
>
<CardContent className="flex flex-col gap-4 p-6 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="font-semibold text-foreground">{c.title}</p>
<p className="text-sm text-muted-foreground">{c.description}</p>
</div>
{c.buttonLabel && (
<button
type="button"
className="inline-flex items-center justify-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground"
onClick={() => navigate(`/bookings/${booking.id}/contract`)}
>
<FileSignature className="size-4" />
{c.buttonLabel}
</button>
)}
</CardContent>
</Card>
);
}
function RouteEndpoint({ function RouteEndpoint({
label, label,
station, station,

View File

@@ -1,7 +1,4 @@
import { import { UseQueryOptions, QueryObserverOptions } from "@tanstack/react-query";
UseQueryOptions,
QueryObserverOptions,
} from "@tanstack/react-query";
import axios, { AxiosError, InternalAxiosRequestConfig } from "axios"; import axios, { AxiosError, InternalAxiosRequestConfig } from "axios";
import { URL_CONSTANTS } from "@/constants/URLS"; import { URL_CONSTANTS } from "@/constants/URLS";
@@ -23,11 +20,15 @@ function setCookie(name: string, value: string, days: number) {
} }
function clearAuthCookies() { function clearAuthCookies() {
["auth-token", "refresh-token", "auth-user", "current-position-id", "selected-position-id"].forEach( [
(name) => { "auth-token",
document.cookie = `${name}=; Max-Age=0; path=/`; "refresh-token",
}, "auth-user",
); "current-position-id",
"selected-position-id",
].forEach((name) => {
document.cookie = `${name}=; Max-Age=0; path=/`;
});
} }
// Attach auth token to every request // Attach auth token to every request
@@ -96,17 +97,13 @@ client.interceptors.response.use(
if (!refreshToken) { if (!refreshToken) {
isRefreshing = false; isRefreshing = false;
clearAuthCookies(); clearAuthCookies();
if (window.location.pathname !== "/login") {
window.location.href = "/login";
}
return Promise.reject(error); return Promise.reject(error);
} }
try { try {
const { data } = await client.post<{ data: { token: string; refreshToken: string } }>( const { data } = await client.post<{
URL_CONSTANTS.AUTH.REFRESH_TOKEN, data: { token: string; refreshToken: string };
{ refreshToken }, }>(URL_CONSTANTS.AUTH.REFRESH_TOKEN, { refreshToken });
);
const { token, refreshToken: newRefreshToken } = data.data; const { token, refreshToken: newRefreshToken } = data.data;
setCookie("auth-token", token, 7); setCookie("auth-token", token, 7);
setCookie("refresh-token", newRefreshToken, 7); setCookie("refresh-token", newRefreshToken, 7);

10
cargo-types.service.ts Normal file
View File

@@ -0,0 +1,10 @@
import axios from 'axios';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
export const cargoTypesService = {
async getCargoTypes() {
const { data } = await axios.get(`${API_URL}/api/cargo-types`);
return data;
},
};

View File

@@ -0,0 +1,10 @@
import axios from 'axios';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
export const containerTypesService = {
async getContainerTypes() {
const { data } = await axios.get(`${API_URL}/api/container-types`);
return data;
},
};

3551
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

12
use-cargo-types.ts Normal file
View File

@@ -0,0 +1,12 @@
import { useQuery } from '@tanstack/react-query';
import { cargoTypesService } from '@/services/cargo-types.service';
export const CARGO_TYPES_QUERY_KEY = ['cargo-types'];
export function useCargoTypes() {
return useQuery({
queryKey: CARGO_TYPES_QUERY_KEY,
queryFn: () => cargoTypesService.getCargoTypes(),
staleTime: Infinity,
});
}

0
use-cargoes.ts Normal file
View File

12
use-container-types.ts Normal file
View File

@@ -0,0 +1,12 @@
import { useQuery } from '@tanstack/react-query';
import { containerTypesService } from '@/services/container-types.service';
export const CONTAINER_TYPES_QUERY_KEY = ['container-types'];
export function useContainerTypes() {
return useQuery({
queryKey: CONTAINER_TYPES_QUERY_KEY,
queryFn: () => containerTypesService.getContainerTypes(),
staleTime: Infinity,
});
}

12
use-wagon-types.ts Normal file
View File

@@ -0,0 +1,12 @@
import { useQuery } from '@tanstack/react-query';
import { wagonTypesService } from '@/services/wagon-types.service';
export const WAGON_TYPES_QUERY_KEY = ['wagon-types'];
export function useWagonTypes() {
return useQuery({
queryKey: WAGON_TYPES_QUERY_KEY,
queryFn: () => wagonTypesService.getWagonTypes(),
staleTime: Infinity,
});
}

0
wagon-type.entity.ts Normal file
View File

Some files were not shown because too many files have changed in this diff Show More