mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
changes
This commit is contained in:
0
WagonForm.tsx
Normal file
0
WagonForm.tsx
Normal file
@@ -24,8 +24,8 @@
|
||||
"@nestjs/platform-express": "^11.0.0",
|
||||
"@nestjs/swagger": "^11.4.2",
|
||||
"@nestjs/typeorm": "^11.0.1",
|
||||
"@tria-plc/api-common": "^0.1.4",
|
||||
"@tria-plc/iamapi-common": "^0.1.6",
|
||||
"@tria-plc/api-common": "^1.4.0",
|
||||
"@tria-plc/iamapi-common": "^0.5.1",
|
||||
"amqp-connection-manager": "^5.0.0",
|
||||
"amqplib": "^2.0.1",
|
||||
"axios": "^1.16.1",
|
||||
@@ -34,8 +34,8 @@
|
||||
"dotenv": "^17.4.2",
|
||||
"handlebars": "^4.7.9",
|
||||
"minio": "7.1.3",
|
||||
"puppeteer": "^24.2.0",
|
||||
"pg": "^8.13.0",
|
||||
"puppeteer": "^24.2.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
@@ -52,6 +52,7 @@
|
||||
"@types/jest": "^29.5.13",
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^20.14.0",
|
||||
"@types/pg": "^8.6.7",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"jest": "^29.7.0",
|
||||
"supertest": "^7.0.0",
|
||||
|
||||
@@ -75,7 +75,6 @@ import { CargoesModule } from './modules/cargoes/cargoes.module';
|
||||
BookingsModule,
|
||||
FilesModule,
|
||||
ConsignmentsModule,
|
||||
TrainsModule,
|
||||
LocomotivesModule,
|
||||
WagonTypesModule,
|
||||
TrainSetsModule,
|
||||
|
||||
@@ -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;`);
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CreateCargoDto } from './dto/create-cargo.dto';
|
||||
@@ -28,8 +29,8 @@ export class CargoesController {
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List all cargoes' })
|
||||
findAll() {
|
||||
return this.cargoesService.findAll();
|
||||
findAll(@Query() query: Record<string, string | undefined>) {
|
||||
return this.cargoesService.findAll(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@@ -67,4 +68,4 @@ export class CargoesController {
|
||||
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto?: DeliverCargoDto) {
|
||||
return this.cargoesService.deliverCargo(id, dto);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,14 @@ import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Cargo } from './entities/cargoes.entity';
|
||||
import { Container } from '../container-management/entities/container.entity';
|
||||
import { CargoType } from '../rule-engine/entities/cargo-type.entity';
|
||||
import { CargoesController } from './cargoes.controller';
|
||||
import { CargoesService } from './cargoes.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Cargo, Container])],
|
||||
imports: [TypeOrmModule.forFeature([Cargo, Container, CargoType])],
|
||||
controllers: [CargoesController],
|
||||
providers: [CargoesService],
|
||||
exports: [CargoesService],
|
||||
})
|
||||
export class CargoesModule {}
|
||||
export class CargoesModule {}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { FindOptionsOrder, FindOptionsWhere, ILike, Repository } from 'typeorm';
|
||||
import { CreateCargoDto } from './dto/create-cargo.dto';
|
||||
import { UpdateCargoDto } from './dto/update-cargo.dto';
|
||||
import { LoadCargoDto } from './dto/load-cargo.dto';
|
||||
import { DeliverCargoDto } from './dto/deliver-cargo.dto';
|
||||
import { Cargo } from './entities/cargoes.entity';
|
||||
import { Container } from '../container-management/entities/container.entity';
|
||||
import { CargoType } from '../rule-engine/entities/cargo-type.entity';
|
||||
|
||||
@Injectable()
|
||||
export class CargoesService {
|
||||
@@ -15,15 +16,64 @@ export class CargoesService {
|
||||
private readonly cargoRepo: Repository<Cargo>,
|
||||
@InjectRepository(Container)
|
||||
private readonly containerRepo: Repository<Container>,
|
||||
@InjectRepository(CargoType)
|
||||
private readonly cargoTypeRepo: Repository<CargoType>,
|
||||
) {}
|
||||
|
||||
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);
|
||||
return this.cargoRepo.save(cargo);
|
||||
}
|
||||
|
||||
async findAll(): Promise<Cargo[]> {
|
||||
return this.cargoRepo.find({ order: { cargoReference: 'ASC' } });
|
||||
async findAll(query: Record<string, string | undefined> = {}): Promise<Cargo[]> {
|
||||
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> {
|
||||
@@ -34,6 +84,24 @@ export class CargoesService {
|
||||
|
||||
async update(id: string, dto: UpdateCargoDto): Promise<Cargo> {
|
||||
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);
|
||||
return this.cargoRepo.save(cargo);
|
||||
}
|
||||
@@ -101,4 +169,4 @@ export class CargoesService {
|
||||
|
||||
return this.cargoRepo.save(cargo);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,21 @@ export class Company extends BaseEntity {
|
||||
@Column({ name: 'email', type: 'varchar', length: 150, nullable: true })
|
||||
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 })
|
||||
website?: string | null;
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CreateContainerDto } from './dto/create-container.dto';
|
||||
@@ -27,8 +28,8 @@ export class ContainersController {
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List all containers' })
|
||||
findAll() {
|
||||
return this.containersService.findAll();
|
||||
findAll(@Query() query: Record<string, string | undefined>) {
|
||||
return this.containersService.findAll(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@@ -60,4 +61,4 @@ export class ContainersController {
|
||||
unassignFromWagon(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.containersService.unassignFromWagon(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,13 @@ import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Container } from './entities/container.entity';
|
||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||
import { ContainersController } from './containers.controller';
|
||||
import { ContainersService } from './containers.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Container, Wagon])], // ✅ add Wagon
|
||||
imports: [TypeOrmModule.forFeature([Container, Wagon, ContainerType])],
|
||||
controllers: [ContainersController],
|
||||
providers: [ContainersService],
|
||||
})
|
||||
export class ContainersModule {}
|
||||
export class ContainersModule {}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
// apps/edr-freight-api/src/modules/container-management/containers.service.ts
|
||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { FindOptionsOrder, FindOptionsWhere, ILike, Repository } from 'typeorm';
|
||||
import { CreateContainerDto } from './dto/create-container.dto';
|
||||
import { UpdateContainerDto } from './dto/update-container.dto';
|
||||
import { AssignContainerToWagonDto } from './dto/assign-container-to-wagon.dto';
|
||||
import { Container } from './entities/container.entity';
|
||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||
|
||||
@Injectable()
|
||||
export class ContainersService {
|
||||
@@ -15,17 +16,61 @@ export class ContainersService {
|
||||
private readonly containerRepo: Repository<Container>,
|
||||
@InjectRepository(Wagon)
|
||||
private readonly wagonRepo: Repository<Wagon>, // ✅ use raw repository
|
||||
@InjectRepository(ContainerType)
|
||||
private readonly containerTypeRepo: Repository<ContainerType>,
|
||||
) {}
|
||||
|
||||
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);
|
||||
if (dto.wagonId === undefined) container.wagonId = null;
|
||||
if (dto.position === undefined) container.position = null;
|
||||
return this.containerRepo.save(container);
|
||||
}
|
||||
|
||||
async findAll(): Promise<Container[]> {
|
||||
return this.containerRepo.find({ order: { containerNumber: 'ASC' } });
|
||||
async findAll(query: Record<string, string | undefined> = {}): Promise<Container[]> {
|
||||
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> {
|
||||
@@ -36,9 +81,27 @@ export class ContainersService {
|
||||
|
||||
async update(id: string, dto: UpdateContainerDto): Promise<Container> {
|
||||
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);
|
||||
if (dto.wagonId === undefined) container.wagonId = null;
|
||||
if (dto.position === undefined) container.position = null;
|
||||
return this.containerRepo.save(container);
|
||||
}
|
||||
|
||||
@@ -82,4 +145,4 @@ export class ContainersService {
|
||||
container.status = 'AVAILABLE';
|
||||
return this.containerRepo.save(container);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,10 +61,10 @@ export class CustomersController {
|
||||
return this.customersService.findById(id);
|
||||
}
|
||||
|
||||
@Get("user/:userId")
|
||||
findByUserId(@Param("userId", ParseUUIDPipe) userId: string): Promise<any> {
|
||||
return this.customersService.findByUserId(userId);
|
||||
}
|
||||
// @Get("user/:userId")
|
||||
// findByUserId(@Param("userId", ParseUUIDPipe) userId: string): Promise<any> {
|
||||
// return this.customersService.findByUserId(userId);
|
||||
// }
|
||||
|
||||
@Patch(":id")
|
||||
@ApiOperation({ summary: "Update a customer" })
|
||||
|
||||
@@ -52,15 +52,15 @@ export class CustomersService {
|
||||
return customer;
|
||||
}
|
||||
|
||||
async findByUserId(userId: string): Promise<Customer> {
|
||||
const customer = await this.customersRepository.findByUserId(userId);
|
||||
// async findByUserId(userId: string): Promise<Customer> {
|
||||
// const customer = await this.customersRepository.findByUserId(userId);
|
||||
|
||||
if (!customer) {
|
||||
throw new NotFoundException(`Customer with ID ${userId} not found`);
|
||||
}
|
||||
// if (!customer) {
|
||||
// throw new NotFoundException(`Customer with ID ${userId} not found`);
|
||||
// }
|
||||
|
||||
return customer;
|
||||
}
|
||||
// return customer;
|
||||
//}
|
||||
|
||||
/** Get customer by email */
|
||||
async findByEmail(email: string): Promise<Customer> {
|
||||
@@ -100,16 +100,16 @@ export class CustomersService {
|
||||
throw new BadRequestException("VAT number must be exactly 10 digits");
|
||||
}
|
||||
|
||||
// Check email conflict
|
||||
if (dto.email) {
|
||||
const existing = await this.customersRepository.findByEmail(dto.email);
|
||||
// // Check email conflict
|
||||
// if (dto.email) {
|
||||
// const existing = await this.customersRepository.findByEmail(dto.email);
|
||||
|
||||
if (existing && existing.userId !== id) {
|
||||
throw new ConflictException(
|
||||
`Customer with email "${dto.email}" already exists`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// // if (existing && existing.userId !== id) {
|
||||
// // throw new ConflictException(
|
||||
// // `Customer with email "${dto.email}" already exists`,
|
||||
// // );
|
||||
// // }
|
||||
// }
|
||||
|
||||
const updated = await this.customersRepository.update(id, dto);
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { Customer } from '../entities/customer.entity';
|
||||
|
||||
export class ResponseCustomerDto {
|
||||
UserId: string;
|
||||
//UserId: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
@@ -30,7 +30,7 @@ export class ResponseCustomerDto {
|
||||
updatedAt: Date;
|
||||
|
||||
constructor(customer: Customer) {
|
||||
this.UserId = customer.userId;
|
||||
//this.UserId = customer.userId;
|
||||
this.firstName = customer.firstName;
|
||||
this.lastName = customer.lastName;
|
||||
this.email = customer.email;
|
||||
|
||||
@@ -3,12 +3,12 @@ import { Column, Entity, Index } from 'typeorm';
|
||||
|
||||
@Entity({ schema: 'freight', name: 'customers' })
|
||||
@Index(['email'])
|
||||
@Index(['userId'])
|
||||
//@Index(['userId'])
|
||||
@Index(['tinNumber'])
|
||||
@Index(['fanNumber'])
|
||||
export class Customer extends BaseEntity {
|
||||
@Column({ name: 'user_id', type: 'uuid' })
|
||||
userId!: string;
|
||||
//@Column({ name: 'user_id', type: 'uuid' })
|
||||
//userId!: string;
|
||||
|
||||
@Column({ name: 'first_name', type: 'varchar', length: 100 })
|
||||
firstName!: string;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { IsString } from "class-validator";
|
||||
|
||||
export class InitiateBookingPayment {
|
||||
@IsString()
|
||||
bookingId!: string;
|
||||
}
|
||||
@@ -4,11 +4,6 @@ import { Public } from "@edr/api-common";
|
||||
import { randomUUID } from "crypto";
|
||||
import { Response } from "express"
|
||||
|
||||
// import * as fs from 'fs';
|
||||
// import * as path from 'path';
|
||||
// import Handlebars from 'handlebars';
|
||||
|
||||
|
||||
@Public()
|
||||
@Controller("payments")
|
||||
export class PaymentController {
|
||||
@@ -21,13 +16,12 @@ export class PaymentController {
|
||||
return res.send(filled)
|
||||
}
|
||||
|
||||
@Post("/initiate")
|
||||
@Post("/initiate/booking")
|
||||
async initiatePayment() {
|
||||
|
||||
//Only for testing..
|
||||
const redirectBaseURL = "http://localhost:3004/payment"
|
||||
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, _) => {
|
||||
resp({
|
||||
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)
|
||||
// }
|
||||
// }
|
||||
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
clientAction: ClientAction,
|
||||
status: PaymentEntity["status"],
|
||||
@@ -43,9 +43,16 @@ export class PaymentService {
|
||||
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({
|
||||
redirectBaseURL,
|
||||
redirectUrl,
|
||||
amountMinor: amount,
|
||||
currency: currency,
|
||||
merchantOrderId: orderId,
|
||||
@@ -104,7 +111,7 @@ export class PaymentService {
|
||||
throw new BadRequestException()
|
||||
}
|
||||
|
||||
const filePath = path.join(__dirname, "templates", "ceiepts.hbs");
|
||||
const filePath = path.join(__dirname, "templates", "receipt.hbs");
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new InternalServerErrorException()
|
||||
}
|
||||
|
||||
@@ -22,8 +22,7 @@ export class PaymentTelebirrStrategy implements PaymentStrategy {
|
||||
async pay(data: ProviderInitiationInput): Promise<any> {
|
||||
// const refId = randomUUID()
|
||||
// const orderId = createMerchantOrderId()
|
||||
const redirectURL = `${data.redirectBaseURL}/${data.merchantOrderId}`
|
||||
const resp = await this.initiate(redirectURL, data)
|
||||
const resp = await this.initiate(data)
|
||||
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 requestBody = this.buildCreateOrderRequest(redirectURL, input);
|
||||
const requestBody = this.buildCreateOrderRequest(input);
|
||||
const response = await this.requestCreateOrder(fabricToken, requestBody);
|
||||
|
||||
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)
|
||||
const req = {
|
||||
@@ -188,7 +187,7 @@ export class PaymentTelebirrStrategy implements PaymentStrategy {
|
||||
biz_content: {
|
||||
notify_url: this.notifyUrl,
|
||||
appid: this.merchantAppId,
|
||||
redirect_url: redirectURL,
|
||||
redirect_url: input.redirectUrl,
|
||||
merch_code: this.merchantCode,
|
||||
merch_order_id: input.merchantOrderId,
|
||||
trade_type: 'Checkout' as const,
|
||||
|
||||
@@ -10,7 +10,7 @@ export type ClientAction =
|
||||
| { type: 'LAUNCH_APP'; prepayId: string; receiveCode?: string; shortCode: string };
|
||||
|
||||
export interface ProviderInitiationInput {
|
||||
redirectBaseURL: string;
|
||||
redirectUrl: string;
|
||||
merchantOrderId: string;
|
||||
// bookingRef: string;
|
||||
amountMinor: number;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
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 {
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@@ -19,5 +21,6 @@ export class GetEligibleContainerBookingsDto {
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsIn(BOOKING_STATUSES)
|
||||
status?: string;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { CreateTrainDto } from "./dto/create-train.dto";
|
||||
import { UpdateTrainDto } from "./dto/update-train.dto";
|
||||
import { TrainsService } from "./trains.service";
|
||||
|
||||
@ApiTags("trains")
|
||||
@@ -24,8 +28,8 @@ export class TrainsController {
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: "List all trains" })
|
||||
findAll() {
|
||||
return this.trainsService.findAll();
|
||||
findAll(@Query() query: Record<string, string | undefined>) {
|
||||
return this.trainsService.findAll(query);
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@@ -33,4 +37,16 @@ export class TrainsController {
|
||||
findOne(@Param("id", ParseUUIDPipe) id: string) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { FindOptionsOrder, FindOptionsWhere, ILike, Repository } from 'typeorm';
|
||||
import { CreateTrainDto } from './dto/create-train.dto';
|
||||
import { UpdateTrainDto } from './dto/update-train.dto';
|
||||
import { Train } from './entities/train.entity';
|
||||
@@ -17,8 +17,28 @@ export class TrainsService {
|
||||
return this.trainRepo.save(train);
|
||||
}
|
||||
|
||||
findAll(): Promise<Train[]> {
|
||||
return this.trainRepo.find({ order: { code: 'ASC' } });
|
||||
findAll(query: Record<string, string | undefined> = {}): Promise<Train[]> {
|
||||
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> {
|
||||
@@ -38,4 +58,4 @@ export class TrainsService {
|
||||
const train = await this.findById(id);
|
||||
await this.trainRepo.remove(train);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,13 @@ import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { WagonType } from './entities/wagon-type.entity';
|
||||
import { WagonTypesController } from './wagon-types.controller';
|
||||
import { WagonTypesRepository } from './wagon-types.repository';
|
||||
import { WagonTypesService } from './wagon-types.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([WagonType])],
|
||||
controllers: [WagonTypesController],
|
||||
providers: [WagonTypesRepository, WagonTypesService],
|
||||
exports: [WagonTypesRepository, WagonTypesService],
|
||||
})
|
||||
|
||||
@@ -7,6 +7,13 @@ import { WagonTypesRepository } from './wagon-types.repository';
|
||||
export class WagonTypesService {
|
||||
constructor(private readonly wagonTypesRepository: WagonTypesRepository) {}
|
||||
|
||||
async findAll(): Promise<WagonType[]> {
|
||||
return this.wagonTypesRepository.findAll({
|
||||
where: { isActive: true },
|
||||
order: { code: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findByCode(code: string): Promise<WagonType> {
|
||||
const [wagonType] = await this.wagonTypesRepository.findAll({ where: { code } });
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CreateWagonDto } from './dto/create-wagon.dto';
|
||||
@@ -28,8 +29,8 @@ export class WagonsController {
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List all wagons' })
|
||||
findAll() {
|
||||
return this.wagonsService.findAll();
|
||||
findAll(@Query() query: Record<string, string | undefined>) {
|
||||
return this.wagonsService.findAll(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@@ -73,4 +74,4 @@ export class TrainWagonsReorderController {
|
||||
reorder(@Param('trainId', ParseUUIDPipe) trainId: string, @Body() dto: ReorderWagonsDto) {
|
||||
return this.wagonsService.reorderWagons(trainId, dto);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
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 { UpdateWagonDto } from './dto/update-wagon.dto';
|
||||
import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.dto';
|
||||
@@ -26,8 +26,31 @@ export class WagonsService {
|
||||
return this.wagonRepo.save(wagon);
|
||||
}
|
||||
|
||||
async findAll(): Promise<Wagon[]> {
|
||||
return this.wagonRepo.find({ order: { wagonNumber: 'ASC' } });
|
||||
async findAll(query: Record<string, string | undefined> = {}): Promise<Wagon[]> {
|
||||
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> {
|
||||
@@ -39,8 +62,6 @@ export class WagonsService {
|
||||
async update(id: string, dto: UpdateWagonDto): Promise<Wagon> {
|
||||
const wagon = await this.findById(id);
|
||||
Object.assign(wagon, dto);
|
||||
if (dto.trainId === undefined) wagon.trainId = null;
|
||||
if (dto.sequenceNumber === undefined) wagon.sequenceNumber = null;
|
||||
return this.wagonRepo.save(wagon);
|
||||
}
|
||||
|
||||
@@ -98,4 +119,4 @@ export class WagonsService {
|
||||
await queryRunner.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
25
apps/edr-freight-api/src/scripts/create-freight-schema.js
Normal file
25
apps/edr-freight-api/src/scripts/create-freight-schema.js
Normal 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);
|
||||
}
|
||||
})();
|
||||
27
apps/edr-freight-api/src/scripts/create-freight-schema.ts
Normal file
27
apps/edr-freight-api/src/scripts/create-freight-schema.ts
Normal 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();
|
||||
21
apps/edr-freight-api/src/scripts/run-migrations.ts
Normal file
21
apps/edr-freight-api/src/scripts/run-migrations.ts
Normal 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();
|
||||
@@ -4,7 +4,11 @@ import { DataSource } from "typeorm";
|
||||
|
||||
import { BookingContainer } from "../modules/bookings/entities/booking-container.entity";
|
||||
import { Booking } from "../modules/bookings/entities/booking.entity";
|
||||
import { Customer } from "../modules/customers/entities/customer.entity";
|
||||
import {
|
||||
Company,
|
||||
CompanyStatus,
|
||||
CompanyType,
|
||||
} from "../modules/companies/entities/company.entity";
|
||||
import { Locomotive } from "../modules/locomotives/entities/locomotive.entity";
|
||||
import { ServiceType } from "../modules/rule-engine/entities/service-type.entity";
|
||||
import { Yard } from "../modules/rule-engine/entities/yard.entity";
|
||||
@@ -14,7 +18,8 @@ import { ContainerType } from "../modules/rule-engine/entities/container-type.en
|
||||
const SEED_FLAG = "SEED_DEMO_BOOKINGS";
|
||||
|
||||
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 = [
|
||||
{ code: "DJIBOUTI", label: "Djibouti", country: "Djibouti", displayOrder: 1 },
|
||||
@@ -183,39 +188,35 @@ export class DemoBookingsSeeder {
|
||||
{ conflictPaths: { code: true } },
|
||||
);
|
||||
|
||||
await manager.getRepository(Customer).upsert(
|
||||
await manager.getRepository(Company).upsert(
|
||||
{
|
||||
userId: "00000000-0000-0000-0000-000000000111",
|
||||
firstName: "Train",
|
||||
lastName: "Scheduling",
|
||||
email: CUSTOMER_EMAIL,
|
||||
phone: "251900000001",
|
||||
companyName: "Train Scheduling Demo Customer",
|
||||
companyEmail: CUSTOMER_EMAIL,
|
||||
companyPhone: "251900000001",
|
||||
companyLocation: "Addis Ababa",
|
||||
companyAddress: "Demo Address",
|
||||
customerType: "DEMO",
|
||||
status: "ACTIVE",
|
||||
contactPersonName: "Train Scheduling",
|
||||
contactPersonPhone: "251900000001",
|
||||
tinNumber: "1234567890",
|
||||
name: "Train Scheduling Demo Customer",
|
||||
type: CompanyType.Customer,
|
||||
status: CompanyStatus.Active,
|
||||
tin: COMPANY_TIN,
|
||||
vatNumber: "1234567890",
|
||||
fanNumber: "1234567890123456",
|
||||
country: "Ethiopia",
|
||||
address: "Demo Address",
|
||||
phone: "251900000001",
|
||||
email: COMPANY_EMAIL,
|
||||
website: null,
|
||||
contactPersonName: "Train Scheduling",
|
||||
contactPersonPhone: "251900000001",
|
||||
generalManagerName: "Demo Manager",
|
||||
generalManagerEmail: CUSTOMER_EMAIL,
|
||||
generalManagerEmail: COMPANY_EMAIL,
|
||||
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
|
||||
.getRepository(ServiceType)
|
||||
.findOneByOrFail({ code: SERVICE_TYPE_CODE }),
|
||||
manager
|
||||
.getRepository(Customer)
|
||||
.findOneByOrFail({ email: CUSTOMER_EMAIL }),
|
||||
.getRepository(Company)
|
||||
.findOneByOrFail({ tin: COMPANY_TIN }),
|
||||
manager.getRepository(Yard).find(),
|
||||
manager.getRepository(ContainerType).find(),
|
||||
]);
|
||||
@@ -247,7 +248,7 @@ export class DemoBookingsSeeder {
|
||||
await manager.getRepository(Booking).upsert(
|
||||
{
|
||||
reference: demoBooking.reference,
|
||||
companyId: customer.id,
|
||||
companyId: company.id,
|
||||
status: "APPROVED",
|
||||
scheduledDate: new Date(demoBooking.scheduledDate),
|
||||
totalAmount: 0,
|
||||
|
||||
@@ -17,8 +17,9 @@
|
||||
"@mantine/core": "^9.3.0",
|
||||
"@mantine/hooks": "^9.3.0",
|
||||
"@tabler/icons-react": "^3.44.0",
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"@tanstack/react-query": "^5.100.11",
|
||||
"@tria-plc/iamui-common": "1.1.1",
|
||||
"@tria-plc/iamui-common": "1.1.2",
|
||||
"axios": "^1.7.7",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
@@ -24,7 +24,7 @@ import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
|
||||
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
|
||||
import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page";
|
||||
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 PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage";
|
||||
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 RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
|
||||
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
|
||||
// import TrainsPage from "./pages/trains/TrainsPage";
|
||||
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
|
||||
//import TrainsPage from "./pages/trains/TrainsPage";
|
||||
import TrainDetailPage from "./pages/trains/TrainDetailPage";
|
||||
import WagonsPage from "./pages/wagons/WagonsPage";
|
||||
import ContainersPage from "./pages/containers_management/ContainersPage";
|
||||
import CargoesPage from "./pages/cargoes/CargoesPage";
|
||||
import TrainsPage from "./pages/trains/TrainsPage";
|
||||
import {
|
||||
CargoesCrudPage,
|
||||
ContainersCrudPage,
|
||||
TrainMasterDataPage,
|
||||
WagonsCrudPage,
|
||||
} from "./pages/fleet/FleetCrudPages";
|
||||
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
|
||||
import TrainDetailPage from "./pages/trains/TrainDetailPage";
|
||||
|
||||
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
{
|
||||
@@ -239,14 +241,13 @@ const App = () => {
|
||||
<Route
|
||||
path="booking-requests/:id/contract"
|
||||
element={<BookingContractPage />}
|
||||
/>
|
||||
{/* <Route path="operations/train-scheduling" element={<TrainsPage />} />
|
||||
|
||||
<Route path="trains" element={<TrainsPage />} /> */}
|
||||
<Route path="trains/:id" element={<TrainDetailPage />} />
|
||||
<Route path="wagons" element={<WagonsPage />} />
|
||||
<Route path="containers" element={<ContainersPage />} />
|
||||
<Route path="cargoes" element={<CargoesPage />} />
|
||||
/>
|
||||
<Route path="operations/train-scheduling" element={<TrainsPage />} />
|
||||
<Route path="trains" element={<TrainMasterDataPage />} />
|
||||
<Route path="trains/:id" element={<TrainDetailPage />} />
|
||||
<Route path="wagons" element={<WagonsCrudPage />} />
|
||||
<Route path="containers" element={<ContainersCrudPage />} />
|
||||
<Route path="cargoes" element={<CargoesCrudPage />} />
|
||||
|
||||
<Route path="user-management" element={<UserManagementPage />} />
|
||||
<Route path="user-management/users" element={<UsersPage />} />
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
/** Shared surfaces for booking list & detail — frosted glass, neutral accents. */
|
||||
|
||||
export const bookingGlass = {
|
||||
card:
|
||||
"border border-border/50 bg-card/75 shadow-sm backdrop-blur-md supports-[backdrop-filter]:bg-card/60",
|
||||
card: "border border-border/50 bg-card/75 shadow-sm backdrop-blur-md supports-[backdrop-filter]:bg-card/60",
|
||||
panel:
|
||||
"border border-border/50 bg-card/80 shadow-sm backdrop-blur-md supports-[backdrop-filter]:bg-card/65",
|
||||
rail:
|
||||
"border border-border/40 bg-muted/20 backdrop-blur-sm supports-[backdrop-filter]:bg-muted/15",
|
||||
rail: "border border-border/40 bg-muted/20 backdrop-blur-sm supports-[backdrop-filter]:bg-muted/15",
|
||||
iconWell:
|
||||
"border border-border/50 bg-background/70 text-foreground/75 shadow-sm backdrop-blur-sm supports-[backdrop-filter]:bg-background/50",
|
||||
iconWellHero:
|
||||
@@ -22,9 +20,8 @@ export const bookingGlass = {
|
||||
} as const;
|
||||
|
||||
export const bookingSurface = {
|
||||
page:
|
||||
"min-h-screen bg-gradient-to-b from-muted/30 via-background to-background",
|
||||
pageInner: "mx-auto max-w-[1600px] space-y-5 p-6 lg:p-8",
|
||||
page: "min-h-screen bg-background ",
|
||||
pageInner: "mx-auto max-w-[1600px] space-y-5 ",
|
||||
hero: `relative overflow-hidden rounded-2xl ${bookingGlass.card}`,
|
||||
heroGlow:
|
||||
"pointer-events-none absolute -right-24 -top-24 size-72 rounded-full bg-muted/40 blur-3xl",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,16 @@
|
||||
import { useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
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 { Label } from '@/components/ui/label';
|
||||
import { useContainers, useAssignContainerToWagon } from '@/hooks/useContainers';
|
||||
import { useContainers, useAssignContainerToWagon } from './use-containers';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { Plus } from 'lucide-react';
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useContainersByWagon, useUnassignContainer } from '@/hooks/useContainers';
|
||||
import { useContainersByWagon, useUnassignContainer } from './use-containers';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import type { Container } from '@/services/containerService';
|
||||
import type { Container } from './container.service';
|
||||
|
||||
export function ContainersTable({ wagonId }: { wagonId: string }) {
|
||||
const { data: containers, refetch } = useContainersByWagon(wagonId);
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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}`);
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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}`);
|
||||
},
|
||||
};
|
||||
@@ -56,7 +56,10 @@ const FreightDashboardHeader = ({
|
||||
if (!isUserMenuOpen) return;
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
@@ -74,10 +77,14 @@ const FreightDashboardHeader = ({
|
||||
}, [isUserMenuOpen]);
|
||||
|
||||
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">
|
||||
<h1 className="truncate text-xl font-bold tracking-tight text-gray-900">{pageMeta.title}</h1>
|
||||
<p className="mt-0.5 truncate text-sm text-gray-500">{pageMeta.subtitle}</p>
|
||||
<h1 className="truncate text-xl font-bold tracking-tight text-foreground">
|
||||
{pageMeta.title}
|
||||
</h1>
|
||||
<p className="mt-0.5 truncate text-sm text-secondary-foreground">
|
||||
{pageMeta.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
@@ -85,14 +92,24 @@ const FreightDashboardHeader = ({
|
||||
<button
|
||||
type="button"
|
||||
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}
|
||||
>
|
||||
{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>
|
||||
) : 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" />
|
||||
</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" />
|
||||
</button>
|
||||
|
||||
<button type="button" aria-label="Notifications" className={iconButtonClass}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Notifications"
|
||||
className={iconButtonClass}
|
||||
>
|
||||
<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" />
|
||||
</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"
|
||||
>
|
||||
<div className="border-b border-gray-100 px-4 py-3">
|
||||
<p className="text-sm font-semibold text-gray-900">{userName}</p>
|
||||
{userEmail ? <p className="text-xs text-gray-500">{userEmail}</p> : null}
|
||||
<p className="text-sm font-semibold text-gray-900">
|
||||
{userName}
|
||||
</p>
|
||||
{userEmail ? (
|
||||
<p className="text-xs text-gray-500">{userEmail}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<a
|
||||
href="#profile"
|
||||
|
||||
@@ -12,7 +12,9 @@ function getInitialTheme(): Theme {
|
||||
if (typeof window === "undefined") return "light";
|
||||
const stored = window.localStorage.getItem(THEME_STORAGE_KEY);
|
||||
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 {
|
||||
@@ -29,7 +31,7 @@ export interface FreightDashboardLayoutProps {
|
||||
}
|
||||
|
||||
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 = ({
|
||||
sidebarSections,
|
||||
@@ -59,7 +61,8 @@ const FreightDashboardLayout = ({
|
||||
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
|
||||
}, [theme, enableThemeToggle]);
|
||||
|
||||
const toggleTheme = () => setTheme((current) => (current === "dark" ? "light" : "dark"));
|
||||
const toggleTheme = () =>
|
||||
setTheme((current) => (current === "dark" ? "light" : "dark"));
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -71,17 +74,17 @@ const FreightDashboardLayout = ({
|
||||
/>
|
||||
|
||||
<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)" }}
|
||||
>
|
||||
<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
|
||||
sections={sidebarSections}
|
||||
activeHref={activeHref}
|
||||
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}`}>
|
||||
<FreightDashboardHeader
|
||||
pageMeta={pageMeta}
|
||||
|
||||
@@ -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 { cn } from "@/lib/utils";
|
||||
@@ -20,21 +26,28 @@ const collectSidebarHrefs = (items: SidebarItem[]): string[] =>
|
||||
items.flatMap((item) => {
|
||||
const hrefs: string[] = [];
|
||||
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;
|
||||
});
|
||||
|
||||
const flattenSectionItems = (sections: SidebarSection[]) =>
|
||||
sections.flatMap((section) => section.items);
|
||||
|
||||
const FreightSidebar = ({ sections, activeHref, onNavigate }: FreightSidebarProps) => {
|
||||
const FreightSidebar = ({
|
||||
sections,
|
||||
activeHref,
|
||||
onNavigate,
|
||||
}: FreightSidebarProps) => {
|
||||
const items = useMemo(() => flattenSectionItems(sections), [sections]);
|
||||
const activePath = activeHref?.toLowerCase() ?? "";
|
||||
|
||||
const isHrefActive = useCallback(
|
||||
(href: string) => {
|
||||
const normalized = href.toLowerCase();
|
||||
return activePath === normalized || activePath.startsWith(`${normalized}/`);
|
||||
return (
|
||||
activePath === normalized || activePath.startsWith(`${normalized}/`)
|
||||
);
|
||||
},
|
||||
[activePath],
|
||||
);
|
||||
@@ -72,7 +85,8 @@ const FreightSidebar = ({ sections, activeHref, onNavigate }: FreightSidebarProp
|
||||
return acc;
|
||||
}, [activePath, branchContainsActive, isHrefActive, items]);
|
||||
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>(defaultExpanded);
|
||||
const [expanded, setExpanded] =
|
||||
useState<Record<string, boolean>>(defaultExpanded);
|
||||
|
||||
useEffect(() => {
|
||||
setExpanded((current) => ({ ...defaultExpanded, ...current }));
|
||||
@@ -108,7 +122,11 @@ const FreightSidebar = ({ sections, activeHref, onNavigate }: FreightSidebarProp
|
||||
: "text-gray-900",
|
||||
);
|
||||
|
||||
const renderNavBranch = (children: SidebarItem[], depth: number, parentKey: string) =>
|
||||
const renderNavBranch = (
|
||||
children: SidebarItem[],
|
||||
depth: number,
|
||||
parentKey: string,
|
||||
) =>
|
||||
children.map((child) => {
|
||||
const key = sidebarItemKey(child, parentKey);
|
||||
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 itemHref = item.href.toLowerCase();
|
||||
const childActive = hasChildren ? branchContainsActive(item.children!) : false;
|
||||
const childActive = hasChildren
|
||||
? branchContainsActive(item.children!)
|
||||
: false;
|
||||
const isCurrentItem = hasChildren
|
||||
? activePath === itemHref
|
||||
: isHrefActive(itemHref);
|
||||
@@ -206,10 +226,12 @@ const FreightSidebar = ({ sections, activeHref, onNavigate }: FreightSidebarProp
|
||||
href={item.href}
|
||||
onClick={(event) => navigateTo(event, item.href!)}
|
||||
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 ? (
|
||||
<span className={iconClass(leafActive, isActive)}>{item.icon}</span>
|
||||
<span className={iconClass(leafActive, isActive)}>
|
||||
{item.icon}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="truncate">{item.label}</span>
|
||||
</a>
|
||||
@@ -257,10 +279,12 @@ const FreightSidebar = ({ sections, activeHref, onNavigate }: FreightSidebarProp
|
||||
};
|
||||
|
||||
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">
|
||||
<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>
|
||||
|
||||
<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(
|
||||
"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
|
||||
"text-gray-400"
|
||||
"text-sidebar-secondary-foreground",
|
||||
)}
|
||||
>
|
||||
{section.title}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { Card, CardHeader, CardTitle, CardDescription, CardFooter, CardAction, CardContent } from '@edr/ui-common';
|
||||
@@ -4,6 +4,8 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from
|
||||
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 { useWagonTypes } from '@/hooks/use-wagon-types';
|
||||
import { useCreateWagon, useUpdateWagon } from '@/hooks/useWagons';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
@@ -25,6 +27,7 @@ export function WagonFormDialog({ trigger, wagon, onSuccess }: WagonFormDialogPr
|
||||
});
|
||||
const createWagon = useCreateWagon();
|
||||
const updateWagon = useUpdateWagon();
|
||||
const { data: wagonTypes = [], isLoading: wagonTypesLoading } = useWagonTypes();
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -40,6 +43,10 @@ export function WagonFormDialog({ trigger, wagon, onSuccess }: WagonFormDialogPr
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!form.wagonNumber || !form.wagonTypeId) {
|
||||
toast({ title: 'Missing required field', description: 'Please select a wagon type.', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (wagon) await updateWagon.mutateAsync({ id: wagon.id, data: form });
|
||||
else await createWagon.mutateAsync(form);
|
||||
@@ -57,10 +64,37 @@ export function WagonFormDialog({ trigger, wagon, onSuccess }: WagonFormDialogPr
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>{wagon ? 'Edit Wagon' : 'Create Wagon'}</DialogTitle></DialogHeader>
|
||||
<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 Type ID*</Label><Input required value={form.wagonTypeId} onChange={e => setForm({...form, wagonTypeId: e.target.value})} /></div>
|
||||
<div><Label>Tare Weight (kg)*</Label><Input type="number" required value={form.tareWeight} onChange={e => setForm({...form, tareWeight: parseFloat(e.target.value)})} /></div>
|
||||
<div><Label>Max Payload (kg)*</Label><Input type="number" required value={form.maxPayloadWeight} onChange={e => setForm({...form, maxPayloadWeight: parseFloat(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*</Label>
|
||||
<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>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>
|
||||
@@ -68,4 +102,4 @@ export function WagonFormDialog({ trigger, wagon, onSuccess }: WagonFormDialogPr
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useWagonsByTrain, useUnassignWagon, useReorderWagons } from '@/hooks/useWagons';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Trash2, GripVertical } from 'lucide-react';
|
||||
//import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
//import { Button } from '@/components/ui/button';
|
||||
//import { Trash2, GripVertical } from 'lucide-react';
|
||||
// import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
|
||||
|
||||
export function WagonsTable({ trainId }: { trainId: string }) {
|
||||
|
||||
@@ -77,6 +77,7 @@ export const URL_CONSTANTS = {
|
||||
|
||||
BOOKINGS: {
|
||||
BASE: "/bookings",
|
||||
REFERENCE_DATA: "/bookings/reference-data",
|
||||
LIST_SUMMARY: "/bookings/list-summary",
|
||||
BY_ID: (id: string) => `/bookings/${id}`,
|
||||
QUEUE: (queue: string) => `/bookings/queues/${queue}`,
|
||||
|
||||
12
apps/edr-freight-web/backoffice/src/hooks/use-cargo-types.ts
Normal file
12
apps/edr-freight-web/backoffice/src/hooks/use-cargo-types.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
1
apps/edr-freight-web/backoffice/src/hooks/use-cargoes.ts
Normal file
1
apps/edr-freight-web/backoffice/src/hooks/use-cargoes.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from '@/components/container_management/use-cargoes';
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from '@/components/container_management/use-containers';
|
||||
12
apps/edr-freight-web/backoffice/src/hooks/use-wagon-types.ts
Normal file
12
apps/edr-freight-web/backoffice/src/hooks/use-wagon-types.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -4,16 +4,44 @@ import { cargoService } from '@/services/cargoService';
|
||||
export const cargoKeys = {
|
||||
all: ['cargoes'] 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() {
|
||||
return useQuery({ queryKey: cargoKeys.all, queryFn: () => cargoService.getAll().then(res => res.data) });
|
||||
}
|
||||
|
||||
export const useGetCargoes = useCargoes;
|
||||
|
||||
export function useCargoesByContainer(containerId: string) {
|
||||
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() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
@@ -36,4 +64,4 @@ export function useUnloadCargo() {
|
||||
mutationFn: (id: string) => cargoService.unload(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: cargoKeys.all })
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,16 +4,44 @@ import { containerService } from '@/services/containerService';
|
||||
export const containerKeys = {
|
||||
all: ['containers'] 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() {
|
||||
return useQuery({ queryKey: containerKeys.all, queryFn: () => containerService.getAll().then(res => res.data) });
|
||||
}
|
||||
|
||||
export const useGetContainers = useContainers;
|
||||
|
||||
export function useContainersByWagon(wagonId: string) {
|
||||
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() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
@@ -28,4 +56,4 @@ export function useUnassignContainer() {
|
||||
mutationFn: containerService.unassign,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: containerKeys.all })
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,10 +12,14 @@ export function useTrains() {
|
||||
return useQuery({ queryKey: trainKeys.lists(), queryFn: () => trainService.getAll().then(res => res.data) });
|
||||
}
|
||||
|
||||
export const useGetTrains = useTrains;
|
||||
|
||||
export function useTrain(id: string) {
|
||||
return useQuery({ queryKey: trainKeys.detail(id), queryFn: () => trainService.getById(id).then(res => res.data), enabled: !!id });
|
||||
}
|
||||
|
||||
export const useGetTrain = useTrain;
|
||||
|
||||
export function useCreateTrain() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({ mutationFn: trainService.create, onSuccess: () => qc.invalidateQueries({ queryKey: trainKeys.lists() }) });
|
||||
@@ -32,4 +36,4 @@ export function useUpdateTrain() {
|
||||
export function useDeleteTrain() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({ mutationFn: trainService.delete, onSuccess: () => qc.invalidateQueries({ queryKey: trainKeys.lists() }) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,16 +5,25 @@ export const wagonKeys = {
|
||||
all: ['wagons'] as const,
|
||||
byTrain: (trainId: string) => [...wagonKeys.all, 'train', trainId] as const,
|
||||
details: () => [...wagonKeys.all, 'detail'] as const,
|
||||
detail: (id: string) => [...wagonKeys.details(), id] as const,
|
||||
};
|
||||
|
||||
export function useWagons() {
|
||||
return useQuery({ queryKey: wagonKeys.all, queryFn: () => wagonService.getAll().then(res => res.data) });
|
||||
}
|
||||
|
||||
export const useGetWagons = useWagons;
|
||||
|
||||
export function useWagonsByTrain(trainId: string) {
|
||||
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() {
|
||||
const qc = useQueryClient();
|
||||
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() {
|
||||
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 }) });
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -360,17 +360,25 @@ const TrainsPage = () => {
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Booking status</label>
|
||||
<input
|
||||
className={inputClassName}
|
||||
placeholder="APPROVED"
|
||||
value={filters.status ?? ''}
|
||||
onChange={(event) =>
|
||||
<Select
|
||||
value={filters.status ?? '__all__'}
|
||||
onValueChange={(value) =>
|
||||
setFilters((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>
|
||||
</section>
|
||||
@@ -757,6 +765,7 @@ const TrainsPage = () => {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default TrainsPage;
|
||||
|
||||
// export default function TrainsPage() {
|
||||
// const { data: trains, isLoading } = useTrains();
|
||||
@@ -795,4 +804,4 @@ const TrainsPage = () => {
|
||||
// </CardContent>
|
||||
// </Card>
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
|
||||
109
apps/edr-freight-web/backoffice/src/pages/wagons/WagonForm.tsx
Normal file
109
apps/edr-freight-web/backoffice/src/pages/wagons/WagonForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -19,8 +19,11 @@ export interface Cargo {
|
||||
|
||||
export const cargoService = {
|
||||
getAll: () => apiClient.get<Cargo[]>('/cargoes'),
|
||||
getById: (id: string) => apiClient.get<Cargo>(`/cargoes/${id}`),
|
||||
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) =>
|
||||
apiClient.post(`/cargoes/${cargoId}/load`, { quantity, weight, volume }),
|
||||
deliver: (cargoId: string) => apiClient.post(`/cargoes/${cargoId}/deliver`),
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -16,8 +16,12 @@ export interface Container {
|
||||
|
||||
export const containerService = {
|
||||
getAll: () => apiClient.get<Container[]>('/containers'),
|
||||
getById: (id: string) => apiClient.get<Container>(`/containers/${id}`),
|
||||
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) =>
|
||||
apiClient.post(`/containers/${containerId}/assign-wagon`, { wagonId, position }),
|
||||
unassign: (containerId: string) => apiClient.post(`/containers/${containerId}/unassign-wagon`),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@ import type {
|
||||
} from '@/types/trainScheduling';
|
||||
|
||||
interface BookingReferenceDataResponse {
|
||||
yard?: YardOption[];
|
||||
yard?: Array<YardOption & { label?: string }>;
|
||||
}
|
||||
|
||||
export const trainSchedulingService = {
|
||||
@@ -82,6 +82,11 @@ export const trainSchedulingService = {
|
||||
URL_CONSTANTS.BOOKINGS.REFERENCE_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,
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
@@ -24,5 +24,5 @@ export const trainService = {
|
||||
create: (data: Partial<Train>) => apiClient.post('/trains', data),
|
||||
update: (id: string, data: Partial<Train>) => apiClient.patch(`/trains/${id}`, data),
|
||||
delete: (id: string) => apiClient.delete(`/trains/${id}`),
|
||||
getDetails: (id: string) => apiClient.get(`/trains/${id}/details`),
|
||||
getDetails: (id: string) => apiClient .get(`/trains/${id}/details`),
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -14,13 +14,14 @@ export interface Wagon {
|
||||
|
||||
export const wagonService = {
|
||||
getAll: () => apiClient.get<Wagon[]>('/wagons'),
|
||||
getById: (id: string) => apiClient.get<Wagon>(`/wagons/${id}`),
|
||||
getByTrain: (trainId: string) => apiClient.get<Wagon[]>(`/wagons?trainId=${trainId}`),
|
||||
assignToTrain: (wagonId: string, trainId: string, sequenceNumber?: number) =>
|
||||
apiClient.post(`/wagons/${wagonId}/assign-train`, { trainId, sequenceNumber }),
|
||||
unassign: (wagonId: string) => apiClient.post(`/wagons/${wagonId}/unassign-train`),
|
||||
reorder: (trainId: string, wagonIds: string[]) =>
|
||||
apiClient.post(`/trains/${trainId}/reorder-wagons`, { wagonIds }),
|
||||
create: (data: Partial<Wagon>) => apiClient.post('/wagons', data),
|
||||
update: (id: string, data: Partial<Wagon>) => apiClient.patch(`/wagons/${id}`, data),
|
||||
|
||||
};
|
||||
create: (data: Partial<Wagon>) => apiClient.post('/wagons', data),
|
||||
update: (id: string, data: Partial<Wagon>) => apiClient.patch(`/wagons/${id}`, data),
|
||||
delete: (id: string) => apiClient.delete(`/wagons/${id}`),
|
||||
};
|
||||
|
||||
@@ -88,12 +88,12 @@ export const URL_CONSTANTS = {
|
||||
|
||||
BOOKINGS: {
|
||||
BASE: "/bookings",
|
||||
BY_ID: (id: string | number) => `/bookings/${id}`,
|
||||
CONTRACT_VIEW: (id: string) => `/bookings/${id}/contract/view`,
|
||||
CONTRACT_DOCUMENT: (id: string) => `/bookings/${id}/contract/document`,
|
||||
CONTRACT_SIGN: (id: string) => `/bookings/${id}/contract/sign`,
|
||||
CONTRACT_DOWNLOAD: (id: string) => `/bookings/${id}/contract`,
|
||||
CANCEL: (id: string | number) => `/bookings/${id}/cancel`,
|
||||
CONFIRM: (id: string | number) => `/bookings/${id}/confirm`,
|
||||
BY_ID: (id: string | number) => `/api/bookings/${id}`,
|
||||
CONTRACT_VIEW: (id: string) => `/api/bookings/${id}/contract/view`,
|
||||
CONTRACT_DOCUMENT: (id: string) => `/api/bookings/${id}/contract/document`,
|
||||
CONTRACT_SIGN: (id: string) => `/api/bookings/${id}/contract/sign`,
|
||||
CONTRACT_DOWNLOAD: (id: string) => `/api/bookings/${id}/contract`,
|
||||
CANCEL: (id: string | number) => `/api/bookings/${id}/cancel`,
|
||||
CONFIRM: (id: string | number) => `/api/bookings/${id}/confirm`,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -172,7 +172,6 @@ const useAuth = () => {
|
||||
});
|
||||
localStorage.clear();
|
||||
queryClient.clear();
|
||||
window.location.href = "/login";
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
@@ -21,6 +21,7 @@ export default function BookingContractPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const [signOpen, setSignOpen] = useState(false);
|
||||
const [signerName, setSignerName] = useState("");
|
||||
const [signatureData, setSignatureData] = useState<string | null>(null);
|
||||
@@ -58,6 +59,12 @@ export default function BookingContractPage() {
|
||||
}
|
||||
}, [id, data?.reference]);
|
||||
|
||||
const handlePrint = useCallback(() => {
|
||||
if (iframeRef.current?.contentWindow) {
|
||||
iframeRef.current.contentWindow.print();
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-[40vh] items-center justify-center">
|
||||
@@ -77,8 +84,6 @@ export default function BookingContractPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const bodyHtml = extractBodyHtml(data.html);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-muted/30 p-4 md:p-8">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
@@ -88,7 +93,7 @@ export default function BookingContractPage() {
|
||||
Back to booking
|
||||
</Button>
|
||||
<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" />
|
||||
Print
|
||||
</Button>
|
||||
@@ -105,9 +110,12 @@ export default function BookingContractPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<article
|
||||
className="contract-document rounded-lg border bg-white p-6 shadow-sm print:shadow-none md:p-10"
|
||||
dangerouslySetInnerHTML={{ __html: bodyHtml }}
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
srcDoc={data.html}
|
||||
className="w-full rounded-lg border bg-white shadow-sm"
|
||||
style={{ minHeight: "80vh" }}
|
||||
title="Contract document"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -158,8 +166,3 @@ export default function BookingContractPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function extractBodyHtml(fullHtml: string): string {
|
||||
const match = fullHtml.match(/<body[^>]*>([\s\S]*)<\/body>/i);
|
||||
return match ? match[1] : fullHtml;
|
||||
}
|
||||
|
||||
@@ -844,28 +844,7 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
{(booking.status === "CONTRACT_READY" ||
|
||||
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 & sign contract
|
||||
</button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{renderContractCard(booking, navigate)}
|
||||
|
||||
<Card>
|
||||
<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({
|
||||
label,
|
||||
station,
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
UseQueryOptions,
|
||||
QueryObserverOptions,
|
||||
} from "@tanstack/react-query";
|
||||
import { UseQueryOptions, QueryObserverOptions } from "@tanstack/react-query";
|
||||
import axios, { AxiosError, InternalAxiosRequestConfig } from "axios";
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
|
||||
@@ -23,11 +20,15 @@ function setCookie(name: string, value: string, days: number) {
|
||||
}
|
||||
|
||||
function clearAuthCookies() {
|
||||
["auth-token", "refresh-token", "auth-user", "current-position-id", "selected-position-id"].forEach(
|
||||
(name) => {
|
||||
document.cookie = `${name}=; Max-Age=0; path=/`;
|
||||
},
|
||||
);
|
||||
[
|
||||
"auth-token",
|
||||
"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
|
||||
@@ -96,17 +97,13 @@ client.interceptors.response.use(
|
||||
if (!refreshToken) {
|
||||
isRefreshing = false;
|
||||
clearAuthCookies();
|
||||
if (window.location.pathname !== "/login") {
|
||||
window.location.href = "/login";
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
try {
|
||||
const { data } = await client.post<{ data: { token: string; refreshToken: string } }>(
|
||||
URL_CONSTANTS.AUTH.REFRESH_TOKEN,
|
||||
{ refreshToken },
|
||||
);
|
||||
const { data } = await client.post<{
|
||||
data: { token: string; refreshToken: string };
|
||||
}>(URL_CONSTANTS.AUTH.REFRESH_TOKEN, { refreshToken });
|
||||
const { token, refreshToken: newRefreshToken } = data.data;
|
||||
setCookie("auth-token", token, 7);
|
||||
setCookie("refresh-token", newRefreshToken, 7);
|
||||
|
||||
10
cargo-types.service.ts
Normal file
10
cargo-types.service.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
10
container-types.service.ts
Normal file
10
container-types.service.ts
Normal 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
3551
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
12
use-cargo-types.ts
Normal file
12
use-cargo-types.ts
Normal 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
0
use-cargoes.ts
Normal file
12
use-container-types.ts
Normal file
12
use-container-types.ts
Normal 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
12
use-wagon-types.ts
Normal 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
0
wagon-type.entity.ts
Normal file
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user