mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 05:25:41 +00:00
Trains management CRUD issues solved
This commit is contained in:
@@ -53,6 +53,7 @@
|
|||||||
"@types/multer": "^2.1.0",
|
"@types/multer": "^2.1.0",
|
||||||
"@types/node": "^20.14.0",
|
"@types/node": "^20.14.0",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
|
"@types/pg": "^8.6.7",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.0.0",
|
||||||
"ts-jest": "^29.2.5",
|
"ts-jest": "^29.2.5",
|
||||||
|
|||||||
@@ -75,7 +75,6 @@ import { CargoesModule } from './modules/cargoes/cargoes.module';
|
|||||||
BookingsModule,
|
BookingsModule,
|
||||||
FilesModule,
|
FilesModule,
|
||||||
ConsignmentsModule,
|
ConsignmentsModule,
|
||||||
TrainsModule,
|
|
||||||
LocomotivesModule,
|
LocomotivesModule,
|
||||||
WagonTypesModule,
|
WagonTypesModule,
|
||||||
TrainSetsModule,
|
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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
|
Query,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { CreateCargoDto } from './dto/create-cargo.dto';
|
import { CreateCargoDto } from './dto/create-cargo.dto';
|
||||||
@@ -28,8 +29,8 @@ export class CargoesController {
|
|||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: 'List all cargoes' })
|
@ApiOperation({ summary: 'List all cargoes' })
|
||||||
findAll() {
|
findAll(@Query() query: Record<string, string | undefined>) {
|
||||||
return this.cargoesService.findAll();
|
return this.cargoesService.findAll(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { FindOptionsOrder, FindOptionsWhere, ILike, Repository } from 'typeorm';
|
||||||
import { CreateCargoDto } from './dto/create-cargo.dto';
|
import { CreateCargoDto } from './dto/create-cargo.dto';
|
||||||
import { UpdateCargoDto } from './dto/update-cargo.dto';
|
import { UpdateCargoDto } from './dto/update-cargo.dto';
|
||||||
import { LoadCargoDto } from './dto/load-cargo.dto';
|
import { LoadCargoDto } from './dto/load-cargo.dto';
|
||||||
@@ -22,8 +22,36 @@ export class CargoesService {
|
|||||||
return this.cargoRepo.save(cargo);
|
return this.cargoRepo.save(cargo);
|
||||||
}
|
}
|
||||||
|
|
||||||
async findAll(): Promise<Cargo[]> {
|
async findAll(query: Record<string, string | undefined> = {}): Promise<Cargo[]> {
|
||||||
return this.cargoRepo.find({ order: { cargoReference: 'ASC' } });
|
const where: FindOptionsWhere<Cargo>[] | FindOptionsWhere<Cargo> = [];
|
||||||
|
const search = query.search?.trim();
|
||||||
|
const status = query.status?.trim();
|
||||||
|
const containerId = query.containerId?.trim();
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
where.push({
|
||||||
|
cargoReference: ILike(`%${search}%`),
|
||||||
|
...(status ? { status } : {}),
|
||||||
|
...(containerId ? { containerId } : {}),
|
||||||
|
});
|
||||||
|
where.push({
|
||||||
|
description: ILike(`%${search}%`),
|
||||||
|
...(status ? { status } : {}),
|
||||||
|
...(containerId ? { containerId } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortBy = ['cargoReference', 'quantity', 'weight', 'volume', 'status'].includes(query.sortBy ?? '')
|
||||||
|
? (query.sortBy as keyof Cargo)
|
||||||
|
: 'cargoReference';
|
||||||
|
const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
|
||||||
|
|
||||||
|
return this.cargoRepo.find({
|
||||||
|
where: search ? where : { ...(status ? { status } : {}), ...(containerId ? { containerId } : {}) },
|
||||||
|
order: { [sortBy]: sortOrder } as FindOptionsOrder<Cargo>,
|
||||||
|
skip: query.page && query.limit ? (Number(query.page) - 1) * Number(query.limit) : undefined,
|
||||||
|
take: query.limit ? Number(query.limit) : undefined,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async findById(id: string): Promise<Cargo> {
|
async findById(id: string): Promise<Cargo> {
|
||||||
|
|||||||
@@ -53,6 +53,21 @@ export class Company extends BaseEntity {
|
|||||||
@Column({ name: 'email', type: 'varchar', length: 150, nullable: true })
|
@Column({ name: 'email', type: 'varchar', length: 150, nullable: true })
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'contact_person_name', type: 'varchar', length: 100, nullable: true })
|
||||||
|
contactPersonName?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'contact_person_phone', type: 'varchar', length: 20, nullable: true })
|
||||||
|
contactPersonPhone?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'general_manager_name', type: 'varchar', length: 100, nullable: true })
|
||||||
|
generalManagerName?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'general_manager_email', type: 'varchar', length: 150, nullable: true })
|
||||||
|
generalManagerEmail?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'general_manager_phone', type: 'varchar', length: 20, nullable: true })
|
||||||
|
generalManagerPhone?: string | null;
|
||||||
|
|
||||||
@Column({ name: 'website', type: 'varchar', length: 200, nullable: true })
|
@Column({ name: 'website', type: 'varchar', length: 200, nullable: true })
|
||||||
website?: string | null;
|
website?: string | null;
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
|
Query,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { CreateContainerDto } from './dto/create-container.dto';
|
import { CreateContainerDto } from './dto/create-container.dto';
|
||||||
@@ -27,8 +28,8 @@ export class ContainersController {
|
|||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: 'List all containers' })
|
@ApiOperation({ summary: 'List all containers' })
|
||||||
findAll() {
|
findAll(@Query() query: Record<string, string | undefined>) {
|
||||||
return this.containersService.findAll();
|
return this.containersService.findAll(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// apps/edr-freight-api/src/modules/container-management/containers.service.ts
|
// apps/edr-freight-api/src/modules/container-management/containers.service.ts
|
||||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { FindOptionsOrder, FindOptionsWhere, ILike, Repository } from 'typeorm';
|
||||||
import { CreateContainerDto } from './dto/create-container.dto';
|
import { CreateContainerDto } from './dto/create-container.dto';
|
||||||
import { UpdateContainerDto } from './dto/update-container.dto';
|
import { UpdateContainerDto } from './dto/update-container.dto';
|
||||||
import { AssignContainerToWagonDto } from './dto/assign-container-to-wagon.dto';
|
import { AssignContainerToWagonDto } from './dto/assign-container-to-wagon.dto';
|
||||||
@@ -24,8 +24,31 @@ export class ContainersService {
|
|||||||
return this.containerRepo.save(container);
|
return this.containerRepo.save(container);
|
||||||
}
|
}
|
||||||
|
|
||||||
async findAll(): Promise<Container[]> {
|
async findAll(query: Record<string, string | undefined> = {}): Promise<Container[]> {
|
||||||
return this.containerRepo.find({ order: { containerNumber: 'ASC' } });
|
const where: FindOptionsWhere<Container>[] | FindOptionsWhere<Container> = [];
|
||||||
|
const search = query.search?.trim();
|
||||||
|
const status = query.status?.trim();
|
||||||
|
const wagonId = query.wagonId?.trim();
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
where.push({
|
||||||
|
containerNumber: ILike(`%${search}%`),
|
||||||
|
...(status ? { status } : {}),
|
||||||
|
...(wagonId ? { wagonId } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortBy = ['containerNumber', 'tareWeight', 'maxGrossWeight', 'status', 'position'].includes(query.sortBy ?? '')
|
||||||
|
? (query.sortBy as keyof Container)
|
||||||
|
: 'containerNumber';
|
||||||
|
const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
|
||||||
|
|
||||||
|
return this.containerRepo.find({
|
||||||
|
where: search ? where : { ...(status ? { status } : {}), ...(wagonId ? { wagonId } : {}) },
|
||||||
|
order: { [sortBy]: sortOrder } as FindOptionsOrder<Container>,
|
||||||
|
skip: query.page && query.limit ? (Number(query.page) - 1) * Number(query.limit) : undefined,
|
||||||
|
take: query.limit ? Number(query.limit) : undefined,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async findById(id: string): Promise<Container> {
|
async findById(id: string): Promise<Container> {
|
||||||
@@ -37,8 +60,6 @@ export class ContainersService {
|
|||||||
async update(id: string, dto: UpdateContainerDto): Promise<Container> {
|
async update(id: string, dto: UpdateContainerDto): Promise<Container> {
|
||||||
const container = await this.findById(id);
|
const container = await this.findById(id);
|
||||||
Object.assign(container, dto);
|
Object.assign(container, dto);
|
||||||
if (dto.wagonId === undefined) container.wagonId = null;
|
|
||||||
if (dto.position === undefined) container.position = null;
|
|
||||||
return this.containerRepo.save(container);
|
return this.containerRepo.save(container);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,10 +61,10 @@ export class CustomersController {
|
|||||||
return this.customersService.findById(id);
|
return this.customersService.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("user/:userId")
|
// @Get("user/:userId")
|
||||||
findByUserId(@Param("userId", ParseUUIDPipe) userId: string): Promise<any> {
|
// findByUserId(@Param("userId", ParseUUIDPipe) userId: string): Promise<any> {
|
||||||
return this.customersService.findByUserId(userId);
|
// return this.customersService.findByUserId(userId);
|
||||||
}
|
// }
|
||||||
|
|
||||||
@Patch(":id")
|
@Patch(":id")
|
||||||
@ApiOperation({ summary: "Update a customer" })
|
@ApiOperation({ summary: "Update a customer" })
|
||||||
|
|||||||
@@ -52,15 +52,15 @@ export class CustomersService {
|
|||||||
return customer;
|
return customer;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByUserId(userId: string): Promise<Customer> {
|
// async findByUserId(userId: string): Promise<Customer> {
|
||||||
const customer = await this.customersRepository.findByUserId(userId);
|
// const customer = await this.customersRepository.findByUserId(userId);
|
||||||
|
|
||||||
if (!customer) {
|
// if (!customer) {
|
||||||
throw new NotFoundException(`Customer with ID ${userId} not found`);
|
// throw new NotFoundException(`Customer with ID ${userId} not found`);
|
||||||
}
|
// }
|
||||||
|
|
||||||
return customer;
|
// return customer;
|
||||||
}
|
//}
|
||||||
|
|
||||||
/** Get customer by email */
|
/** Get customer by email */
|
||||||
async findByEmail(email: string): Promise<Customer> {
|
async findByEmail(email: string): Promise<Customer> {
|
||||||
@@ -100,16 +100,16 @@ export class CustomersService {
|
|||||||
throw new BadRequestException("VAT number must be exactly 10 digits");
|
throw new BadRequestException("VAT number must be exactly 10 digits");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check email conflict
|
// // Check email conflict
|
||||||
if (dto.email) {
|
// if (dto.email) {
|
||||||
const existing = await this.customersRepository.findByEmail(dto.email);
|
// const existing = await this.customersRepository.findByEmail(dto.email);
|
||||||
|
|
||||||
if (existing && existing.userId !== id) {
|
// // if (existing && existing.userId !== id) {
|
||||||
throw new ConflictException(
|
// // throw new ConflictException(
|
||||||
`Customer with email "${dto.email}" already exists`,
|
// // `Customer with email "${dto.email}" already exists`,
|
||||||
);
|
// // );
|
||||||
}
|
// // }
|
||||||
}
|
// }
|
||||||
|
|
||||||
const updated = await this.customersRepository.update(id, dto);
|
const updated = await this.customersRepository.update(id, dto);
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { Customer } from '../entities/customer.entity';
|
import { Customer } from '../entities/customer.entity';
|
||||||
|
|
||||||
export class ResponseCustomerDto {
|
export class ResponseCustomerDto {
|
||||||
UserId: string;
|
//UserId: string;
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
email: string;
|
email: string;
|
||||||
@@ -30,7 +30,7 @@ export class ResponseCustomerDto {
|
|||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
|
|
||||||
constructor(customer: Customer) {
|
constructor(customer: Customer) {
|
||||||
this.UserId = customer.userId;
|
//this.UserId = customer.userId;
|
||||||
this.firstName = customer.firstName;
|
this.firstName = customer.firstName;
|
||||||
this.lastName = customer.lastName;
|
this.lastName = customer.lastName;
|
||||||
this.email = customer.email;
|
this.email = customer.email;
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ import { Column, Entity, Index } from 'typeorm';
|
|||||||
|
|
||||||
@Entity({ schema: 'freight', name: 'customers' })
|
@Entity({ schema: 'freight', name: 'customers' })
|
||||||
@Index(['email'])
|
@Index(['email'])
|
||||||
@Index(['userId'])
|
//@Index(['userId'])
|
||||||
@Index(['tinNumber'])
|
@Index(['tinNumber'])
|
||||||
@Index(['fanNumber'])
|
@Index(['fanNumber'])
|
||||||
export class Customer extends BaseEntity {
|
export class Customer extends BaseEntity {
|
||||||
@Column({ name: 'user_id', type: 'uuid' })
|
//@Column({ name: 'user_id', type: 'uuid' })
|
||||||
userId!: string;
|
//userId!: string;
|
||||||
|
|
||||||
@Column({ name: 'first_name', type: 'varchar', length: 100 })
|
@Column({ name: 'first_name', type: 'varchar', length: 100 })
|
||||||
firstName!: string;
|
firstName!: string;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsDateString, IsOptional, IsUUID } from 'class-validator';
|
import { IsDateString, IsIn, IsOptional, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
import { BOOKING_STATUSES } from '../../bookings/entities/booking.entity';
|
||||||
|
|
||||||
export class GetEligibleContainerBookingsDto {
|
export class GetEligibleContainerBookingsDto {
|
||||||
@ApiPropertyOptional({ format: 'uuid' })
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
@@ -19,5 +21,6 @@ export class GetEligibleContainerBookingsDto {
|
|||||||
|
|
||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
@IsIn(BOOKING_STATUSES)
|
||||||
status?: string;
|
status?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,11 +118,9 @@ export class TrainSchedulingService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (query.status) {
|
queryBuilder.andWhere("booking.status = :status", {
|
||||||
queryBuilder.andWhere("booking.status = :status", {
|
status: query.status ?? "PAID",
|
||||||
status: query.status,
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const bookings = await queryBuilder
|
const bookings = await queryBuilder
|
||||||
.orderBy("booking.scheduled_date", "ASC")
|
.orderBy("booking.scheduled_date", "ASC")
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
Param,
|
Param,
|
||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
|
Query,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
|
|
||||||
import { CreateTrainDto } from "./dto/create-train.dto";
|
import { CreateTrainDto } from "./dto/create-train.dto";
|
||||||
|
import { UpdateTrainDto } from "./dto/update-train.dto";
|
||||||
import { TrainsService } from "./trains.service";
|
import { TrainsService } from "./trains.service";
|
||||||
|
|
||||||
@ApiTags("trains")
|
@ApiTags("trains")
|
||||||
@@ -24,8 +28,8 @@ export class TrainsController {
|
|||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: "List all trains" })
|
@ApiOperation({ summary: "List all trains" })
|
||||||
findAll() {
|
findAll(@Query() query: Record<string, string | undefined>) {
|
||||||
return this.trainsService.findAll();
|
return this.trainsService.findAll(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(":id")
|
@Get(":id")
|
||||||
@@ -33,4 +37,16 @@ export class TrainsController {
|
|||||||
findOne(@Param("id", ParseUUIDPipe) id: string) {
|
findOne(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
return this.trainsService.findById(id);
|
return this.trainsService.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Patch(":id")
|
||||||
|
@ApiOperation({ summary: "Update a train" })
|
||||||
|
update(@Param("id", ParseUUIDPipe) id: string, @Body() dto: UpdateTrainDto) {
|
||||||
|
return this.trainsService.update(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(":id")
|
||||||
|
@ApiOperation({ summary: "Delete a train" })
|
||||||
|
remove(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
|
return this.trainsService.remove(id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { FindOptionsOrder, FindOptionsWhere, ILike, Repository } from 'typeorm';
|
||||||
import { CreateTrainDto } from './dto/create-train.dto';
|
import { CreateTrainDto } from './dto/create-train.dto';
|
||||||
import { UpdateTrainDto } from './dto/update-train.dto';
|
import { UpdateTrainDto } from './dto/update-train.dto';
|
||||||
import { Train } from './entities/train.entity';
|
import { Train } from './entities/train.entity';
|
||||||
@@ -17,8 +17,28 @@ export class TrainsService {
|
|||||||
return this.trainRepo.save(train);
|
return this.trainRepo.save(train);
|
||||||
}
|
}
|
||||||
|
|
||||||
findAll(): Promise<Train[]> {
|
findAll(query: Record<string, string | undefined> = {}): Promise<Train[]> {
|
||||||
return this.trainRepo.find({ order: { code: 'ASC' } });
|
const where: FindOptionsWhere<Train>[] | FindOptionsWhere<Train> = [];
|
||||||
|
const search = query.search?.trim();
|
||||||
|
const status = query.status?.trim();
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
where.push({ code: ILike(`%${search}%`), ...(status ? { status: status as Train['status'] } : {}) });
|
||||||
|
where.push({ trainNumber: ILike(`%${search}%`), ...(status ? { status: status as Train['status'] } : {}) });
|
||||||
|
where.push({ trainName: ILike(`%${search}%`), ...(status ? { status: status as Train['status'] } : {}) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortBy = ['code', 'trainNumber', 'trainName', 'capacityTons', 'status'].includes(query.sortBy ?? '')
|
||||||
|
? (query.sortBy as keyof Train)
|
||||||
|
: 'code';
|
||||||
|
const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
|
||||||
|
|
||||||
|
return this.trainRepo.find({
|
||||||
|
where: search ? where : status ? { status: status as Train['status'] } : {},
|
||||||
|
order: { [sortBy]: sortOrder } as FindOptionsOrder<Train>,
|
||||||
|
skip: query.page && query.limit ? (Number(query.page) - 1) * Number(query.limit) : undefined,
|
||||||
|
take: query.limit ? Number(query.limit) : undefined,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async findById(id: string): Promise<Train> {
|
async findById(id: string): Promise<Train> {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
|
Query,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { CreateWagonDto } from './dto/create-wagon.dto';
|
import { CreateWagonDto } from './dto/create-wagon.dto';
|
||||||
@@ -28,8 +29,8 @@ export class WagonsController {
|
|||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: 'List all wagons' })
|
@ApiOperation({ summary: 'List all wagons' })
|
||||||
findAll() {
|
findAll(@Query() query: Record<string, string | undefined>) {
|
||||||
return this.wagonsService.findAll();
|
return this.wagonsService.findAll(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository, DataSource } from 'typeorm';
|
import { Repository, DataSource, FindOptionsOrder, FindOptionsWhere, ILike } from 'typeorm';
|
||||||
import { CreateWagonDto } from './dto/create-wagon.dto';
|
import { CreateWagonDto } from './dto/create-wagon.dto';
|
||||||
import { UpdateWagonDto } from './dto/update-wagon.dto';
|
import { UpdateWagonDto } from './dto/update-wagon.dto';
|
||||||
import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.dto';
|
import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.dto';
|
||||||
@@ -26,8 +26,31 @@ export class WagonsService {
|
|||||||
return this.wagonRepo.save(wagon);
|
return this.wagonRepo.save(wagon);
|
||||||
}
|
}
|
||||||
|
|
||||||
async findAll(): Promise<Wagon[]> {
|
async findAll(query: Record<string, string | undefined> = {}): Promise<Wagon[]> {
|
||||||
return this.wagonRepo.find({ order: { wagonNumber: 'ASC' } });
|
const where: FindOptionsWhere<Wagon>[] | FindOptionsWhere<Wagon> = [];
|
||||||
|
const search = query.search?.trim();
|
||||||
|
const status = query.status?.trim();
|
||||||
|
const trainId = query.trainId?.trim();
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
where.push({
|
||||||
|
wagonNumber: ILike(`%${search}%`),
|
||||||
|
...(status ? { status } : {}),
|
||||||
|
...(trainId ? { trainId } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortBy = ['wagonNumber', 'tareWeight', 'maxPayloadWeight', 'status', 'sequenceNumber'].includes(query.sortBy ?? '')
|
||||||
|
? (query.sortBy as keyof Wagon)
|
||||||
|
: 'wagonNumber';
|
||||||
|
const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
|
||||||
|
|
||||||
|
return this.wagonRepo.find({
|
||||||
|
where: search ? where : { ...(status ? { status } : {}), ...(trainId ? { trainId } : {}) },
|
||||||
|
order: { [sortBy]: sortOrder } as FindOptionsOrder<Wagon>,
|
||||||
|
skip: query.page && query.limit ? (Number(query.page) - 1) * Number(query.limit) : undefined,
|
||||||
|
take: query.limit ? Number(query.limit) : undefined,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async findById(id: string): Promise<Wagon> {
|
async findById(id: string): Promise<Wagon> {
|
||||||
@@ -39,8 +62,6 @@ export class WagonsService {
|
|||||||
async update(id: string, dto: UpdateWagonDto): Promise<Wagon> {
|
async update(id: string, dto: UpdateWagonDto): Promise<Wagon> {
|
||||||
const wagon = await this.findById(id);
|
const wagon = await this.findById(id);
|
||||||
Object.assign(wagon, dto);
|
Object.assign(wagon, dto);
|
||||||
if (dto.trainId === undefined) wagon.trainId = null;
|
|
||||||
if (dto.sequenceNumber === undefined) wagon.sequenceNumber = null;
|
|
||||||
return this.wagonRepo.save(wagon);
|
return this.wagonRepo.save(wagon);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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 { BookingContainer } from "../modules/bookings/entities/booking-container.entity";
|
||||||
import { Booking } from "../modules/bookings/entities/booking.entity";
|
import { Booking } from "../modules/bookings/entities/booking.entity";
|
||||||
import { Customer } from "../modules/customers/entities/customer.entity";
|
import {
|
||||||
|
Company,
|
||||||
|
CompanyStatus,
|
||||||
|
CompanyType,
|
||||||
|
} from "../modules/companies/entities/company.entity";
|
||||||
import { Locomotive } from "../modules/locomotives/entities/locomotive.entity";
|
import { Locomotive } from "../modules/locomotives/entities/locomotive.entity";
|
||||||
import { ServiceType } from "../modules/rule-engine/entities/service-type.entity";
|
import { ServiceType } from "../modules/rule-engine/entities/service-type.entity";
|
||||||
import { Yard } from "../modules/rule-engine/entities/yard.entity";
|
import { Yard } from "../modules/rule-engine/entities/yard.entity";
|
||||||
@@ -14,7 +18,8 @@ import { ContainerType } from "../modules/rule-engine/entities/container-type.en
|
|||||||
const SEED_FLAG = "SEED_DEMO_BOOKINGS";
|
const SEED_FLAG = "SEED_DEMO_BOOKINGS";
|
||||||
|
|
||||||
const SERVICE_TYPE_CODE = "RAIL_CONTAINER";
|
const SERVICE_TYPE_CODE = "RAIL_CONTAINER";
|
||||||
const CUSTOMER_EMAIL = "train-scheduling-demo@edr.local";
|
const COMPANY_EMAIL = "train-scheduling-demo@edr.local";
|
||||||
|
const COMPANY_TIN = "1234567890";
|
||||||
|
|
||||||
const YARDS = [
|
const YARDS = [
|
||||||
{ code: "DJIBOUTI", label: "Djibouti", country: "Djibouti", displayOrder: 1 },
|
{ code: "DJIBOUTI", label: "Djibouti", country: "Djibouti", displayOrder: 1 },
|
||||||
@@ -183,39 +188,35 @@ export class DemoBookingsSeeder {
|
|||||||
{ conflictPaths: { code: true } },
|
{ conflictPaths: { code: true } },
|
||||||
);
|
);
|
||||||
|
|
||||||
await manager.getRepository(Customer).upsert(
|
await manager.getRepository(Company).upsert(
|
||||||
{
|
{
|
||||||
userId: "00000000-0000-0000-0000-000000000111",
|
name: "Train Scheduling Demo Customer",
|
||||||
firstName: "Train",
|
type: CompanyType.Customer,
|
||||||
lastName: "Scheduling",
|
status: CompanyStatus.Active,
|
||||||
email: CUSTOMER_EMAIL,
|
tin: COMPANY_TIN,
|
||||||
phone: "251900000001",
|
|
||||||
companyName: "Train Scheduling Demo Customer",
|
|
||||||
companyEmail: CUSTOMER_EMAIL,
|
|
||||||
companyPhone: "251900000001",
|
|
||||||
companyLocation: "Addis Ababa",
|
|
||||||
companyAddress: "Demo Address",
|
|
||||||
customerType: "DEMO",
|
|
||||||
status: "ACTIVE",
|
|
||||||
contactPersonName: "Train Scheduling",
|
|
||||||
contactPersonPhone: "251900000001",
|
|
||||||
tinNumber: "1234567890",
|
|
||||||
vatNumber: "1234567890",
|
vatNumber: "1234567890",
|
||||||
fanNumber: "1234567890123456",
|
fanNumber: "1234567890123456",
|
||||||
|
country: "Ethiopia",
|
||||||
|
address: "Demo Address",
|
||||||
|
phone: "251900000001",
|
||||||
|
email: COMPANY_EMAIL,
|
||||||
|
website: null,
|
||||||
|
contactPersonName: "Train Scheduling",
|
||||||
|
contactPersonPhone: "251900000001",
|
||||||
generalManagerName: "Demo Manager",
|
generalManagerName: "Demo Manager",
|
||||||
generalManagerEmail: CUSTOMER_EMAIL,
|
generalManagerEmail: COMPANY_EMAIL,
|
||||||
generalManagerPhone: "251900000001",
|
generalManagerPhone: "251900000001",
|
||||||
},
|
},
|
||||||
{ conflictPaths: { email: true } },
|
{ conflictPaths: { tin: true } },
|
||||||
);
|
);
|
||||||
|
|
||||||
const [serviceType, customer, yards, containerTypes] = await Promise.all([
|
const [serviceType, company, yards, containerTypes] = await Promise.all([
|
||||||
manager
|
manager
|
||||||
.getRepository(ServiceType)
|
.getRepository(ServiceType)
|
||||||
.findOneByOrFail({ code: SERVICE_TYPE_CODE }),
|
.findOneByOrFail({ code: SERVICE_TYPE_CODE }),
|
||||||
manager
|
manager
|
||||||
.getRepository(Customer)
|
.getRepository(Company)
|
||||||
.findOneByOrFail({ email: CUSTOMER_EMAIL }),
|
.findOneByOrFail({ tin: COMPANY_TIN }),
|
||||||
manager.getRepository(Yard).find(),
|
manager.getRepository(Yard).find(),
|
||||||
manager.getRepository(ContainerType).find(),
|
manager.getRepository(ContainerType).find(),
|
||||||
]);
|
]);
|
||||||
@@ -247,7 +248,7 @@ export class DemoBookingsSeeder {
|
|||||||
await manager.getRepository(Booking).upsert(
|
await manager.getRepository(Booking).upsert(
|
||||||
{
|
{
|
||||||
reference: demoBooking.reference,
|
reference: demoBooking.reference,
|
||||||
companyId: customer.id,
|
companyId: company.id,
|
||||||
status: "APPROVED",
|
status: "APPROVED",
|
||||||
scheduledDate: new Date(demoBooking.scheduledDate),
|
scheduledDate: new Date(demoBooking.scheduledDate),
|
||||||
totalAmount: 0,
|
totalAmount: 0,
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
|
|||||||
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
|
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
|
||||||
import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page";
|
import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page";
|
||||||
import OverviewPage from "./pages/dashboard/OverviewPage";
|
import OverviewPage from "./pages/dashboard/OverviewPage";
|
||||||
import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage";
|
//import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage";
|
||||||
import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage";
|
import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage";
|
||||||
import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage";
|
import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage";
|
||||||
import RolesPage from "./pages/dashboard/user-management/RolesPage";
|
import RolesPage from "./pages/dashboard/user-management/RolesPage";
|
||||||
@@ -34,13 +34,15 @@ import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage
|
|||||||
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
|
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
|
||||||
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
|
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
|
||||||
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
|
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
|
||||||
// import TrainsPage from "./pages/trains/TrainsPage";
|
import TrainsPage from "./pages/trains/TrainsPage";
|
||||||
|
import {
|
||||||
|
CargoesCrudPage,
|
||||||
|
ContainersCrudPage,
|
||||||
|
TrainMasterDataPage,
|
||||||
|
WagonsCrudPage,
|
||||||
|
} from "./pages/fleet/FleetCrudPages";
|
||||||
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
|
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
|
||||||
//import TrainsPage from "./pages/trains/TrainsPage";
|
|
||||||
import TrainDetailPage from "./pages/trains/TrainDetailPage";
|
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";
|
|
||||||
|
|
||||||
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||||
{
|
{
|
||||||
@@ -240,13 +242,12 @@ const App = () => {
|
|||||||
path="booking-requests/:id/contract"
|
path="booking-requests/:id/contract"
|
||||||
element={<BookingContractPage />}
|
element={<BookingContractPage />}
|
||||||
/>
|
/>
|
||||||
{/* <Route path="operations/train-scheduling" element={<TrainsPage />} />
|
<Route path="operations/train-scheduling" element={<TrainsPage />} />
|
||||||
|
<Route path="trains" element={<TrainMasterDataPage />} />
|
||||||
<Route path="trains" element={<TrainsPage />} /> */}
|
|
||||||
<Route path="trains/:id" element={<TrainDetailPage />} />
|
<Route path="trains/:id" element={<TrainDetailPage />} />
|
||||||
<Route path="wagons" element={<WagonsPage />} />
|
<Route path="wagons" element={<WagonsCrudPage />} />
|
||||||
<Route path="containers" element={<ContainersPage />} />
|
<Route path="containers" element={<ContainersCrudPage />} />
|
||||||
<Route path="cargoes" element={<CargoesPage />} />
|
<Route path="cargoes" element={<CargoesCrudPage />} />
|
||||||
|
|
||||||
<Route path="user-management" element={<UserManagementPage />} />
|
<Route path="user-management" element={<UserManagementPage />} />
|
||||||
<Route path="user-management/users" element={<UsersPage />} />
|
<Route path="user-management/users" element={<UsersPage />} />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
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 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
||||||
|
|
||||||
|
export default function ContainerFormDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
container,
|
||||||
|
wagons = [],
|
||||||
|
onSuccess,
|
||||||
|
}: ContainerFormDialogProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
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 createMutation = useMutation({
|
||||||
|
mutationFn: (data: Partial<Container>) =>
|
||||||
|
axios.post(`${API_BASE_URL}/api/containers`, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Container created successfully');
|
||||||
|
onOpenChange(false);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['containers'] });
|
||||||
|
onSuccess?.();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
const message = axios.isAxiosError(error)
|
||||||
|
? error.response?.data?.message || 'Failed to create container'
|
||||||
|
: 'Failed to create container';
|
||||||
|
toast.error(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: (data: Partial<Container>) =>
|
||||||
|
axios.patch(`${API_BASE_URL}/api/containers/${container?.id}`, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Container updated successfully');
|
||||||
|
onOpenChange(false);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['containers'] });
|
||||||
|
onSuccess?.();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
const message = axios.isAxiosError(error)
|
||||||
|
? error.response?.data?.message || 'Failed to update container'
|
||||||
|
: 'Failed to update container';
|
||||||
|
toast.error(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!formData.containerNumber || !formData.containerTypeId) {
|
||||||
|
toast.error('Please fill in all required fields');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (container?.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>
|
||||||
|
{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">Type *</Label>
|
||||||
|
<Input
|
||||||
|
id="containerTypeId"
|
||||||
|
value={formData.containerTypeId || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, containerTypeId: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="e.g., 20ft Box"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="wagonId">Wagon (Optional)</Label>
|
||||||
|
<select
|
||||||
|
id="wagonId"
|
||||||
|
value={formData.wagonId || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, wagonId: 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 wagon...</option>
|
||||||
|
{wagons.map(wagon => (
|
||||||
|
<option key={wagon.id} value={wagon.id}>
|
||||||
|
{wagon.wagonNumber}
|
||||||
|
</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="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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
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 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 [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>
|
||||||
|
<Input
|
||||||
|
id="wagonTypeId"
|
||||||
|
value={formData.wagonTypeId || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, wagonTypeId: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="e.g., Flat Bed"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</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 { useWagonsByTrain, useUnassignWagon, useReorderWagons } from '@/hooks/useWagons';
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
//import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||||
import { Button } from '@/components/ui/button';
|
//import { Button } from '@/components/ui/button';
|
||||||
import { Trash2, GripVertical } from 'lucide-react';
|
//import { Trash2, GripVertical } from 'lucide-react';
|
||||||
// import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
|
// import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
|
||||||
|
|
||||||
export function WagonsTable({ trainId }: { trainId: string }) {
|
export function WagonsTable({ trainId }: { trainId: string }) {
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ export const URL_CONSTANTS = {
|
|||||||
|
|
||||||
BOOKINGS: {
|
BOOKINGS: {
|
||||||
BASE: "/bookings",
|
BASE: "/bookings",
|
||||||
|
REFERENCE_DATA: "/bookings/reference-data",
|
||||||
LIST_SUMMARY: "/bookings/list-summary",
|
LIST_SUMMARY: "/bookings/list-summary",
|
||||||
BY_ID: (id: string) => `/bookings/${id}`,
|
BY_ID: (id: string) => `/bookings/${id}`,
|
||||||
QUEUE: (queue: string) => `/bookings/queues/${queue}`,
|
QUEUE: (queue: string) => `/bookings/queues/${queue}`,
|
||||||
|
|||||||
@@ -4,16 +4,44 @@ import { cargoService } from '@/services/cargoService';
|
|||||||
export const cargoKeys = {
|
export const cargoKeys = {
|
||||||
all: ['cargoes'] as const,
|
all: ['cargoes'] as const,
|
||||||
byContainer: (containerId: string) => [...cargoKeys.all, 'container', containerId] as const,
|
byContainer: (containerId: string) => [...cargoKeys.all, 'container', containerId] as const,
|
||||||
|
details: () => [...cargoKeys.all, 'detail'] as const,
|
||||||
|
detail: (id: string) => [...cargoKeys.details(), id] as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useCargoes() {
|
export function useCargoes() {
|
||||||
return useQuery({ queryKey: cargoKeys.all, queryFn: () => cargoService.getAll().then(res => res.data) });
|
return useQuery({ queryKey: cargoKeys.all, queryFn: () => cargoService.getAll().then(res => res.data) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const useGetCargoes = useCargoes;
|
||||||
|
|
||||||
export function useCargoesByContainer(containerId: string) {
|
export function useCargoesByContainer(containerId: string) {
|
||||||
return useQuery({ queryKey: cargoKeys.byContainer(containerId), queryFn: () => cargoService.getByContainer(containerId).then(res => res.data), enabled: !!containerId });
|
return useQuery({ queryKey: cargoKeys.byContainer(containerId), queryFn: () => cargoService.getByContainer(containerId).then(res => res.data), enabled: !!containerId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useCargo(id: string) {
|
||||||
|
return useQuery({ queryKey: cargoKeys.detail(id), queryFn: () => cargoService.getById(id).then(res => res.data), enabled: !!id });
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useGetCargo = useCargo;
|
||||||
|
|
||||||
|
export function useCreateCargo() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({ mutationFn: cargoService.create, onSuccess: () => qc.invalidateQueries({ queryKey: cargoKeys.all }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateCargo() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({ mutationFn: ({ id, data }: any) => cargoService.update(id, data), onSuccess: (_, { id }) => {
|
||||||
|
qc.invalidateQueries({ queryKey: cargoKeys.all });
|
||||||
|
qc.invalidateQueries({ queryKey: cargoKeys.detail(id) });
|
||||||
|
} });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteCargo() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({ mutationFn: cargoService.delete, onSuccess: () => qc.invalidateQueries({ queryKey: cargoKeys.all }) });
|
||||||
|
}
|
||||||
|
|
||||||
export function useLoadCargo() {
|
export function useLoadCargo() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
@@ -4,16 +4,44 @@ import { containerService } from '@/services/containerService';
|
|||||||
export const containerKeys = {
|
export const containerKeys = {
|
||||||
all: ['containers'] as const,
|
all: ['containers'] as const,
|
||||||
byWagon: (wagonId: string) => [...containerKeys.all, 'wagon', wagonId] as const,
|
byWagon: (wagonId: string) => [...containerKeys.all, 'wagon', wagonId] as const,
|
||||||
|
details: () => [...containerKeys.all, 'detail'] as const,
|
||||||
|
detail: (id: string) => [...containerKeys.details(), id] as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useContainers() {
|
export function useContainers() {
|
||||||
return useQuery({ queryKey: containerKeys.all, queryFn: () => containerService.getAll().then(res => res.data) });
|
return useQuery({ queryKey: containerKeys.all, queryFn: () => containerService.getAll().then(res => res.data) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const useGetContainers = useContainers;
|
||||||
|
|
||||||
export function useContainersByWagon(wagonId: string) {
|
export function useContainersByWagon(wagonId: string) {
|
||||||
return useQuery({ queryKey: containerKeys.byWagon(wagonId), queryFn: () => containerService.getByWagon(wagonId).then(res => res.data), enabled: !!wagonId });
|
return useQuery({ queryKey: containerKeys.byWagon(wagonId), queryFn: () => containerService.getByWagon(wagonId).then(res => res.data), enabled: !!wagonId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useContainer(id: string) {
|
||||||
|
return useQuery({ queryKey: containerKeys.detail(id), queryFn: () => containerService.getById(id).then(res => res.data), enabled: !!id });
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useGetContainer = useContainer;
|
||||||
|
|
||||||
|
export function useCreateContainer() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({ mutationFn: containerService.create, onSuccess: () => qc.invalidateQueries({ queryKey: containerKeys.all }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateContainer() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({ mutationFn: ({ id, data }: any) => containerService.update(id, data), onSuccess: (_, { id }) => {
|
||||||
|
qc.invalidateQueries({ queryKey: containerKeys.all });
|
||||||
|
qc.invalidateQueries({ queryKey: containerKeys.detail(id) });
|
||||||
|
} });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteContainer() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({ mutationFn: containerService.delete, onSuccess: () => qc.invalidateQueries({ queryKey: containerKeys.all }) });
|
||||||
|
}
|
||||||
|
|
||||||
export function useAssignContainerToWagon() {
|
export function useAssignContainerToWagon() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
@@ -12,10 +12,14 @@ export function useTrains() {
|
|||||||
return useQuery({ queryKey: trainKeys.lists(), queryFn: () => trainService.getAll().then(res => res.data) });
|
return useQuery({ queryKey: trainKeys.lists(), queryFn: () => trainService.getAll().then(res => res.data) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const useGetTrains = useTrains;
|
||||||
|
|
||||||
export function useTrain(id: string) {
|
export function useTrain(id: string) {
|
||||||
return useQuery({ queryKey: trainKeys.detail(id), queryFn: () => trainService.getById(id).then(res => res.data), enabled: !!id });
|
return useQuery({ queryKey: trainKeys.detail(id), queryFn: () => trainService.getById(id).then(res => res.data), enabled: !!id });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const useGetTrain = useTrain;
|
||||||
|
|
||||||
export function useCreateTrain() {
|
export function useCreateTrain() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({ mutationFn: trainService.create, onSuccess: () => qc.invalidateQueries({ queryKey: trainKeys.lists() }) });
|
return useMutation({ mutationFn: trainService.create, onSuccess: () => qc.invalidateQueries({ queryKey: trainKeys.lists() }) });
|
||||||
|
|||||||
@@ -5,16 +5,25 @@ export const wagonKeys = {
|
|||||||
all: ['wagons'] as const,
|
all: ['wagons'] as const,
|
||||||
byTrain: (trainId: string) => [...wagonKeys.all, 'train', trainId] as const,
|
byTrain: (trainId: string) => [...wagonKeys.all, 'train', trainId] as const,
|
||||||
details: () => [...wagonKeys.all, 'detail'] as const,
|
details: () => [...wagonKeys.all, 'detail'] as const,
|
||||||
|
detail: (id: string) => [...wagonKeys.details(), id] as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useWagons() {
|
export function useWagons() {
|
||||||
return useQuery({ queryKey: wagonKeys.all, queryFn: () => wagonService.getAll().then(res => res.data) });
|
return useQuery({ queryKey: wagonKeys.all, queryFn: () => wagonService.getAll().then(res => res.data) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const useGetWagons = useWagons;
|
||||||
|
|
||||||
export function useWagonsByTrain(trainId: string) {
|
export function useWagonsByTrain(trainId: string) {
|
||||||
return useQuery({ queryKey: wagonKeys.byTrain(trainId), queryFn: () => wagonService.getByTrain(trainId).then(res => res.data), enabled: !!trainId });
|
return useQuery({ queryKey: wagonKeys.byTrain(trainId), queryFn: () => wagonService.getByTrain(trainId).then(res => res.data), enabled: !!trainId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useWagon(id: string) {
|
||||||
|
return useQuery({ queryKey: wagonKeys.detail(id), queryFn: () => wagonService.getById(id).then(res => res.data), enabled: !!id });
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useGetWagon = useWagon;
|
||||||
|
|
||||||
export function useAssignWagonToTrain() {
|
export function useAssignWagonToTrain() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({ mutationFn: ({ wagonId, trainId, sequenceNumber }: any) => wagonService.assignToTrain(wagonId, trainId, sequenceNumber), onSuccess: (_, { trainId }) => qc.invalidateQueries({ queryKey: wagonKeys.byTrain(trainId) }) });
|
return useMutation({ mutationFn: ({ wagonId, trainId, sequenceNumber }: any) => wagonService.assignToTrain(wagonId, trainId, sequenceNumber), onSuccess: (_, { trainId }) => qc.invalidateQueries({ queryKey: wagonKeys.byTrain(trainId) }) });
|
||||||
@@ -37,5 +46,13 @@ export function useCreateWagon() {
|
|||||||
|
|
||||||
export function useUpdateWagon() {
|
export function useUpdateWagon() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({ mutationFn: ({ id, data }: any) => wagonService.update(id, data), onSuccess: () => qc.invalidateQueries({ queryKey: wagonKeys.all }) });
|
return useMutation({ mutationFn: ({ id, data }: any) => wagonService.update(id, data), onSuccess: (_, { id }) => {
|
||||||
|
qc.invalidateQueries({ queryKey: wagonKeys.all });
|
||||||
|
qc.invalidateQueries({ queryKey: wagonKeys.detail(id) });
|
||||||
|
} });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteWagon() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({ mutationFn: wagonService.delete, onSuccess: () => qc.invalidateQueries({ queryKey: wagonKeys.all }) });
|
||||||
}
|
}
|
||||||
@@ -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,464 @@
|
|||||||
|
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||||
|
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 Field = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
type?: 'text' | 'number';
|
||||||
|
required?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
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, string | number>;
|
||||||
|
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, string | number>) =>
|
||||||
|
Object.fromEntries(
|
||||||
|
Object.entries(values)
|
||||||
|
.map(([key, value]) => [key, typeof value === 'string' ? value.trim() : value])
|
||||||
|
.filter(([, value]) => value !== ''),
|
||||||
|
);
|
||||||
|
|
||||||
|
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 { 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);
|
||||||
|
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] ?? '']),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setFormOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeForm = () => {
|
||||||
|
setFormOpen(false);
|
||||||
|
setEditing(null);
|
||||||
|
setForm(emptyValues);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (event: FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const payload = normalizePayload(form);
|
||||||
|
|
||||||
|
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 {
|
||||||
|
toast({ title: 'Save failed', description: 'Please check the fields and try again.', 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) => (
|
||||||
|
<div key={field.key} className="space-y-2">
|
||||||
|
<Label htmlFor={field.key}>{field.label}</Label>
|
||||||
|
<Input
|
||||||
|
id={field.key}
|
||||||
|
type={field.type ?? 'text'}
|
||||||
|
required={field.required}
|
||||||
|
value={form[field.key] ?? ''}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
[field.key]: field.type === 'number' ? Number(event.target.value) : event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</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>;
|
||||||
|
|
||||||
|
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();
|
||||||
|
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 ID' },
|
||||||
|
{ 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 ID', required: true },
|
||||||
|
{ 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();
|
||||||
|
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 ID' },
|
||||||
|
{ key: 'wagonId', label: 'Wagon', render: (container) => container.wagonId || 'Unassigned' },
|
||||||
|
{ 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 ID', required: true },
|
||||||
|
{ key: 'wagonId', label: 'Wagon ID' },
|
||||||
|
{ 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();
|
||||||
|
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: 'containerId', label: 'Container ID' },
|
||||||
|
{ 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 ID', required: true },
|
||||||
|
{ key: 'cargoTypeId', label: 'Cargo type ID' },
|
||||||
|
{ 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' }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -80,7 +80,7 @@ const deriveFromBooking = (
|
|||||||
|
|
||||||
const TrainsPage = () => {
|
const TrainsPage = () => {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [filters, setFilters] = useState<TrainScheduleFilters>({});
|
const [filters, setFilters] = useState<TrainScheduleFilters>({ status: 'PAID' });
|
||||||
const [selectedBookingIds, setSelectedBookingIds] = useState<string[]>([]);
|
const [selectedBookingIds, setSelectedBookingIds] = useState<string[]>([]);
|
||||||
const [preview, setPreview] = useState<TrainSchedulePreviewResponse | null>(null);
|
const [preview, setPreview] = useState<TrainSchedulePreviewResponse | null>(null);
|
||||||
const [selectedLocomotiveId, setSelectedLocomotiveId] = useState('');
|
const [selectedLocomotiveId, setSelectedLocomotiveId] = useState('');
|
||||||
@@ -360,17 +360,24 @@ const TrainsPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">Booking status</label>
|
<label className="text-sm font-medium">Booking status</label>
|
||||||
<input
|
<Select
|
||||||
className={inputClassName}
|
value={filters.status ?? 'PAID'}
|
||||||
placeholder="APPROVED"
|
onValueChange={(value) =>
|
||||||
value={filters.status ?? ''}
|
|
||||||
onChange={(event) =>
|
|
||||||
setFilters((current) => ({
|
setFilters((current) => ({
|
||||||
...current,
|
...current,
|
||||||
status: event.target.value || undefined,
|
status: value === '__all__' ? undefined : value,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
/>
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Paid bookings" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="PAID">Paid</SelectItem>
|
||||||
|
<SelectItem value="FULLY_EXECUTED">Fully executed</SelectItem>
|
||||||
|
<SelectItem value="APPROVED">Approved</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -19,8 +19,11 @@ export interface Cargo {
|
|||||||
|
|
||||||
export const cargoService = {
|
export const cargoService = {
|
||||||
getAll: () => apiClient.get<Cargo[]>('/cargoes'),
|
getAll: () => apiClient.get<Cargo[]>('/cargoes'),
|
||||||
|
getById: (id: string) => apiClient.get<Cargo>(`/cargoes/${id}`),
|
||||||
getByContainer: (containerId: string) => apiClient.get<Cargo[]>(`/cargoes?containerId=${containerId}`),
|
getByContainer: (containerId: string) => apiClient.get<Cargo[]>(`/cargoes?containerId=${containerId}`),
|
||||||
create: (data: any) => apiClient.post('/cargoes', data),
|
create: (data: Partial<Cargo>) => apiClient.post('/cargoes', data),
|
||||||
|
update: (id: string, data: Partial<Cargo>) => apiClient.patch(`/cargoes/${id}`, data),
|
||||||
|
delete: (id: string) => apiClient.delete(`/cargoes/${id}`),
|
||||||
load: (cargoId: string, quantity: number, weight: number, volume?: number) =>
|
load: (cargoId: string, quantity: number, weight: number, volume?: number) =>
|
||||||
apiClient.post(`/cargoes/${cargoId}/load`, { quantity, weight, volume }),
|
apiClient.post(`/cargoes/${cargoId}/load`, { quantity, weight, volume }),
|
||||||
deliver: (cargoId: string) => apiClient.post(`/cargoes/${cargoId}/deliver`),
|
deliver: (cargoId: string) => apiClient.post(`/cargoes/${cargoId}/deliver`),
|
||||||
|
|||||||
@@ -16,7 +16,11 @@ export interface Container {
|
|||||||
|
|
||||||
export const containerService = {
|
export const containerService = {
|
||||||
getAll: () => apiClient.get<Container[]>('/containers'),
|
getAll: () => apiClient.get<Container[]>('/containers'),
|
||||||
|
getById: (id: string) => apiClient.get<Container>(`/containers/${id}`),
|
||||||
getByWagon: (wagonId: string) => apiClient.get<Container[]>(`/containers?wagonId=${wagonId}`),
|
getByWagon: (wagonId: string) => apiClient.get<Container[]>(`/containers?wagonId=${wagonId}`),
|
||||||
|
create: (data: Partial<Container>) => apiClient.post('/containers', data),
|
||||||
|
update: (id: string, data: Partial<Container>) => apiClient.patch(`/containers/${id}`, data),
|
||||||
|
delete: (id: string) => apiClient.delete(`/containers/${id}`),
|
||||||
assignToWagon: (containerId: string, wagonId: string, position?: number) =>
|
assignToWagon: (containerId: string, wagonId: string, position?: number) =>
|
||||||
apiClient.post(`/containers/${containerId}/assign-wagon`, { wagonId, position }),
|
apiClient.post(`/containers/${containerId}/assign-wagon`, { wagonId, position }),
|
||||||
unassign: (containerId: string) => apiClient.post(`/containers/${containerId}/unassign-wagon`),
|
unassign: (containerId: string) => apiClient.post(`/containers/${containerId}/unassign-wagon`),
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import type {
|
|||||||
} from '@/types/trainScheduling';
|
} from '@/types/trainScheduling';
|
||||||
|
|
||||||
interface BookingReferenceDataResponse {
|
interface BookingReferenceDataResponse {
|
||||||
yard?: YardOption[];
|
yard?: Array<YardOption & { label?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const trainSchedulingService = {
|
export const trainSchedulingService = {
|
||||||
@@ -82,6 +82,11 @@ export const trainSchedulingService = {
|
|||||||
URL_CONSTANTS.BOOKINGS.REFERENCE_DATA,
|
URL_CONSTANTS.BOOKINGS.REFERENCE_DATA,
|
||||||
);
|
);
|
||||||
const data = unwrap(response.data);
|
const data = unwrap(response.data);
|
||||||
return data.yard ?? [];
|
return (data.yard ?? []).map((yard) => ({
|
||||||
|
id: yard.id,
|
||||||
|
name: yard.name ?? yard.label ?? yard.code,
|
||||||
|
code: yard.code,
|
||||||
|
country: yard.country,
|
||||||
|
}));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,13 +14,14 @@ export interface Wagon {
|
|||||||
|
|
||||||
export const wagonService = {
|
export const wagonService = {
|
||||||
getAll: () => apiClient.get<Wagon[]>('/wagons'),
|
getAll: () => apiClient.get<Wagon[]>('/wagons'),
|
||||||
|
getById: (id: string) => apiClient.get<Wagon>(`/wagons/${id}`),
|
||||||
getByTrain: (trainId: string) => apiClient.get<Wagon[]>(`/wagons?trainId=${trainId}`),
|
getByTrain: (trainId: string) => apiClient.get<Wagon[]>(`/wagons?trainId=${trainId}`),
|
||||||
assignToTrain: (wagonId: string, trainId: string, sequenceNumber?: number) =>
|
assignToTrain: (wagonId: string, trainId: string, sequenceNumber?: number) =>
|
||||||
apiClient.post(`/wagons/${wagonId}/assign-train`, { trainId, sequenceNumber }),
|
apiClient.post(`/wagons/${wagonId}/assign-train`, { trainId, sequenceNumber }),
|
||||||
unassign: (wagonId: string) => apiClient.post(`/wagons/${wagonId}/unassign-train`),
|
unassign: (wagonId: string) => apiClient.post(`/wagons/${wagonId}/unassign-train`),
|
||||||
reorder: (trainId: string, wagonIds: string[]) =>
|
reorder: (trainId: string, wagonIds: string[]) =>
|
||||||
apiClient.post(`/trains/${trainId}/reorder-wagons`, { wagonIds }),
|
apiClient.post(`/trains/${trainId}/reorder-wagons`, { wagonIds }),
|
||||||
create: (data: Partial<Wagon>) => apiClient.post('/wagons', data),
|
create: (data: Partial<Wagon>) => apiClient.post('/wagons', data),
|
||||||
update: (id: string, data: Partial<Wagon>) => apiClient.patch(`/wagons/${id}`, data),
|
update: (id: string, data: Partial<Wagon>) => apiClient.patch(`/wagons/${id}`, data),
|
||||||
|
delete: (id: string) => apiClient.delete(`/wagons/${id}`),
|
||||||
};
|
};
|
||||||
1780
pnpm-lock.yaml
generated
1780
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user