diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index b6898322b..eb1f4ae74 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -48,6 +48,7 @@ import { WagonsModule } from './modules/wagons/wagons.module'; import { ContainersModule } from './modules/container-management/containers.module'; import { CargoesModule } from './modules/cargoes/cargoes.module'; import { RoutesModule } from './modules/routes/routes.module'; +import { WarehousesModule } from './modules/warehouses/warehouses.module'; @Module({ imports: [ @@ -100,6 +101,7 @@ import { RoutesModule } from './modules/routes/routes.module'; ContainersModule, CargoesModule, RoutesModule, + WarehousesModule, ], providers: [EdrOrgSeeder, DemoUsersSeeder,FreightStaffUsersSeeder, DemoBookingsSeeder, PricingDataSeeder, FileUploadSettingsSeeder], }) diff --git a/apps/edr-freight-api/src/migrations/1790000000000-CreateWarehouseModule.ts b/apps/edr-freight-api/src/migrations/1790000000000-CreateWarehouseModule.ts new file mode 100644 index 000000000..b921a7194 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1790000000000-CreateWarehouseModule.ts @@ -0,0 +1,124 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateWarehouseModule1790000000000 implements MigrationInterface { + name = 'CreateWarehouseModule1790000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.warehouses ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(160) NOT NULL, + code VARCHAR(40) NOT NULL UNIQUE, + type VARCHAR(32) NOT NULL, + station_id UUID NULL, + location_name VARCHAR(200) NULL, + capacity_weight NUMERIC(14,3) NULL, + capacity_containers INT NULL, + current_weight NUMERIC(14,3) NOT NULL DEFAULT 0, + current_containers INT NOT NULL DEFAULT 0, + status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL + ); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.warehouse_yards ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + warehouse_id UUID NOT NULL REFERENCES freight.warehouses(id) ON DELETE CASCADE, + name VARCHAR(160) NOT NULL, + code VARCHAR(40) NOT NULL, + type VARCHAR(32) NOT NULL, + capacity_weight NUMERIC(14,3) NULL, + capacity_containers INT NULL, + current_weight NUMERIC(14,3) NOT NULL DEFAULT 0, + current_containers INT NOT NULL DEFAULT 0, + status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL, + CONSTRAINT uq_warehouse_yards_code UNIQUE (warehouse_id, code) + ); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.warehouse_zones ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + yard_id UUID NOT NULL REFERENCES freight.warehouse_yards(id) ON DELETE CASCADE, + name VARCHAR(160) NOT NULL, + code VARCHAR(40) NOT NULL, + type VARCHAR(32) NOT NULL, + capacity_weight NUMERIC(14,3) NULL, + capacity_containers INT NULL, + current_weight NUMERIC(14,3) NOT NULL DEFAULT 0, + current_containers INT NOT NULL DEFAULT 0, + status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL, + CONSTRAINT uq_warehouse_zones_code UNIQUE (yard_id, code) + ); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.warehouse_inventory ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + warehouse_id UUID NOT NULL REFERENCES freight.warehouses(id), + yard_id UUID NOT NULL REFERENCES freight.warehouse_yards(id), + zone_id UUID NOT NULL REFERENCES freight.warehouse_zones(id), + booking_id UUID NOT NULL, + cargo_id UUID NULL, + container_id UUID NULL, + goods_id UUID NULL, + quantity NUMERIC(12,3) NOT NULL DEFAULT 0, + weight NUMERIC(14,3) NOT NULL DEFAULT 0, + volume NUMERIC(12,3) NULL, + status VARCHAR(32) NOT NULL DEFAULT 'ARRIVED_AT_WAREHOUSE', + arrived_at TIMESTAMPTZ NULL, + inspected_at TIMESTAMPTZ NULL, + ready_for_loading_at TIMESTAMPTZ NULL, + notes TEXT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL + ); + `); + + const indexes: Array<[string, string, string]> = [ + ['idx_warehouses_type', 'warehouses', 'type'], + ['idx_warehouses_status', 'warehouses', 'status'], + ['idx_warehouses_station_id', 'warehouses', 'station_id'], + ['idx_warehouse_yards_warehouse_id', 'warehouse_yards', 'warehouse_id'], + ['idx_warehouse_yards_type', 'warehouse_yards', 'type'], + ['idx_warehouse_yards_status', 'warehouse_yards', 'status'], + ['idx_warehouse_zones_yard_id', 'warehouse_zones', 'yard_id'], + ['idx_warehouse_zones_type', 'warehouse_zones', 'type'], + ['idx_warehouse_zones_status', 'warehouse_zones', 'status'], + ['idx_warehouse_inventory_warehouse_id', 'warehouse_inventory', 'warehouse_id'], + ['idx_warehouse_inventory_yard_id', 'warehouse_inventory', 'yard_id'], + ['idx_warehouse_inventory_zone_id', 'warehouse_inventory', 'zone_id'], + ['idx_warehouse_inventory_booking_id', 'warehouse_inventory', 'booking_id'], + ['idx_warehouse_inventory_cargo_id', 'warehouse_inventory', 'cargo_id'], + ['idx_warehouse_inventory_container_id', 'warehouse_inventory', 'container_id'], + ['idx_warehouse_inventory_goods_id', 'warehouse_inventory', 'goods_id'], + ['idx_warehouse_inventory_status', 'warehouse_inventory', 'status'], + ]; + + for (const [indexName, table, column] of indexes) { + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS ${indexName} ON freight.${table}(${column});`, + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_inventory;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_zones;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_yards;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouses;`); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-yard.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-yard.dto.ts new file mode 100644 index 000000000..18dc12c78 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-yard.dto.ts @@ -0,0 +1,37 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; + +import { WAREHOUSE_YARD_TYPES, WarehouseYardType } from '../entities/warehouse-yard.entity'; + +export class CreateWarehouseYardDto { + @ApiPropertyOptional({ format: 'uuid', description: 'Optional — taken from the route param when omitted' }) + @IsOptional() + @IsUUID() + warehouseId?: string; + + @ApiProperty() + @IsString() + @MaxLength(160) + name!: string; + + @ApiProperty() + @IsString() + @MaxLength(40) + code!: string; + + @ApiProperty({ enum: WAREHOUSE_YARD_TYPES }) + @IsEnum(WAREHOUSE_YARD_TYPES) + type!: WarehouseYardType; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + @Min(0) + capacityWeight?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + @Min(0) + capacityContainers?: number; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-zone.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-zone.dto.ts new file mode 100644 index 000000000..bd6f08e51 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-zone.dto.ts @@ -0,0 +1,37 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; + +import { WAREHOUSE_ZONE_TYPES, WarehouseZoneType } from '../entities/warehouse-zone.entity'; + +export class CreateWarehouseZoneDto { + @ApiPropertyOptional({ format: 'uuid', description: 'Optional — taken from the route param when omitted' }) + @IsOptional() + @IsUUID() + yardId?: string; + + @ApiProperty() + @IsString() + @MaxLength(160) + name!: string; + + @ApiProperty() + @IsString() + @MaxLength(40) + code!: string; + + @ApiProperty({ enum: WAREHOUSE_ZONE_TYPES }) + @IsEnum(WAREHOUSE_ZONE_TYPES) + type!: WarehouseZoneType; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + @Min(0) + capacityWeight?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + @Min(0) + capacityContainers?: number; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts new file mode 100644 index 000000000..0fdb48fa0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts @@ -0,0 +1,43 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; + +import { WAREHOUSE_TYPES, WarehouseType } from '../entities/warehouse.entity'; + +export class CreateWarehouseDto { + @ApiProperty() + @IsString() + @MaxLength(160) + name!: string; + + @ApiProperty() + @IsString() + @MaxLength(40) + code!: string; + + @ApiProperty({ enum: WAREHOUSE_TYPES }) + @IsEnum(WAREHOUSE_TYPES) + type!: WarehouseType; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + stationId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(200) + locationName?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + @Min(0) + capacityWeight?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + @Min(0) + capacityContainers?: number; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts new file mode 100644 index 000000000..c867eec3c --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts @@ -0,0 +1,54 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator'; + +import { + WAREHOUSE_INVENTORY_STATUSES, + WarehouseInventoryStatus, +} from '../entities/warehouse-inventory.entity'; + +export class FilterWarehouseInventoryDto { + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + warehouseId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + yardId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + zoneId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + bookingId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + cargoId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + containerId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + goodsId?: string; + + @ApiPropertyOptional({ enum: WAREHOUSE_INVENTORY_STATUSES }) + @IsOptional() + @IsEnum(WAREHOUSE_INVENTORY_STATUSES) + status?: WarehouseInventoryStatus; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + search?: string; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/filter-warehouse.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/filter-warehouse.dto.ts new file mode 100644 index 000000000..f07088ccc --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/filter-warehouse.dto.ts @@ -0,0 +1,26 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator'; + +import { WAREHOUSE_STATUSES, WAREHOUSE_TYPES, WarehouseStatus, WarehouseType } from '../entities/warehouse.entity'; + +export class FilterWarehouseDto { + @ApiPropertyOptional() + @IsOptional() + @IsString() + search?: string; + + @ApiPropertyOptional({ enum: WAREHOUSE_TYPES }) + @IsOptional() + @IsEnum(WAREHOUSE_TYPES) + type?: WarehouseType; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + stationId?: string; + + @ApiPropertyOptional({ enum: WAREHOUSE_STATUSES }) + @IsOptional() + @IsEnum(WAREHOUSE_STATUSES) + status?: WarehouseStatus; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/inquiry-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/inquiry-inventory.dto.ts new file mode 100644 index 000000000..cba259d00 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/inquiry-inventory.dto.ts @@ -0,0 +1,49 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator'; + +import { + WAREHOUSE_INVENTORY_STATUSES, + WarehouseInventoryStatus, +} from '../entities/warehouse-inventory.entity'; + +export class InquiryWarehouseInventoryDto { + @ApiPropertyOptional() + @IsOptional() + @IsString() + bookingNumber?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + containerNumber?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + cargoType?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + goodsName?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + warehouseId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + yardId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + zoneId?: string; + + @ApiPropertyOptional({ enum: WAREHOUSE_INVENTORY_STATUSES }) + @IsOptional() + @IsEnum(WAREHOUSE_INVENTORY_STATUSES) + status?: WarehouseInventoryStatus; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/receive-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/receive-inventory.dto.ts new file mode 100644 index 000000000..b0dc16282 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/receive-inventory.dto.ts @@ -0,0 +1,56 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator'; + +export class ReceiveWarehouseInventoryDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + warehouseId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + yardId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + zoneId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + bookingId!: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + cargoId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + containerId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + goodsId?: string; + + @ApiProperty() + @IsNumber() + @Min(0) + quantity!: number; + + @ApiProperty() + @IsNumber() + @Min(0) + weight!: number; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + @Min(0) + volume?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + notes?: string; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/update-warehouse-yard.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/update-warehouse-yard.dto.ts new file mode 100644 index 000000000..717923534 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/update-warehouse-yard.dto.ts @@ -0,0 +1,12 @@ +import { ApiPropertyOptional, PartialType } from '@nestjs/swagger'; +import { IsEnum, IsOptional } from 'class-validator'; + +import { WAREHOUSE_YARD_STATUSES, WarehouseYardStatus } from '../entities/warehouse-yard.entity'; +import { CreateWarehouseYardDto } from './create-warehouse-yard.dto'; + +export class UpdateWarehouseYardDto extends PartialType(CreateWarehouseYardDto) { + @ApiPropertyOptional({ enum: WAREHOUSE_YARD_STATUSES }) + @IsOptional() + @IsEnum(WAREHOUSE_YARD_STATUSES) + status?: WarehouseYardStatus; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/update-warehouse-zone.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/update-warehouse-zone.dto.ts new file mode 100644 index 000000000..01cd8301d --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/update-warehouse-zone.dto.ts @@ -0,0 +1,12 @@ +import { ApiPropertyOptional, PartialType } from '@nestjs/swagger'; +import { IsEnum, IsOptional } from 'class-validator'; + +import { WAREHOUSE_ZONE_STATUSES, WarehouseZoneStatus } from '../entities/warehouse-zone.entity'; +import { CreateWarehouseZoneDto } from './create-warehouse-zone.dto'; + +export class UpdateWarehouseZoneDto extends PartialType(CreateWarehouseZoneDto) { + @ApiPropertyOptional({ enum: WAREHOUSE_ZONE_STATUSES }) + @IsOptional() + @IsEnum(WAREHOUSE_ZONE_STATUSES) + status?: WarehouseZoneStatus; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/update-warehouse.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/update-warehouse.dto.ts new file mode 100644 index 000000000..e6038fca2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/update-warehouse.dto.ts @@ -0,0 +1,12 @@ +import { ApiPropertyOptional, PartialType } from '@nestjs/swagger'; +import { IsEnum, IsOptional } from 'class-validator'; + +import { WAREHOUSE_STATUSES, WarehouseStatus } from '../entities/warehouse.entity'; +import { CreateWarehouseDto } from './create-warehouse.dto'; + +export class UpdateWarehouseDto extends PartialType(CreateWarehouseDto) { + @ApiPropertyOptional({ enum: WAREHOUSE_STATUSES }) + @IsOptional() + @IsEnum(WAREHOUSE_STATUSES) + status?: WarehouseStatus; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts new file mode 100644 index 000000000..7cdb99aeb --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts @@ -0,0 +1,81 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { Warehouse } from './warehouse.entity'; +import { WarehouseYard } from './warehouse-yard.entity'; +import { WarehouseZone } from './warehouse-zone.entity'; + +export const WAREHOUSE_INVENTORY_STATUSES = [ + 'ARRIVED_AT_WAREHOUSE', + 'UNDER_INSPECTION', + 'READY_FOR_LOADING', +] as const; +export type WarehouseInventoryStatus = (typeof WAREHOUSE_INVENTORY_STATUSES)[number]; + +@Entity({ schema: 'freight', name: 'warehouse_inventory' }) +@Index(['warehouseId']) +@Index(['yardId']) +@Index(['zoneId']) +@Index(['bookingId']) +@Index(['cargoId']) +@Index(['containerId']) +@Index(['goodsId']) +@Index(['status']) +export class WarehouseInventory extends BaseEntity { + @Column({ name: 'warehouse_id', type: 'uuid' }) + warehouseId!: string; + + @ManyToOne(() => Warehouse) + @JoinColumn({ name: 'warehouse_id' }) + warehouse?: Warehouse; + + @Column({ name: 'yard_id', type: 'uuid' }) + yardId!: string; + + @ManyToOne(() => WarehouseYard) + @JoinColumn({ name: 'yard_id' }) + yard?: WarehouseYard; + + @Column({ name: 'zone_id', type: 'uuid' }) + zoneId!: string; + + @ManyToOne(() => WarehouseZone) + @JoinColumn({ name: 'zone_id' }) + zone?: WarehouseZone; + + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + @Column({ name: 'cargo_id', type: 'uuid', nullable: true }) + cargoId?: string | null; + + @Column({ name: 'container_id', type: 'uuid', nullable: true }) + containerId?: string | null; + + @Column({ name: 'goods_id', type: 'uuid', nullable: true }) + goodsId?: string | null; + + @Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3, default: 0 }) + quantity!: number; + + @Column({ name: 'weight', type: 'numeric', precision: 14, scale: 3, default: 0 }) + weight!: number; + + @Column({ name: 'volume', type: 'numeric', precision: 12, scale: 3, nullable: true }) + volume?: number | null; + + @Column({ name: 'status', type: 'varchar', length: 32, default: 'ARRIVED_AT_WAREHOUSE' }) + status!: WarehouseInventoryStatus; + + @Column({ name: 'arrived_at', type: 'timestamptz', nullable: true }) + arrivedAt?: Date | null; + + @Column({ name: 'inspected_at', type: 'timestamptz', nullable: true }) + inspectedAt?: Date | null; + + @Column({ name: 'ready_for_loading_at', type: 'timestamptz', nullable: true }) + readyForLoadingAt?: Date | null; + + @Column({ name: 'notes', type: 'text', nullable: true }) + notes?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-yard.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-yard.entity.ts new file mode 100644 index 000000000..90d381580 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-yard.entity.ts @@ -0,0 +1,60 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; + +import { Warehouse } from './warehouse.entity'; +import { WarehouseZone } from './warehouse-zone.entity'; + +export const WAREHOUSE_YARD_TYPES = [ + 'CONTAINER_YARD', + 'BULK_YARD', + 'GENERAL_CARGO_YARD', + 'HAZARDOUS_YARD', + 'COLD_STORAGE_YARD', +] as const; +export type WarehouseYardType = (typeof WAREHOUSE_YARD_TYPES)[number]; + +export const WAREHOUSE_YARD_STATUSES = ['ACTIVE', 'INACTIVE'] as const; +export type WarehouseYardStatus = (typeof WAREHOUSE_YARD_STATUSES)[number]; + +@Entity({ schema: 'freight', name: 'warehouse_yards' }) +@Index(['warehouseId']) +@Index(['type']) +@Index(['status']) +export class WarehouseYard extends BaseEntity { + @Column({ name: 'warehouse_id', type: 'uuid' }) + warehouseId!: string; + + @ManyToOne(() => Warehouse, (warehouse) => warehouse.yards, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'warehouse_id' }) + warehouse?: Warehouse; + + @Column({ name: 'name', type: 'varchar', length: 160 }) + name!: string; + + @Column({ name: 'code', type: 'varchar', length: 40 }) + code!: string; + + @Column({ name: 'type', type: 'varchar', length: 32 }) + type!: WarehouseYardType; + + @Column({ name: 'capacity_weight', type: 'numeric', precision: 14, scale: 3, nullable: true }) + capacityWeight?: number | null; + + @Column({ name: 'capacity_containers', type: 'int', nullable: true }) + capacityContainers?: number | null; + + @Column({ name: 'current_weight', type: 'numeric', precision: 14, scale: 3, default: 0 }) + currentWeight!: number; + + @Column({ name: 'current_containers', type: 'int', default: 0 }) + currentContainers!: number; + + @Column({ name: 'status', type: 'varchar', length: 16, default: 'ACTIVE' }) + status!: WarehouseYardStatus; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; + + @OneToMany(() => WarehouseZone, (zone) => zone.yard) + zones?: WarehouseZone[]; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-zone.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-zone.entity.ts new file mode 100644 index 000000000..5f9996809 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-zone.entity.ts @@ -0,0 +1,56 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { WarehouseYard } from './warehouse-yard.entity'; + +export const WAREHOUSE_ZONE_TYPES = [ + 'CONTAINER_ZONE', + 'BULK_ZONE', + 'GENERAL_CARGO_ZONE', + 'HAZARDOUS_ZONE', + 'COLD_STORAGE_ZONE', +] as const; +export type WarehouseZoneType = (typeof WAREHOUSE_ZONE_TYPES)[number]; + +export const WAREHOUSE_ZONE_STATUSES = ['ACTIVE', 'INACTIVE'] as const; +export type WarehouseZoneStatus = (typeof WAREHOUSE_ZONE_STATUSES)[number]; + +@Entity({ schema: 'freight', name: 'warehouse_zones' }) +@Index(['yardId']) +@Index(['type']) +@Index(['status']) +export class WarehouseZone extends BaseEntity { + @Column({ name: 'yard_id', type: 'uuid' }) + yardId!: string; + + @ManyToOne(() => WarehouseYard, (yard) => yard.zones, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'yard_id' }) + yard?: WarehouseYard; + + @Column({ name: 'name', type: 'varchar', length: 160 }) + name!: string; + + @Column({ name: 'code', type: 'varchar', length: 40 }) + code!: string; + + @Column({ name: 'type', type: 'varchar', length: 32 }) + type!: WarehouseZoneType; + + @Column({ name: 'capacity_weight', type: 'numeric', precision: 14, scale: 3, nullable: true }) + capacityWeight?: number | null; + + @Column({ name: 'capacity_containers', type: 'int', nullable: true }) + capacityContainers?: number | null; + + @Column({ name: 'current_weight', type: 'numeric', precision: 14, scale: 3, default: 0 }) + currentWeight!: number; + + @Column({ name: 'current_containers', type: 'int', default: 0 }) + currentContainers!: number; + + @Column({ name: 'status', type: 'varchar', length: 16, default: 'ACTIVE' }) + status!: WarehouseZoneStatus; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse.entity.ts new file mode 100644 index 000000000..ee04f77e9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse.entity.ts @@ -0,0 +1,53 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, OneToMany } from 'typeorm'; + +import { WarehouseYard } from './warehouse-yard.entity'; + +export const WAREHOUSE_TYPES = ['OPEN_WAREHOUSE', 'CLOSED_WAREHOUSE'] as const; +export type WarehouseType = (typeof WAREHOUSE_TYPES)[number]; + +export const WAREHOUSE_STATUSES = ['ACTIVE', 'INACTIVE'] as const; +export type WarehouseStatus = (typeof WAREHOUSE_STATUSES)[number]; + +@Entity({ schema: 'freight', name: 'warehouses' }) +@Index(['code'], { unique: true }) +@Index(['type']) +@Index(['status']) +@Index(['stationId']) +export class Warehouse extends BaseEntity { + @Column({ name: 'name', type: 'varchar', length: 160 }) + name!: string; + + @Column({ name: 'code', type: 'varchar', length: 40, unique: true }) + code!: string; + + @Column({ name: 'type', type: 'varchar', length: 32 }) + type!: WarehouseType; + + @Column({ name: 'station_id', type: 'uuid', nullable: true }) + stationId?: string | null; + + @Column({ name: 'location_name', type: 'varchar', length: 200, nullable: true }) + locationName?: string | null; + + @Column({ name: 'capacity_weight', type: 'numeric', precision: 14, scale: 3, nullable: true }) + capacityWeight?: number | null; + + @Column({ name: 'capacity_containers', type: 'int', nullable: true }) + capacityContainers?: number | null; + + @Column({ name: 'current_weight', type: 'numeric', precision: 14, scale: 3, default: 0 }) + currentWeight!: number; + + @Column({ name: 'current_containers', type: 'int', default: 0 }) + currentContainers!: number; + + @Column({ name: 'status', type: 'varchar', length: 16, default: 'ACTIVE' }) + status!: WarehouseStatus; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; + + @OneToMany(() => WarehouseYard, (yard) => yard.warehouse) + yards?: WarehouseYard[]; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts new file mode 100644 index 000000000..27282fc1d --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -0,0 +1,50 @@ +import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto'; +import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto'; +import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto'; +import { WarehouseInventoryService } from './warehouse-inventory.service'; + +@ApiTags('warehouse-inventory') +@ApiBearerAuth() +@Controller('warehouse-inventory') +export class WarehouseInventoryController { + constructor(private readonly inventoryService: WarehouseInventoryService) {} + + @Get() + @ApiOperation({ summary: 'List warehouse inventory' }) + findAll(@Query() filter: FilterWarehouseInventoryDto) { + return this.inventoryService.findAll(filter); + } + + @Get('ready-for-loading') + @ApiOperation({ summary: 'List inventory ready for loading' }) + findReadyForLoading(@Query() filter: FilterWarehouseInventoryDto) { + return this.inventoryService.findReadyForLoading(filter); + } + + @Get('inquiry') + @ApiOperation({ summary: 'Locate any item inside the warehouse' }) + inquiry(@Query() filter: InquiryWarehouseInventoryDto) { + return this.inventoryService.inquiry(filter); + } + + @Post('receive') + @ApiOperation({ summary: 'Receive inventory at a warehouse location' }) + receive(@Body() dto: ReceiveWarehouseInventoryDto) { + return this.inventoryService.receive(dto); + } + + @Patch(':id/inspect') + @ApiOperation({ summary: 'Move inventory to UNDER_INSPECTION' }) + inspect(@Param('id', ParseUUIDPipe) id: string) { + return this.inventoryService.inspect(id); + } + + @Patch(':id/ready-for-loading') + @ApiOperation({ summary: 'Move inventory to READY_FOR_LOADING' }) + readyForLoading(@Param('id', ParseUUIDPipe) id: string) { + return this.inventoryService.readyForLoading(id); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.repository.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.repository.ts new file mode 100644 index 000000000..4f249cf53 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.repository.ts @@ -0,0 +1,13 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { WarehouseInventory } from './entities/warehouse-inventory.entity'; + +@Injectable() +export class WarehouseInventoryRepository extends BaseRepository { + constructor(@InjectRepository(WarehouseInventory) repository: Repository) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts new file mode 100644 index 000000000..18f7c0f61 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -0,0 +1,323 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { DataSource, EntityManager, FindManyOptions, ILike } from 'typeorm'; + +import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto'; +import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto'; +import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto'; +import { WarehouseInventory } from './entities/warehouse-inventory.entity'; +import { WarehouseYard } from './entities/warehouse-yard.entity'; +import { WarehouseZone } from './entities/warehouse-zone.entity'; +import { Warehouse } from './entities/warehouse.entity'; +import { WarehouseInventoryRepository } from './warehouse-inventory.repository'; + +export interface InventoryInquiryResult { + id: string; + bookingId: string; + bookingNumber: string | null; + customerName: string | null; + containerNumber: string | null; + cargoType: string | null; + cargoDescription: string | null; + goodsId: string | null; + warehouse: { id: string; name: string; code: string } | null; + yard: { id: string; name: string; code: string } | null; + zone: { id: string; name: string; code: string } | null; + status: string; + quantity: number; + weight: number; + arrivedAt: Date | null; + readyForLoadingAt: Date | null; +} + +@Injectable() +export class WarehouseInventoryService { + constructor( + private readonly dataSource: DataSource, + private readonly inventoryRepository: WarehouseInventoryRepository, + ) {} + + // ── Listing ──────────────────────────────────────────────────────────── + + findAll(filter: FilterWarehouseInventoryDto): Promise { + const base = { + ...(filter.warehouseId ? { warehouseId: filter.warehouseId } : {}), + ...(filter.yardId ? { yardId: filter.yardId } : {}), + ...(filter.zoneId ? { zoneId: filter.zoneId } : {}), + ...(filter.bookingId ? { bookingId: filter.bookingId } : {}), + ...(filter.cargoId ? { cargoId: filter.cargoId } : {}), + ...(filter.containerId ? { containerId: filter.containerId } : {}), + ...(filter.goodsId ? { goodsId: filter.goodsId } : {}), + ...(filter.status ? { status: filter.status } : {}), + }; + + const search = filter.search?.trim(); + const where: FindManyOptions['where'] = search + ? { ...base, notes: ILike(`%${search}%`) } + : base; + + return this.inventoryRepository.findAll({ + where, + relations: { warehouse: true, yard: true, zone: true }, + order: { createdAt: 'DESC' }, + }); + } + + findReadyForLoading(filter: FilterWarehouseInventoryDto): Promise { + return this.findAll({ ...filter, status: 'READY_FOR_LOADING' }); + } + + async findById(id: string): Promise { + const item = await this.inventoryRepository.findById(id, { + relations: { warehouse: true, yard: true, zone: true }, + }); + + if (!item) { + throw new NotFoundException(`Inventory item ${id} not found`); + } + + return item; + } + + // ── Receive (with location + capacity validation) ────────────────────── + + async receive(dto: ReceiveWarehouseInventoryDto): Promise { + const weight = Number(dto.weight) || 0; + const containerCount = dto.containerId ? Math.round(Number(dto.quantity) || 0) : 0; + + const id = await this.dataSource.transaction(async (manager) => { + const { warehouse, yard, zone } = await this.validateLocation(manager, dto); + + await this.assertBookingExists(manager, dto.bookingId); + + this.assertCapacity('Warehouse', warehouse, weight, containerCount); + this.assertCapacity('Yard', yard, weight, containerCount); + this.assertCapacity('Zone', zone, weight, containerCount); + + const now = new Date(); + const saved = await manager.getRepository(WarehouseInventory).save( + manager.getRepository(WarehouseInventory).create({ + warehouseId: dto.warehouseId, + yardId: dto.yardId, + zoneId: dto.zoneId, + bookingId: dto.bookingId, + cargoId: dto.cargoId ?? null, + containerId: dto.containerId ?? null, + goodsId: dto.goodsId ?? null, + quantity: Number(dto.quantity) || 0, + weight, + volume: dto.volume ?? null, + status: 'ARRIVED_AT_WAREHOUSE', + arrivedAt: now, + notes: dto.notes?.trim() ?? null, + }), + ); + + await this.applyCapacityDelta(manager, dto, weight, containerCount); + + return saved.id; + }); + + return this.findById(id); + } + + // ── Status transitions ───────────────────────────────────────────────── + + async inspect(id: string): Promise { + const item = await this.findById(id); + + if (item.status !== 'ARRIVED_AT_WAREHOUSE') { + throw new BadRequestException( + `Only items in ARRIVED_AT_WAREHOUSE can be inspected (current: ${item.status})`, + ); + } + + await this.inventoryRepository.update(id, { + status: 'UNDER_INSPECTION', + inspectedAt: new Date(), + }); + + return this.findById(id); + } + + async readyForLoading(id: string): Promise { + const item = await this.findById(id); + + if (item.status !== 'UNDER_INSPECTION') { + throw new BadRequestException( + `Only items in UNDER_INSPECTION can be marked READY_FOR_LOADING (current: ${item.status})`, + ); + } + + await this.inventoryRepository.update(id, { + status: 'READY_FOR_LOADING', + readyForLoadingAt: new Date(), + }); + + return this.findById(id); + } + + // ── Inquiry ──────────────────────────────────────────────────────────── + + async inquiry(filter: InquiryWarehouseInventoryDto): Promise { + const qb = this.dataSource + .getRepository(WarehouseInventory) + .createQueryBuilder('inv') + .leftJoinAndSelect('inv.warehouse', 'warehouse') + .leftJoinAndSelect('inv.yard', 'yard') + .leftJoinAndSelect('inv.zone', 'zone') + .leftJoin('freight.bookings', 'booking', 'booking.id = inv.booking_id') + .leftJoin('freight.companies', 'company', 'company.id = booking.company_id') + .leftJoin('freight.containers', 'container', 'container.id = inv.container_id') + .leftJoin('freight.cargoes', 'cargo', 'cargo.id = inv.cargo_id') + .leftJoin('freight.cargo_types', 'cargo_type', 'cargo_type.id = cargo.cargo_type_id') + .addSelect('booking.reference', 'b_reference') + .addSelect('company.name', 'c_name') + .addSelect('container.container_number', 'ct_number') + .addSelect('cargo.description', 'cg_description') + .addSelect('cargo_type.cargo_type_name', 'cgt_name') + .orderBy('inv.created_at', 'DESC'); + + if (filter.bookingNumber?.trim()) { + qb.andWhere('booking.reference ILIKE :bn', { bn: `%${filter.bookingNumber.trim()}%` }); + } + if (filter.containerNumber?.trim()) { + qb.andWhere('container.container_number ILIKE :cn', { cn: `%${filter.containerNumber.trim()}%` }); + } + if (filter.cargoType?.trim()) { + qb.andWhere('cargo_type.cargo_type_name ILIKE :ctype', { ctype: `%${filter.cargoType.trim()}%` }); + } + if (filter.goodsName?.trim()) { + // No dedicated goods entity in Batch 1 — best-effort match against notes / cargo description. + qb.andWhere('(inv.notes ILIKE :gn OR cargo.description ILIKE :gn)', { gn: `%${filter.goodsName.trim()}%` }); + } + if (filter.warehouseId) { + qb.andWhere('inv.warehouse_id = :wid', { wid: filter.warehouseId }); + } + if (filter.yardId) { + qb.andWhere('inv.yard_id = :yid', { yid: filter.yardId }); + } + if (filter.zoneId) { + qb.andWhere('inv.zone_id = :zid', { zid: filter.zoneId }); + } + if (filter.status) { + qb.andWhere('inv.status = :status', { status: filter.status }); + } + + const { entities, raw } = await qb.getRawAndEntities(); + + return entities.map((inv, index) => { + const row = raw[index] ?? {}; + return { + id: inv.id, + bookingId: inv.bookingId, + bookingNumber: row.b_reference ?? null, + customerName: row.c_name ?? null, + containerNumber: row.ct_number ?? null, + cargoType: row.cgt_name ?? null, + cargoDescription: row.cg_description ?? null, + goodsId: inv.goodsId ?? null, + warehouse: inv.warehouse + ? { id: inv.warehouse.id, name: inv.warehouse.name, code: inv.warehouse.code } + : null, + yard: inv.yard ? { id: inv.yard.id, name: inv.yard.name, code: inv.yard.code } : null, + zone: inv.zone ? { id: inv.zone.id, name: inv.zone.name, code: inv.zone.code } : null, + status: inv.status, + quantity: Number(inv.quantity), + weight: Number(inv.weight), + arrivedAt: inv.arrivedAt ?? null, + readyForLoadingAt: inv.readyForLoadingAt ?? null, + }; + }); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private async validateLocation( + manager: EntityManager, + dto: ReceiveWarehouseInventoryDto, + ): Promise<{ warehouse: Warehouse; yard: WarehouseYard; zone: WarehouseZone }> { + const warehouse = await manager.getRepository(Warehouse).findOne({ where: { id: dto.warehouseId } }); + if (!warehouse) { + throw new NotFoundException(`Warehouse ${dto.warehouseId} not found`); + } + if (warehouse.status !== 'ACTIVE') { + throw new BadRequestException('Warehouse is not ACTIVE'); + } + + const yard = await manager.getRepository(WarehouseYard).findOne({ where: { id: dto.yardId } }); + if (!yard) { + throw new NotFoundException(`Yard ${dto.yardId} not found`); + } + if (yard.warehouseId !== warehouse.id) { + throw new BadRequestException('Yard does not belong to the selected warehouse'); + } + if (yard.status !== 'ACTIVE') { + throw new BadRequestException('Yard is not ACTIVE'); + } + + const zone = await manager.getRepository(WarehouseZone).findOne({ where: { id: dto.zoneId } }); + if (!zone) { + throw new NotFoundException(`Zone ${dto.zoneId} not found`); + } + if (zone.yardId !== yard.id) { + throw new BadRequestException('Zone does not belong to the selected yard'); + } + if (zone.status !== 'ACTIVE') { + throw new BadRequestException('Zone is not ACTIVE'); + } + + return { warehouse, yard, zone }; + } + + private async assertBookingExists(manager: EntityManager, bookingId: string): Promise { + const rows = await manager.query( + 'SELECT id FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL LIMIT 1', + [bookingId], + ); + if (!rows || rows.length === 0) { + throw new NotFoundException(`Booking ${bookingId} not found`); + } + } + + private assertCapacity( + label: string, + node: { capacityWeight?: number | null; capacityContainers?: number | null; currentWeight: number; currentContainers: number }, + weightAdd: number, + containerAdd: number, + ): void { + if (node.capacityWeight != null) { + const projected = Number(node.currentWeight) + weightAdd; + if (projected > Number(node.capacityWeight)) { + throw new BadRequestException( + `${label} weight capacity exceeded (${projected} / ${node.capacityWeight})`, + ); + } + } + + if (node.capacityContainers != null && containerAdd > 0) { + const projected = Number(node.currentContainers) + containerAdd; + if (projected > Number(node.capacityContainers)) { + throw new BadRequestException( + `${label} container capacity exceeded (${projected} / ${node.capacityContainers})`, + ); + } + } + } + + private async applyCapacityDelta( + manager: EntityManager, + dto: ReceiveWarehouseInventoryDto, + weightAdd: number, + containerAdd: number, + ): Promise { + await manager.increment(Warehouse, { id: dto.warehouseId }, 'currentWeight', weightAdd); + await manager.increment(WarehouseYard, { id: dto.yardId }, 'currentWeight', weightAdd); + await manager.increment(WarehouseZone, { id: dto.zoneId }, 'currentWeight', weightAdd); + + if (containerAdd > 0) { + await manager.increment(Warehouse, { id: dto.warehouseId }, 'currentContainers', containerAdd); + await manager.increment(WarehouseYard, { id: dto.yardId }, 'currentContainers', containerAdd); + await manager.increment(WarehouseZone, { id: dto.zoneId }, 'currentContainers', containerAdd); + } + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts new file mode 100644 index 000000000..3ee0dde82 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts @@ -0,0 +1,44 @@ +import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { CreateWarehouseZoneDto } from './dto/create-warehouse-zone.dto'; +import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto'; +import { WarehouseYardsService } from './warehouse-yards.service'; +import { WarehouseZonesService } from './warehouse-zones.service'; + +@ApiTags('warehouse-yards') +@ApiBearerAuth() +@Controller('warehouse-yards') +export class WarehouseYardsController { + constructor( + private readonly yardsService: WarehouseYardsService, + private readonly zonesService: WarehouseZonesService, + ) {} + + @Get(':id') + @ApiOperation({ summary: 'Get warehouse yard by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.yardsService.findById(id); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update warehouse yard' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseYardDto) { + return this.yardsService.update(id, dto); + } + + @Get(':yardId/zones') + @ApiOperation({ summary: 'List zones within a yard' }) + listZones(@Param('yardId', ParseUUIDPipe) yardId: string) { + return this.zonesService.findByYard(yardId); + } + + @Post(':yardId/zones') + @ApiOperation({ summary: 'Create a zone within a yard' }) + createZone( + @Param('yardId', ParseUUIDPipe) yardId: string, + @Body() dto: CreateWarehouseZoneDto, + ) { + return this.zonesService.create(yardId, dto); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.repository.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.repository.ts new file mode 100644 index 000000000..99bbdd21f --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.repository.ts @@ -0,0 +1,13 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { WarehouseYard } from './entities/warehouse-yard.entity'; + +@Injectable() +export class WarehouseYardsRepository extends BaseRepository { + constructor(@InjectRepository(WarehouseYard) repository: Repository) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts new file mode 100644 index 000000000..84a8dc8da --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts @@ -0,0 +1,88 @@ +import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; + +import { CreateWarehouseYardDto } from './dto/create-warehouse-yard.dto'; +import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto'; +import { WarehouseYard } from './entities/warehouse-yard.entity'; +import { WarehouseYardsRepository } from './warehouse-yards.repository'; +import { WarehousesService } from './warehouses.service'; + +@Injectable() +export class WarehouseYardsService { + constructor( + private readonly yardsRepository: WarehouseYardsRepository, + private readonly warehousesService: WarehousesService, + ) {} + + findByWarehouse(warehouseId: string): Promise { + return this.yardsRepository.findAll({ + where: { warehouseId }, + relations: { zones: true }, + order: { code: 'ASC' }, + }); + } + + async findById(id: string): Promise { + const yard = await this.yardsRepository.findById(id, { + relations: { warehouse: true, zones: true }, + }); + + if (!yard) { + throw new NotFoundException(`Warehouse yard ${id} not found`); + } + + return yard; + } + + async create(warehouseId: string, dto: CreateWarehouseYardDto): Promise { + // Ensure the parent warehouse exists. + await this.warehousesService.findById(warehouseId); + await this.assertCodeUnique(warehouseId, dto.code.trim()); + + return this.yardsRepository.create({ + warehouseId, + name: dto.name.trim(), + code: dto.code.trim(), + type: dto.type, + capacityWeight: dto.capacityWeight ?? null, + capacityContainers: dto.capacityContainers ?? null, + currentWeight: 0, + currentContainers: 0, + status: 'ACTIVE', + isActive: true, + }); + } + + async update(id: string, dto: UpdateWarehouseYardDto): Promise { + const existing = await this.findById(id); + + if (dto.code && dto.code.trim() !== existing.code) { + await this.assertCodeUnique(existing.warehouseId, dto.code.trim(), id); + } + + const status = dto.status ?? existing.status; + + const updated = await this.yardsRepository.update(id, { + name: dto.name?.trim() ?? existing.name, + code: dto.code?.trim() ?? existing.code, + type: dto.type ?? existing.type, + capacityWeight: dto.capacityWeight ?? existing.capacityWeight, + capacityContainers: dto.capacityContainers ?? existing.capacityContainers, + status, + isActive: status === 'ACTIVE', + }); + + if (!updated) { + throw new NotFoundException(`Warehouse yard ${id} not found`); + } + + return this.findById(id); + } + + private async assertCodeUnique(warehouseId: string, code: string, ignoreId?: string): Promise { + const [existing] = await this.yardsRepository.findAll({ where: { warehouseId, code } }); + + if (existing && existing.id !== ignoreId) { + throw new ConflictException(`Yard code ${code} already exists in this warehouse`); + } + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts new file mode 100644 index 000000000..30c4407f6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts @@ -0,0 +1,24 @@ +import { Body, Controller, Get, Param, ParseUUIDPipe, Patch } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { UpdateWarehouseZoneDto } from './dto/update-warehouse-zone.dto'; +import { WarehouseZonesService } from './warehouse-zones.service'; + +@ApiTags('warehouse-zones') +@ApiBearerAuth() +@Controller('warehouse-zones') +export class WarehouseZonesController { + constructor(private readonly zonesService: WarehouseZonesService) {} + + @Get(':id') + @ApiOperation({ summary: 'Get warehouse zone by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.zonesService.findById(id); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update warehouse zone' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseZoneDto) { + return this.zonesService.update(id, dto); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.repository.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.repository.ts new file mode 100644 index 000000000..94580f116 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.repository.ts @@ -0,0 +1,13 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { WarehouseZone } from './entities/warehouse-zone.entity'; + +@Injectable() +export class WarehouseZonesRepository extends BaseRepository { + constructor(@InjectRepository(WarehouseZone) repository: Repository) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.service.ts new file mode 100644 index 000000000..d3689865e --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.service.ts @@ -0,0 +1,87 @@ +import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; + +import { CreateWarehouseZoneDto } from './dto/create-warehouse-zone.dto'; +import { UpdateWarehouseZoneDto } from './dto/update-warehouse-zone.dto'; +import { WarehouseZone } from './entities/warehouse-zone.entity'; +import { WarehouseYardsService } from './warehouse-yards.service'; +import { WarehouseZonesRepository } from './warehouse-zones.repository'; + +@Injectable() +export class WarehouseZonesService { + constructor( + private readonly zonesRepository: WarehouseZonesRepository, + private readonly yardsService: WarehouseYardsService, + ) {} + + findByYard(yardId: string): Promise { + return this.zonesRepository.findAll({ + where: { yardId }, + order: { code: 'ASC' }, + }); + } + + async findById(id: string): Promise { + const zone = await this.zonesRepository.findById(id, { + relations: { yard: { warehouse: true } }, + }); + + if (!zone) { + throw new NotFoundException(`Warehouse zone ${id} not found`); + } + + return zone; + } + + async create(yardId: string, dto: CreateWarehouseZoneDto): Promise { + // Ensure the parent yard exists. + await this.yardsService.findById(yardId); + await this.assertCodeUnique(yardId, dto.code.trim()); + + return this.zonesRepository.create({ + yardId, + name: dto.name.trim(), + code: dto.code.trim(), + type: dto.type, + capacityWeight: dto.capacityWeight ?? null, + capacityContainers: dto.capacityContainers ?? null, + currentWeight: 0, + currentContainers: 0, + status: 'ACTIVE', + isActive: true, + }); + } + + async update(id: string, dto: UpdateWarehouseZoneDto): Promise { + const existing = await this.findById(id); + + if (dto.code && dto.code.trim() !== existing.code) { + await this.assertCodeUnique(existing.yardId, dto.code.trim(), id); + } + + const status = dto.status ?? existing.status; + + const updated = await this.zonesRepository.update(id, { + name: dto.name?.trim() ?? existing.name, + code: dto.code?.trim() ?? existing.code, + type: dto.type ?? existing.type, + capacityWeight: dto.capacityWeight ?? existing.capacityWeight, + capacityContainers: dto.capacityContainers ?? existing.capacityContainers, + status, + isActive: status === 'ACTIVE', + }); + + if (!updated) { + throw new NotFoundException(`Warehouse zone ${id} not found`); + } + + return this.findById(id); + } + + private async assertCodeUnique(yardId: string, code: string, ignoreId?: string): Promise { + const [existing] = await this.zonesRepository.findAll({ where: { yardId, code } }); + + if (existing && existing.id !== ignoreId) { + throw new ConflictException(`Zone code ${code} already exists in this yard`); + } + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts new file mode 100644 index 000000000..fd52be8e2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts @@ -0,0 +1,58 @@ +import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { CreateWarehouseDto } from './dto/create-warehouse.dto'; +import { CreateWarehouseYardDto } from './dto/create-warehouse-yard.dto'; +import { FilterWarehouseDto } from './dto/filter-warehouse.dto'; +import { UpdateWarehouseDto } from './dto/update-warehouse.dto'; +import { WarehouseYardsService } from './warehouse-yards.service'; +import { WarehousesService } from './warehouses.service'; + +@ApiTags('warehouses') +@ApiBearerAuth() +@Controller('warehouses') +export class WarehousesController { + constructor( + private readonly warehousesService: WarehousesService, + private readonly yardsService: WarehouseYardsService, + ) {} + + @Get() + @ApiOperation({ summary: 'List warehouses' }) + findAll(@Query() filter: FilterWarehouseDto) { + return this.warehousesService.findAll(filter); + } + + @Post() + @ApiOperation({ summary: 'Create warehouse' }) + create(@Body() dto: CreateWarehouseDto) { + return this.warehousesService.create(dto); + } + + @Get(':id') + @ApiOperation({ summary: 'Get warehouse by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.warehousesService.findById(id); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update warehouse' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseDto) { + return this.warehousesService.update(id, dto); + } + + @Get(':warehouseId/yards') + @ApiOperation({ summary: 'List yards within a warehouse' }) + listYards(@Param('warehouseId', ParseUUIDPipe) warehouseId: string) { + return this.yardsService.findByWarehouse(warehouseId); + } + + @Post(':warehouseId/yards') + @ApiOperation({ summary: 'Create a yard within a warehouse' }) + createYard( + @Param('warehouseId', ParseUUIDPipe) warehouseId: string, + @Body() dto: CreateWarehouseYardDto, + ) { + return this.yardsService.create(warehouseId, dto); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts new file mode 100644 index 000000000..e5ad8d371 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts @@ -0,0 +1,41 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { WarehouseInventory } from './entities/warehouse-inventory.entity'; +import { WarehouseYard } from './entities/warehouse-yard.entity'; +import { WarehouseZone } from './entities/warehouse-zone.entity'; +import { Warehouse } from './entities/warehouse.entity'; +import { WarehouseInventoryController } from './warehouse-inventory.controller'; +import { WarehouseInventoryRepository } from './warehouse-inventory.repository'; +import { WarehouseInventoryService } from './warehouse-inventory.service'; +import { WarehouseYardsController } from './warehouse-yards.controller'; +import { WarehouseYardsRepository } from './warehouse-yards.repository'; +import { WarehouseYardsService } from './warehouse-yards.service'; +import { WarehouseZonesController } from './warehouse-zones.controller'; +import { WarehouseZonesRepository } from './warehouse-zones.repository'; +import { WarehouseZonesService } from './warehouse-zones.service'; +import { WarehousesController } from './warehouses.controller'; +import { WarehousesRepository } from './warehouses.repository'; +import { WarehousesService } from './warehouses.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([Warehouse, WarehouseYard, WarehouseZone, WarehouseInventory])], + controllers: [ + WarehousesController, + WarehouseYardsController, + WarehouseZonesController, + WarehouseInventoryController, + ], + providers: [ + WarehousesRepository, + WarehouseYardsRepository, + WarehouseZonesRepository, + WarehouseInventoryRepository, + WarehousesService, + WarehouseYardsService, + WarehouseZonesService, + WarehouseInventoryService, + ], + exports: [WarehousesService, WarehouseYardsService, WarehouseZonesService, WarehouseInventoryService], +}) +export class WarehousesModule {} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.repository.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.repository.ts new file mode 100644 index 000000000..b88ddab50 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.repository.ts @@ -0,0 +1,13 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { Warehouse } from './entities/warehouse.entity'; + +@Injectable() +export class WarehousesRepository extends BaseRepository { + constructor(@InjectRepository(Warehouse) repository: Repository) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts new file mode 100644 index 000000000..a77a02678 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts @@ -0,0 +1,101 @@ +import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { FindManyOptions, ILike } from 'typeorm'; + +import { CreateWarehouseDto } from './dto/create-warehouse.dto'; +import { FilterWarehouseDto } from './dto/filter-warehouse.dto'; +import { UpdateWarehouseDto } from './dto/update-warehouse.dto'; +import { Warehouse } from './entities/warehouse.entity'; +import { WarehousesRepository } from './warehouses.repository'; + +@Injectable() +export class WarehousesService { + constructor(private readonly warehousesRepository: WarehousesRepository) {} + + async findAll(filter: FilterWarehouseDto): Promise { + const where: FindManyOptions['where'] = { + ...(filter.type ? { type: filter.type } : {}), + ...(filter.stationId ? { stationId: filter.stationId } : {}), + ...(filter.status ? { status: filter.status } : {}), + }; + + const search = filter.search?.trim(); + const whereClauses = search + ? [ + { ...where, name: ILike(`%${search}%`) }, + { ...where, code: ILike(`%${search}%`) }, + { ...where, locationName: ILike(`%${search}%`) }, + ] + : where; + + return this.warehousesRepository.findAll({ + where: whereClauses, + order: { code: 'ASC' }, + }); + } + + async findById(id: string): Promise { + const warehouse = await this.warehousesRepository.findById(id, { + relations: { yards: { zones: true } }, + }); + + if (!warehouse) { + throw new NotFoundException(`Warehouse ${id} not found`); + } + + return warehouse; + } + + async create(dto: CreateWarehouseDto): Promise { + await this.assertCodeUnique(dto.code.trim()); + + return this.warehousesRepository.create({ + name: dto.name.trim(), + code: dto.code.trim(), + type: dto.type, + stationId: dto.stationId ?? null, + locationName: dto.locationName?.trim() ?? null, + capacityWeight: dto.capacityWeight ?? null, + capacityContainers: dto.capacityContainers ?? null, + currentWeight: 0, + currentContainers: 0, + status: 'ACTIVE', + isActive: true, + }); + } + + async update(id: string, dto: UpdateWarehouseDto): Promise { + const existing = await this.findById(id); + + if (dto.code && dto.code.trim() !== existing.code) { + await this.assertCodeUnique(dto.code.trim(), id); + } + + const status = dto.status ?? existing.status; + + const updated = await this.warehousesRepository.update(id, { + name: dto.name?.trim() ?? existing.name, + code: dto.code?.trim() ?? existing.code, + type: dto.type ?? existing.type, + stationId: dto.stationId ?? existing.stationId, + locationName: dto.locationName?.trim() ?? existing.locationName, + capacityWeight: dto.capacityWeight ?? existing.capacityWeight, + capacityContainers: dto.capacityContainers ?? existing.capacityContainers, + status, + isActive: status === 'ACTIVE', + }); + + if (!updated) { + throw new NotFoundException(`Warehouse ${id} not found`); + } + + return this.findById(id); + } + + private async assertCodeUnique(code: string, ignoreId?: string): Promise { + const [existing] = await this.warehousesRepository.findAll({ where: { code } }); + + if (existing && existing.id !== ignoreId) { + throw new ConflictException(`Warehouse code ${code} already exists`); + } + } +} diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index d4b7d458a..f5ceb858b 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -45,6 +45,10 @@ import { import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"; import TrainDetailPage from "./pages/trains/TrainDetailPage"; import RoutesPage from "./pages/fleet/RoutesPage"; +import WarehouseListPage from "./pages/warehouses/WarehouseListPage"; +import WarehouseDetailPage from "./pages/warehouses/WarehouseDetailPage"; +import WarehouseInventoryPage from "./pages/warehouses/WarehouseInventoryPage"; +import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage"; const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ { @@ -114,6 +118,26 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ }, ], }, + { + title: "Warehouse Management", + items: [ + { + label: "Warehouses", + href: "/dashboard/warehouses", + icon: , + }, + { + label: "Inventory", + href: "/dashboard/warehouse-inventory", + icon: , + }, + { + label: "Inventory Inquiry", + href: "/dashboard/inventory-inquiry", + icon: , + }, + ], + }, { title: "Administration", items: [ @@ -256,6 +280,11 @@ const App = () => { } /> } /> + } /> + } /> + } /> + } /> + } /> } /> } /> diff --git a/apps/edr-freight-web/backoffice/src/components/ErrorBoundary.tsx b/apps/edr-freight-web/backoffice/src/components/ErrorBoundary.tsx new file mode 100644 index 000000000..00635118d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/ErrorBoundary.tsx @@ -0,0 +1,99 @@ +import { Component, type ErrorInfo, type ReactNode } from "react"; + +interface ErrorBoundaryProps { + children: ReactNode; +} + +interface ErrorBoundaryState { + error: Error | null; +} + +/** + * App-wide error boundary. Without this, any render-time exception unmounts the + * React tree and the user sees a blank white screen. This surfaces the actual + * error message + stack so failures are diagnosable in place. + */ +export class ErrorBoundary extends Component { + state: ErrorBoundaryState = { error: null }; + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { error }; + } + + componentDidCatch(error: Error, info: ErrorInfo) { + // eslint-disable-next-line no-console + console.error("[ErrorBoundary] Uncaught render error:", error, info.componentStack); + } + + handleReset = () => this.setState({ error: null }); + + render() { + const { error } = this.state; + + if (!error) return this.props.children; + + return ( +
+
+

Something went wrong

+

+ A render error was caught. Details below — share this with the developer. +

+
+            {error.message}
+            {"\n\n"}
+            {error.stack}
+          
+ +
+
+ ); + } +} diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/CreateWarehouseModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/CreateWarehouseModal.tsx new file mode 100644 index 000000000..44bcb7c45 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/CreateWarehouseModal.tsx @@ -0,0 +1,173 @@ +import { useEffect, useState } from 'react'; +import { + Button, + Group, + Modal, + NumberInput, + Select, + Stack, + TextInput, +} from '@mantine/core'; + +import { useToast } from '@/hooks/use-toast'; +import { useCreateWarehouse, useUpdateWarehouse } from '@/hooks/useWarehouses'; +import type { SaveWarehousePayload, Warehouse, WarehouseType } from '@/types/warehouse'; +import { extractErrorMessage, statusOptions, warehouseTypeOptions } from './options'; + +interface CreateWarehouseModalProps { + opened: boolean; + onClose: () => void; + warehouse?: Warehouse | null; +} + +interface FormState { + name: string; + code: string; + type: WarehouseType; + locationName: string; + capacityWeight: number | ''; + capacityContainers: number | ''; + status: 'ACTIVE' | 'INACTIVE'; +} + +const emptyForm = (): FormState => ({ + name: '', + code: '', + type: 'OPEN_WAREHOUSE', + locationName: '', + capacityWeight: '', + capacityContainers: '', + status: 'ACTIVE', +}); + +export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWarehouseModalProps) { + const isEdit = Boolean(warehouse); + const { toast } = useToast(); + const createMutation = useCreateWarehouse(); + const updateMutation = useUpdateWarehouse(); + const [form, setForm] = useState(emptyForm()); + + useEffect(() => { + if (opened) { + setForm( + warehouse + ? { + name: warehouse.name, + code: warehouse.code, + type: warehouse.type, + locationName: warehouse.locationName ?? '', + capacityWeight: warehouse.capacityWeight ?? '', + capacityContainers: warehouse.capacityContainers ?? '', + status: warehouse.status, + } + : emptyForm(), + ); + } + }, [opened, warehouse]); + + const submitting = createMutation.isPending || updateMutation.isPending; + + const handleSubmit = async () => { + if (!form.name.trim() || !form.code.trim()) { + toast({ variant: 'destructive', title: 'Name and code are required' }); + return; + } + + const payload: SaveWarehousePayload = { + name: form.name.trim(), + code: form.code.trim(), + type: form.type, + locationName: form.locationName.trim() || undefined, + capacityWeight: form.capacityWeight === '' ? undefined : Number(form.capacityWeight), + capacityContainers: form.capacityContainers === '' ? undefined : Number(form.capacityContainers), + }; + + try { + if (warehouse) { + await updateMutation.mutateAsync({ id: warehouse.id, payload: { ...payload, status: form.status } }); + toast({ title: 'Warehouse updated' }); + } else { + await createMutation.mutateAsync(payload); + toast({ title: 'Warehouse created' }); + } + onClose(); + } catch (error) { + toast({ variant: 'destructive', title: 'Save failed', description: extractErrorMessage(error) }); + } + }; + + return ( + + + + setForm((f) => ({ ...f, name: e.currentTarget.value }))} + /> + setForm((f) => ({ ...f, code: e.currentTarget.value }))} + /> + + + + setForm((f) => ({ ...f, status: (value as 'ACTIVE' | 'INACTIVE') ?? 'ACTIVE' }))} + allowDeselect={false} + /> + )} + + + setForm((f) => ({ ...f, locationName: e.currentTarget.value }))} + /> + + + setForm((f) => ({ ...f, capacityWeight: value === '' ? '' : Number(value) }))} + /> + setForm((f) => ({ ...f, capacityContainers: value === '' ? '' : Number(value) }))} + /> + + + + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/CreateYardModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/CreateYardModal.tsx new file mode 100644 index 000000000..e9230ceae --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/CreateYardModal.tsx @@ -0,0 +1,155 @@ +import { useEffect, useState } from 'react'; +import { Button, Group, Modal, NumberInput, Select, Stack, TextInput } from '@mantine/core'; + +import { useToast } from '@/hooks/use-toast'; +import { useCreateYard, useUpdateYard } from '@/hooks/useWarehouses'; +import type { SaveYardPayload, WarehouseYard, WarehouseYardType } from '@/types/warehouse'; +import { extractErrorMessage, statusOptions, yardTypeOptions } from './options'; + +interface CreateYardModalProps { + opened: boolean; + onClose: () => void; + warehouseId: string; + yard?: WarehouseYard | null; +} + +interface FormState { + name: string; + code: string; + type: WarehouseYardType; + capacityWeight: number | ''; + capacityContainers: number | ''; + status: 'ACTIVE' | 'INACTIVE'; +} + +const emptyForm = (): FormState => ({ + name: '', + code: '', + type: 'CONTAINER_YARD', + capacityWeight: '', + capacityContainers: '', + status: 'ACTIVE', +}); + +export function CreateYardModal({ opened, onClose, warehouseId, yard }: CreateYardModalProps) { + const isEdit = Boolean(yard); + const { toast } = useToast(); + const createMutation = useCreateYard(); + const updateMutation = useUpdateYard(); + const [form, setForm] = useState(emptyForm()); + + useEffect(() => { + if (opened) { + setForm( + yard + ? { + name: yard.name, + code: yard.code, + type: yard.type, + capacityWeight: yard.capacityWeight ?? '', + capacityContainers: yard.capacityContainers ?? '', + status: yard.status, + } + : emptyForm(), + ); + } + }, [opened, yard]); + + const submitting = createMutation.isPending || updateMutation.isPending; + + const handleSubmit = async () => { + if (!form.name.trim() || !form.code.trim()) { + toast({ variant: 'destructive', title: 'Name and code are required' }); + return; + } + + const payload: SaveYardPayload = { + name: form.name.trim(), + code: form.code.trim(), + type: form.type, + capacityWeight: form.capacityWeight === '' ? undefined : Number(form.capacityWeight), + capacityContainers: form.capacityContainers === '' ? undefined : Number(form.capacityContainers), + }; + + try { + if (yard) { + await updateMutation.mutateAsync({ id: yard.id, payload: { ...payload, status: form.status } }); + toast({ title: 'Yard updated' }); + } else { + await createMutation.mutateAsync({ warehouseId, payload }); + toast({ title: 'Yard created' }); + } + onClose(); + } catch (error) { + toast({ variant: 'destructive', title: 'Save failed', description: extractErrorMessage(error) }); + } + }; + + return ( + + + + setForm((f) => ({ ...f, name: e.currentTarget.value }))} + /> + setForm((f) => ({ ...f, code: e.currentTarget.value }))} + /> + + + + setForm((f) => ({ ...f, status: (value as 'ACTIVE' | 'INACTIVE') ?? 'ACTIVE' }))} + allowDeselect={false} + /> + )} + + + + setForm((f) => ({ ...f, capacityWeight: value === '' ? '' : Number(value) }))} + /> + setForm((f) => ({ ...f, capacityContainers: value === '' ? '' : Number(value) }))} + /> + + + + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/CreateZoneModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/CreateZoneModal.tsx new file mode 100644 index 000000000..9c283a1c7 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/CreateZoneModal.tsx @@ -0,0 +1,155 @@ +import { useEffect, useState } from 'react'; +import { Button, Group, Modal, NumberInput, Select, Stack, TextInput } from '@mantine/core'; + +import { useToast } from '@/hooks/use-toast'; +import { useCreateZone, useUpdateZone } from '@/hooks/useWarehouses'; +import type { SaveZonePayload, WarehouseZone, WarehouseZoneType } from '@/types/warehouse'; +import { extractErrorMessage, statusOptions, zoneTypeOptions } from './options'; + +interface CreateZoneModalProps { + opened: boolean; + onClose: () => void; + yardId: string; + zone?: WarehouseZone | null; +} + +interface FormState { + name: string; + code: string; + type: WarehouseZoneType; + capacityWeight: number | ''; + capacityContainers: number | ''; + status: 'ACTIVE' | 'INACTIVE'; +} + +const emptyForm = (): FormState => ({ + name: '', + code: '', + type: 'CONTAINER_ZONE', + capacityWeight: '', + capacityContainers: '', + status: 'ACTIVE', +}); + +export function CreateZoneModal({ opened, onClose, yardId, zone }: CreateZoneModalProps) { + const isEdit = Boolean(zone); + const { toast } = useToast(); + const createMutation = useCreateZone(); + const updateMutation = useUpdateZone(); + const [form, setForm] = useState(emptyForm()); + + useEffect(() => { + if (opened) { + setForm( + zone + ? { + name: zone.name, + code: zone.code, + type: zone.type, + capacityWeight: zone.capacityWeight ?? '', + capacityContainers: zone.capacityContainers ?? '', + status: zone.status, + } + : emptyForm(), + ); + } + }, [opened, zone]); + + const submitting = createMutation.isPending || updateMutation.isPending; + + const handleSubmit = async () => { + if (!form.name.trim() || !form.code.trim()) { + toast({ variant: 'destructive', title: 'Name and code are required' }); + return; + } + + const payload: SaveZonePayload = { + name: form.name.trim(), + code: form.code.trim(), + type: form.type, + capacityWeight: form.capacityWeight === '' ? undefined : Number(form.capacityWeight), + capacityContainers: form.capacityContainers === '' ? undefined : Number(form.capacityContainers), + }; + + try { + if (zone) { + await updateMutation.mutateAsync({ id: zone.id, payload: { ...payload, status: form.status } }); + toast({ title: 'Zone updated' }); + } else { + await createMutation.mutateAsync({ yardId, payload }); + toast({ title: 'Zone created' }); + } + onClose(); + } catch (error) { + toast({ variant: 'destructive', title: 'Save failed', description: extractErrorMessage(error) }); + } + }; + + return ( + + + + setForm((f) => ({ ...f, name: e.currentTarget.value }))} + /> + setForm((f) => ({ ...f, code: e.currentTarget.value }))} + /> + + + + setForm((f) => ({ ...f, status: (value as 'ACTIVE' | 'INACTIVE') ?? 'ACTIVE' }))} + allowDeselect={false} + /> + )} + + + + setForm((f) => ({ ...f, capacityWeight: value === '' ? '' : Number(value) }))} + /> + setForm((f) => ({ ...f, capacityContainers: value === '' ? '' : Number(value) }))} + /> + + + + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx new file mode 100644 index 000000000..bb232b089 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -0,0 +1,215 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Button, Group, Modal, NumberInput, Select, Stack, Textarea, TextInput } from '@mantine/core'; + +import { useToast } from '@/hooks/use-toast'; +import { + useReceiveInventory, + useWarehouseYards, + useWarehouseZones, + useWarehouses, +} from '@/hooks/useWarehouses'; +import type { ReceiveInventoryPayload } from '@/types/warehouse'; +import { extractErrorMessage } from './options'; + +interface ReceiveInventoryModalProps { + opened: boolean; + onClose: () => void; + /** When supplied the booking field is locked to this booking. */ + bookingId?: string; + bookingLabel?: string; + onReceived?: () => void; +} + +interface FormState { + bookingId: string; + warehouseId: string; + yardId: string; + zoneId: string; + quantity: number | ''; + weight: number | ''; + volume: number | ''; + notes: string; +} + +const emptyForm = (bookingId?: string): FormState => ({ + bookingId: bookingId ?? '', + warehouseId: '', + yardId: '', + zoneId: '', + quantity: '', + weight: '', + volume: '', + notes: '', +}); + +export function ReceiveInventoryModal({ + opened, + onClose, + bookingId, + bookingLabel, + onReceived, +}: ReceiveInventoryModalProps) { + const { toast } = useToast(); + const receiveMutation = useReceiveInventory(); + const [form, setForm] = useState(emptyForm(bookingId)); + + useEffect(() => { + if (opened) setForm(emptyForm(bookingId)); + }, [opened, bookingId]); + + // Cascading data — only ACTIVE warehouses are selectable for receiving. + const warehousesQuery = useWarehouses({ status: 'ACTIVE' }); + const yardsQuery = useWarehouseYards(form.warehouseId || undefined); + const zonesQuery = useWarehouseZones(form.yardId || undefined); + + const warehouseOptions = useMemo( + () => + (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })), + [warehousesQuery.data], + ); + const yardOptions = useMemo( + () => + (yardsQuery.data ?? []) + .filter((y) => y.status === 'ACTIVE') + .map((y) => ({ value: y.id, label: `${y.name} (${y.code})` })), + [yardsQuery.data], + ); + const zoneOptions = useMemo( + () => + (zonesQuery.data ?? []) + .filter((z) => z.status === 'ACTIVE') + .map((z) => ({ value: z.id, label: `${z.name} (${z.code})` })), + [zonesQuery.data], + ); + + const submitting = receiveMutation.isPending; + + const handleSubmit = async () => { + if (!form.bookingId.trim()) { + toast({ variant: 'destructive', title: 'Booking is required' }); + return; + } + if (!form.warehouseId || !form.yardId || !form.zoneId) { + toast({ variant: 'destructive', title: 'Select warehouse, yard and zone' }); + return; + } + if (form.quantity === '' || form.weight === '') { + toast({ variant: 'destructive', title: 'Quantity and weight are required' }); + return; + } + + const payload: ReceiveInventoryPayload = { + bookingId: form.bookingId.trim(), + warehouseId: form.warehouseId, + yardId: form.yardId, + zoneId: form.zoneId, + quantity: Number(form.quantity), + weight: Number(form.weight), + volume: form.volume === '' ? undefined : Number(form.volume), + notes: form.notes.trim() || undefined, + }; + + try { + await receiveMutation.mutateAsync(payload); + toast({ title: 'Inventory received', description: 'Status set to ARRIVED_AT_WAREHOUSE' }); + onReceived?.(); + onClose(); + } catch (error) { + toast({ variant: 'destructive', title: 'Receive failed', description: extractErrorMessage(error) }); + } + }; + + return ( + + + {bookingId ? ( + + ) : ( + setForm((f) => ({ ...f, bookingId: e.currentTarget.value }))} + /> + )} + + setForm((f) => ({ ...f, yardId: value ?? '', zoneId: '' }))} + /> + +