mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 13:28:11 +00:00
feat(warehouse): add warehouse management module (batch 1)
Backend (edr-freight-api): - Warehouse, WarehouseYard, WarehouseZone, WarehouseInventory entities - CRUD for warehouses/yards/zones with scoped code uniqueness - Inventory receive with location-hierarchy + capacity validation and transactional capacity-counter updates - inspect / ready-for-loading status transitions - inventory inquiry (booking/customer/container/cargo-type joins) - migration creating freight.warehouse* tables Frontend (freight backoffice): - types, service, react-query hooks, URL constants - reusable components: badges, filters, table/card views, inventory and inquiry tables, create/receive modals - pages: Warehouse list, detail (overview/yards/zones/inventory tabs), inventory, inventory inquiry - Warehouse Information card + Receive At Warehouse on booking detail - routes + sidebar nav; app-wide ErrorBoundary Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -48,6 +48,7 @@ import { WagonsModule } from './modules/wagons/wagons.module';
|
|||||||
import { ContainersModule } from './modules/container-management/containers.module';
|
import { ContainersModule } from './modules/container-management/containers.module';
|
||||||
import { CargoesModule } from './modules/cargoes/cargoes.module';
|
import { CargoesModule } from './modules/cargoes/cargoes.module';
|
||||||
import { RoutesModule } from './modules/routes/routes.module';
|
import { RoutesModule } from './modules/routes/routes.module';
|
||||||
|
import { WarehousesModule } from './modules/warehouses/warehouses.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -100,6 +101,7 @@ import { RoutesModule } from './modules/routes/routes.module';
|
|||||||
ContainersModule,
|
ContainersModule,
|
||||||
CargoesModule,
|
CargoesModule,
|
||||||
RoutesModule,
|
RoutesModule,
|
||||||
|
WarehousesModule,
|
||||||
],
|
],
|
||||||
providers: [EdrOrgSeeder, DemoUsersSeeder,FreightStaffUsersSeeder, DemoBookingsSeeder, PricingDataSeeder, FileUploadSettingsSeeder],
|
providers: [EdrOrgSeeder, DemoUsersSeeder,FreightStaffUsersSeeder, DemoBookingsSeeder, PricingDataSeeder, FileUploadSettingsSeeder],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class CreateWarehouseModule1790000000000 implements MigrationInterface {
|
||||||
|
name = 'CreateWarehouseModule1790000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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;`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<WarehouseInventory> {
|
||||||
|
constructor(@InjectRepository(WarehouseInventory) repository: Repository<WarehouseInventory>) {
|
||||||
|
super(repository);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<WarehouseInventory[]> {
|
||||||
|
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<WarehouseInventory>['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<WarehouseInventory[]> {
|
||||||
|
return this.findAll({ ...filter, status: 'READY_FOR_LOADING' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<WarehouseInventory> {
|
||||||
|
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<WarehouseInventory> {
|
||||||
|
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<WarehouseInventory> {
|
||||||
|
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<WarehouseInventory> {
|
||||||
|
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<InventoryInquiryResult[]> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<WarehouseYard> {
|
||||||
|
constructor(@InjectRepository(WarehouseYard) repository: Repository<WarehouseYard>) {
|
||||||
|
super(repository);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<WarehouseYard[]> {
|
||||||
|
return this.yardsRepository.findAll({
|
||||||
|
where: { warehouseId },
|
||||||
|
relations: { zones: true },
|
||||||
|
order: { code: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<WarehouseYard> {
|
||||||
|
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<WarehouseYard> {
|
||||||
|
// 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<WarehouseYard> {
|
||||||
|
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<void> {
|
||||||
|
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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<WarehouseZone> {
|
||||||
|
constructor(@InjectRepository(WarehouseZone) repository: Repository<WarehouseZone>) {
|
||||||
|
super(repository);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<WarehouseZone[]> {
|
||||||
|
return this.zonesRepository.findAll({
|
||||||
|
where: { yardId },
|
||||||
|
order: { code: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<WarehouseZone> {
|
||||||
|
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<WarehouseZone> {
|
||||||
|
// 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<WarehouseZone> {
|
||||||
|
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<void> {
|
||||||
|
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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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<Warehouse> {
|
||||||
|
constructor(@InjectRepository(Warehouse) repository: Repository<Warehouse>) {
|
||||||
|
super(repository);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Warehouse[]> {
|
||||||
|
const where: FindManyOptions<Warehouse>['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<Warehouse> {
|
||||||
|
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<Warehouse> {
|
||||||
|
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<Warehouse> {
|
||||||
|
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<void> {
|
||||||
|
const [existing] = await this.warehousesRepository.findAll({ where: { code } });
|
||||||
|
|
||||||
|
if (existing && existing.id !== ignoreId) {
|
||||||
|
throw new ConflictException(`Warehouse code ${code} already exists`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,6 +45,10 @@ import {
|
|||||||
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
|
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
|
||||||
import TrainDetailPage from "./pages/trains/TrainDetailPage";
|
import TrainDetailPage from "./pages/trains/TrainDetailPage";
|
||||||
import RoutesPage from "./pages/fleet/RoutesPage";
|
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[] => [
|
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||||
{
|
{
|
||||||
@@ -114,6 +118,26 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Warehouse Management",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: "Warehouses",
|
||||||
|
href: "/dashboard/warehouses",
|
||||||
|
icon: <Container />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Inventory",
|
||||||
|
href: "/dashboard/warehouse-inventory",
|
||||||
|
icon: <Package />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Inventory Inquiry",
|
||||||
|
href: "/dashboard/inventory-inquiry",
|
||||||
|
icon: <Boxes />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Administration",
|
title: "Administration",
|
||||||
items: [
|
items: [
|
||||||
@@ -256,6 +280,11 @@ const App = () => {
|
|||||||
<Route path="containers" element={<ContainersCrudPage />} />
|
<Route path="containers" element={<ContainersCrudPage />} />
|
||||||
<Route path="cargoes" element={<CargoesCrudPage />} />
|
<Route path="cargoes" element={<CargoesCrudPage />} />
|
||||||
|
|
||||||
|
<Route path="warehouses" element={<WarehouseListPage />} />
|
||||||
|
<Route path="warehouses/:id" element={<WarehouseDetailPage />} />
|
||||||
|
<Route path="warehouse-inventory" element={<WarehouseInventoryPage />} />
|
||||||
|
<Route path="inventory-inquiry" element={<InventoryInquiryPage />} />
|
||||||
|
|
||||||
<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 />} />
|
||||||
<Route path="user-management/position-types" element={<PositionTypesPage />} />
|
<Route path="user-management/position-types" element={<PositionTypesPage />} />
|
||||||
|
|||||||
@@ -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<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
minHeight: "100vh",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: 24,
|
||||||
|
background: "#f8fafc",
|
||||||
|
fontFamily: "system-ui, sans-serif",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
maxWidth: 720,
|
||||||
|
width: "100%",
|
||||||
|
background: "#fff",
|
||||||
|
border: "1px solid #fecaca",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 24,
|
||||||
|
boxShadow: "0 1px 3px rgba(0,0,0,0.08)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2 style={{ margin: 0, color: "#b91c1c", fontSize: 18 }}>Something went wrong</h2>
|
||||||
|
<p style={{ color: "#64748b", fontSize: 14 }}>
|
||||||
|
A render error was caught. Details below — share this with the developer.
|
||||||
|
</p>
|
||||||
|
<pre
|
||||||
|
style={{
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
wordBreak: "break-word",
|
||||||
|
background: "#0f172a",
|
||||||
|
color: "#fca5a5",
|
||||||
|
padding: 16,
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 12,
|
||||||
|
maxHeight: 320,
|
||||||
|
overflow: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error.message}
|
||||||
|
{"\n\n"}
|
||||||
|
{error.stack}
|
||||||
|
</pre>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={this.handleReset}
|
||||||
|
style={{
|
||||||
|
marginTop: 12,
|
||||||
|
padding: "8px 16px",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 8,
|
||||||
|
background: "#0f766e",
|
||||||
|
color: "#fff",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<FormState>(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 (
|
||||||
|
<Modal opened={opened} onClose={onClose} title={isEdit ? 'Edit warehouse' : 'Create warehouse'} centered size="lg">
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group grow>
|
||||||
|
<TextInput
|
||||||
|
label="Name"
|
||||||
|
placeholder="Modjo Open Warehouse"
|
||||||
|
required
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, name: e.currentTarget.value }))}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Code"
|
||||||
|
placeholder="MODJO-OW"
|
||||||
|
required
|
||||||
|
value={form.code}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, code: e.currentTarget.value }))}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group grow>
|
||||||
|
<Select
|
||||||
|
label="Type"
|
||||||
|
data={warehouseTypeOptions}
|
||||||
|
value={form.type}
|
||||||
|
onChange={(value) => setForm((f) => ({ ...f, type: (value as WarehouseType) ?? 'OPEN_WAREHOUSE' }))}
|
||||||
|
allowDeselect={false}
|
||||||
|
/>
|
||||||
|
{isEdit && (
|
||||||
|
<Select
|
||||||
|
label="Status"
|
||||||
|
data={statusOptions}
|
||||||
|
value={form.status}
|
||||||
|
onChange={(value) => setForm((f) => ({ ...f, status: (value as 'ACTIVE' | 'INACTIVE') ?? 'ACTIVE' }))}
|
||||||
|
allowDeselect={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="Location name"
|
||||||
|
placeholder="Modjo, Oromia"
|
||||||
|
value={form.locationName}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, locationName: e.currentTarget.value }))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group grow>
|
||||||
|
<NumberInput
|
||||||
|
label="Capacity weight (kg)"
|
||||||
|
placeholder="Optional"
|
||||||
|
min={0}
|
||||||
|
value={form.capacityWeight}
|
||||||
|
onChange={(value) => setForm((f) => ({ ...f, capacityWeight: value === '' ? '' : Number(value) }))}
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label="Capacity containers"
|
||||||
|
placeholder="Optional"
|
||||||
|
min={0}
|
||||||
|
value={form.capacityContainers}
|
||||||
|
onChange={(value) => setForm((f) => ({ ...f, capacityContainers: value === '' ? '' : Number(value) }))}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group justify="flex-end" mt="sm">
|
||||||
|
<Button variant="default" onClick={onClose} disabled={submitting}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSubmit} loading={submitting}>
|
||||||
|
{isEdit ? 'Save changes' : 'Create warehouse'}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<FormState>(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 (
|
||||||
|
<Modal opened={opened} onClose={onClose} title={isEdit ? 'Edit yard' : 'Create yard'} centered size="lg">
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group grow>
|
||||||
|
<TextInput
|
||||||
|
label="Name"
|
||||||
|
placeholder="Container Yard A"
|
||||||
|
required
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, name: e.currentTarget.value }))}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Code"
|
||||||
|
placeholder="CY-A"
|
||||||
|
required
|
||||||
|
value={form.code}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, code: e.currentTarget.value }))}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group grow>
|
||||||
|
<Select
|
||||||
|
label="Type"
|
||||||
|
data={yardTypeOptions}
|
||||||
|
value={form.type}
|
||||||
|
onChange={(value) => setForm((f) => ({ ...f, type: (value as WarehouseYardType) ?? 'CONTAINER_YARD' }))}
|
||||||
|
allowDeselect={false}
|
||||||
|
/>
|
||||||
|
{isEdit && (
|
||||||
|
<Select
|
||||||
|
label="Status"
|
||||||
|
data={statusOptions}
|
||||||
|
value={form.status}
|
||||||
|
onChange={(value) => setForm((f) => ({ ...f, status: (value as 'ACTIVE' | 'INACTIVE') ?? 'ACTIVE' }))}
|
||||||
|
allowDeselect={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group grow>
|
||||||
|
<NumberInput
|
||||||
|
label="Capacity weight (kg)"
|
||||||
|
placeholder="Optional"
|
||||||
|
min={0}
|
||||||
|
value={form.capacityWeight}
|
||||||
|
onChange={(value) => setForm((f) => ({ ...f, capacityWeight: value === '' ? '' : Number(value) }))}
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label="Capacity containers"
|
||||||
|
placeholder="Optional"
|
||||||
|
min={0}
|
||||||
|
value={form.capacityContainers}
|
||||||
|
onChange={(value) => setForm((f) => ({ ...f, capacityContainers: value === '' ? '' : Number(value) }))}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group justify="flex-end" mt="sm">
|
||||||
|
<Button variant="default" onClick={onClose} disabled={submitting}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSubmit} loading={submitting}>
|
||||||
|
{isEdit ? 'Save changes' : 'Create yard'}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<FormState>(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 (
|
||||||
|
<Modal opened={opened} onClose={onClose} title={isEdit ? 'Edit zone' : 'Create zone'} centered size="lg">
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group grow>
|
||||||
|
<TextInput
|
||||||
|
label="Name"
|
||||||
|
placeholder="Zone A-01"
|
||||||
|
required
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, name: e.currentTarget.value }))}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Code"
|
||||||
|
placeholder="A-01"
|
||||||
|
required
|
||||||
|
value={form.code}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, code: e.currentTarget.value }))}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group grow>
|
||||||
|
<Select
|
||||||
|
label="Type"
|
||||||
|
data={zoneTypeOptions}
|
||||||
|
value={form.type}
|
||||||
|
onChange={(value) => setForm((f) => ({ ...f, type: (value as WarehouseZoneType) ?? 'CONTAINER_ZONE' }))}
|
||||||
|
allowDeselect={false}
|
||||||
|
/>
|
||||||
|
{isEdit && (
|
||||||
|
<Select
|
||||||
|
label="Status"
|
||||||
|
data={statusOptions}
|
||||||
|
value={form.status}
|
||||||
|
onChange={(value) => setForm((f) => ({ ...f, status: (value as 'ACTIVE' | 'INACTIVE') ?? 'ACTIVE' }))}
|
||||||
|
allowDeselect={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group grow>
|
||||||
|
<NumberInput
|
||||||
|
label="Capacity weight (kg)"
|
||||||
|
placeholder="Optional"
|
||||||
|
min={0}
|
||||||
|
value={form.capacityWeight}
|
||||||
|
onChange={(value) => setForm((f) => ({ ...f, capacityWeight: value === '' ? '' : Number(value) }))}
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label="Capacity containers"
|
||||||
|
placeholder="Optional"
|
||||||
|
min={0}
|
||||||
|
value={form.capacityContainers}
|
||||||
|
onChange={(value) => setForm((f) => ({ ...f, capacityContainers: value === '' ? '' : Number(value) }))}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group justify="flex-end" mt="sm">
|
||||||
|
<Button variant="default" onClick={onClose} disabled={submitting}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSubmit} loading={submitting}>
|
||||||
|
{isEdit ? 'Save changes' : 'Create zone'}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<FormState>(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 (
|
||||||
|
<Modal opened={opened} onClose={onClose} title="Receive at warehouse" centered size="lg">
|
||||||
|
<Stack gap="md">
|
||||||
|
{bookingId ? (
|
||||||
|
<TextInput label="Booking" value={bookingLabel ?? bookingId} readOnly />
|
||||||
|
) : (
|
||||||
|
<TextInput
|
||||||
|
label="Booking ID"
|
||||||
|
placeholder="Booking UUID"
|
||||||
|
required
|
||||||
|
value={form.bookingId}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, bookingId: e.currentTarget.value }))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="Warehouse"
|
||||||
|
placeholder={warehousesQuery.isLoading ? 'Loading…' : 'Select warehouse'}
|
||||||
|
required
|
||||||
|
searchable
|
||||||
|
data={warehouseOptions}
|
||||||
|
value={form.warehouseId || null}
|
||||||
|
onChange={(value) =>
|
||||||
|
setForm((f) => ({ ...f, warehouseId: value ?? '', yardId: '', zoneId: '' }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="Yard"
|
||||||
|
placeholder={!form.warehouseId ? 'Select a warehouse first' : 'Select yard'}
|
||||||
|
required
|
||||||
|
searchable
|
||||||
|
disabled={!form.warehouseId}
|
||||||
|
data={yardOptions}
|
||||||
|
value={form.yardId || null}
|
||||||
|
onChange={(value) => setForm((f) => ({ ...f, yardId: value ?? '', zoneId: '' }))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="Zone"
|
||||||
|
placeholder={!form.yardId ? 'Select a yard first' : 'Select zone'}
|
||||||
|
required
|
||||||
|
searchable
|
||||||
|
disabled={!form.yardId}
|
||||||
|
data={zoneOptions}
|
||||||
|
value={form.zoneId || null}
|
||||||
|
onChange={(value) => setForm((f) => ({ ...f, zoneId: value ?? '' }))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group grow>
|
||||||
|
<NumberInput
|
||||||
|
label="Quantity"
|
||||||
|
required
|
||||||
|
min={0}
|
||||||
|
value={form.quantity}
|
||||||
|
onChange={(value) => setForm((f) => ({ ...f, quantity: value === '' ? '' : Number(value) }))}
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label="Weight (kg)"
|
||||||
|
required
|
||||||
|
min={0}
|
||||||
|
value={form.weight}
|
||||||
|
onChange={(value) => setForm((f) => ({ ...f, weight: value === '' ? '' : Number(value) }))}
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label="Volume (m³)"
|
||||||
|
placeholder="Optional"
|
||||||
|
min={0}
|
||||||
|
value={form.volume}
|
||||||
|
onChange={(value) => setForm((f) => ({ ...f, volume: value === '' ? '' : Number(value) }))}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
label="Notes"
|
||||||
|
placeholder="Optional notes"
|
||||||
|
autosize
|
||||||
|
minRows={2}
|
||||||
|
value={form.notes}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, notes: e.currentTarget.value }))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group justify="flex-end" mt="sm">
|
||||||
|
<Button variant="default" onClick={onClose} disabled={submitting}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSubmit} loading={submitting}>
|
||||||
|
Receive inventory
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { ActionIcon, Card, Group, SimpleGrid, Stack, Text } from '@mantine/core';
|
||||||
|
import { Eye, MapPin, Pencil } from 'lucide-react';
|
||||||
|
|
||||||
|
import type { Warehouse } from '@/types/warehouse';
|
||||||
|
import { WarehouseStatusBadge, WarehouseTypeBadge } from './badges';
|
||||||
|
import { formatCapacity } from './options';
|
||||||
|
|
||||||
|
interface WarehouseCardViewProps {
|
||||||
|
warehouses: Warehouse[];
|
||||||
|
onView: (warehouse: Warehouse) => void;
|
||||||
|
onEdit: (warehouse: Warehouse) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WarehouseCardView({ warehouses, onView, onEdit }: WarehouseCardViewProps) {
|
||||||
|
if (warehouses.length === 0) {
|
||||||
|
return (
|
||||||
|
<Text c="dimmed" ta="center" py="xl">
|
||||||
|
No warehouses found.
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
{warehouses.map((warehouse) => (
|
||||||
|
<Card key={warehouse.id} withBorder radius="md" padding="lg">
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Group justify="space-between" wrap="nowrap" align="flex-start">
|
||||||
|
<div>
|
||||||
|
<Text fw={700}>{warehouse.name}</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{warehouse.code}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<WarehouseStatusBadge status={warehouse.status} />
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group gap="xs">
|
||||||
|
<WarehouseTypeBadge type={warehouse.type} />
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{warehouse.locationName && (
|
||||||
|
<Group gap={6} c="dimmed">
|
||||||
|
<MapPin size={14} />
|
||||||
|
<Text size="sm">{warehouse.locationName}</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Weight
|
||||||
|
</Text>
|
||||||
|
<Text size="sm">{formatCapacity(warehouse.currentWeight, warehouse.capacityWeight)}</Text>
|
||||||
|
</Group>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Containers
|
||||||
|
</Text>
|
||||||
|
<Text size="sm">{formatCapacity(warehouse.currentContainers, warehouse.capacityContainers)}</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group justify="flex-end" gap="xs" mt="xs">
|
||||||
|
<ActionIcon variant="subtle" color="gray" onClick={() => onView(warehouse)} title="View">
|
||||||
|
<Eye size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
<ActionIcon variant="subtle" color="gray" onClick={() => onEdit(warehouse)} title="Edit">
|
||||||
|
<Pencil size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { Group, SegmentedControl, Select, TextInput } from '@mantine/core';
|
||||||
|
import { LayoutGrid, Search, Table as TableIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import type { WarehouseFilter, WarehouseStatus, WarehouseType } from '@/types/warehouse';
|
||||||
|
import { statusOptions, warehouseTypeOptions } from './options';
|
||||||
|
|
||||||
|
export type WarehouseView = 'table' | 'card';
|
||||||
|
|
||||||
|
interface WarehouseFiltersProps {
|
||||||
|
filter: WarehouseFilter;
|
||||||
|
onChange: (next: WarehouseFilter) => void;
|
||||||
|
view: WarehouseView;
|
||||||
|
onViewChange: (view: WarehouseView) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WarehouseFilters({ filter, onChange, view, onViewChange }: WarehouseFiltersProps) {
|
||||||
|
return (
|
||||||
|
<Group justify="space-between" wrap="wrap" gap="sm">
|
||||||
|
<Group gap="sm" wrap="wrap">
|
||||||
|
<TextInput
|
||||||
|
placeholder="Search by name, code or location"
|
||||||
|
leftSection={<Search size={16} />}
|
||||||
|
value={filter.search ?? ''}
|
||||||
|
onChange={(e) => onChange({ ...filter, search: e.currentTarget.value || undefined })}
|
||||||
|
w={280}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
placeholder="All types"
|
||||||
|
clearable
|
||||||
|
data={warehouseTypeOptions}
|
||||||
|
value={filter.type ?? null}
|
||||||
|
onChange={(value) => onChange({ ...filter, type: (value as WarehouseType) || undefined })}
|
||||||
|
w={190}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
placeholder="All statuses"
|
||||||
|
clearable
|
||||||
|
data={statusOptions}
|
||||||
|
value={filter.status ?? null}
|
||||||
|
onChange={(value) => onChange({ ...filter, status: (value as WarehouseStatus) || undefined })}
|
||||||
|
w={160}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<SegmentedControl
|
||||||
|
value={view}
|
||||||
|
onChange={(value) => onViewChange(value as WarehouseView)}
|
||||||
|
data={[
|
||||||
|
{ value: 'table', label: <TableIcon size={16} /> },
|
||||||
|
{ value: 'card', label: <LayoutGrid size={16} /> },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Badge, Button, Card, Divider, Group, Stack, Text } from '@mantine/core';
|
||||||
|
import { PackagePlus, Warehouse as WarehouseIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { useWarehouseInventory } from '@/hooks/useWarehouses';
|
||||||
|
import { InventoryStatusBadge } from './badges';
|
||||||
|
import { formatDate } from './options';
|
||||||
|
import { ReceiveInventoryModal } from './ReceiveInventoryModal';
|
||||||
|
|
||||||
|
interface WarehouseInfoCardProps {
|
||||||
|
bookingId: string;
|
||||||
|
bookingReference?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Group justify="space-between" wrap="nowrap">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" fw={500} ta="right">
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WarehouseInfoCard({ bookingId, bookingReference }: WarehouseInfoCardProps) {
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const { data, isLoading } = useWarehouseInventory({ bookingId });
|
||||||
|
|
||||||
|
const items = data ?? [];
|
||||||
|
const latest = items[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card withBorder radius="md" padding="lg">
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Group gap="xs">
|
||||||
|
<WarehouseIcon size={18} />
|
||||||
|
<Text fw={700}>Warehouse Information</Text>
|
||||||
|
</Group>
|
||||||
|
{items.length > 0 && (
|
||||||
|
<Badge variant="light" color="gray">
|
||||||
|
{items.length} item{items.length > 1 ? 's' : ''}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Loading…
|
||||||
|
</Text>
|
||||||
|
) : !latest ? (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
This booking has not been received at any warehouse yet.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Row label="Warehouse" value={latest.warehouse ? `${latest.warehouse.name} (${latest.warehouse.code})` : '—'} />
|
||||||
|
<Row label="Yard" value={latest.yard ? `${latest.yard.name} (${latest.yard.code})` : '—'} />
|
||||||
|
<Row label="Zone" value={latest.zone ? `${latest.zone.name} (${latest.zone.code})` : '—'} />
|
||||||
|
<Row label="Inventory Status" value={<InventoryStatusBadge status={latest.status} />} />
|
||||||
|
<Row label="Arrived At" value={formatDate(latest.arrivedAt)} />
|
||||||
|
<Row label="Ready For Loading At" value={formatDate(latest.readyForLoadingAt)} />
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
leftSection={<PackagePlus size={16} />}
|
||||||
|
onClick={() => setModalOpen(true)}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
Receive At Warehouse
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<ReceiveInventoryModal
|
||||||
|
opened={modalOpen}
|
||||||
|
onClose={() => setModalOpen(false)}
|
||||||
|
bookingId={bookingId}
|
||||||
|
bookingLabel={bookingReference}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { Stack, Table, Text } from '@mantine/core';
|
||||||
|
|
||||||
|
import type { InventoryInquiryResult } from '@/types/warehouse';
|
||||||
|
import { InventoryStatusBadge } from './badges';
|
||||||
|
import { formatDate, formatNumber } from './options';
|
||||||
|
|
||||||
|
interface WarehouseInquiryTableProps {
|
||||||
|
results: InventoryInquiryResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemDescriptor = (result: InventoryInquiryResult) => {
|
||||||
|
if (result.containerNumber) return `Container ${result.containerNumber}`;
|
||||||
|
if (result.cargoType) return `Cargo · ${result.cargoType}`;
|
||||||
|
if (result.cargoDescription) return `Cargo · ${result.cargoDescription}`;
|
||||||
|
if (result.goodsId) return 'Goods';
|
||||||
|
return '—';
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WarehouseInquiryTable({ results }: WarehouseInquiryTableProps) {
|
||||||
|
if (results.length === 0) {
|
||||||
|
return (
|
||||||
|
<Text c="dimmed" ta="center" py="xl">
|
||||||
|
No matching items. Adjust your search to locate cargo, containers or goods.
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table.ScrollContainer minWidth={1100}>
|
||||||
|
<Table highlightOnHover verticalSpacing="sm" striped>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Booking</Table.Th>
|
||||||
|
<Table.Th>Customer</Table.Th>
|
||||||
|
<Table.Th>Item</Table.Th>
|
||||||
|
<Table.Th>Warehouse</Table.Th>
|
||||||
|
<Table.Th>Yard</Table.Th>
|
||||||
|
<Table.Th>Zone</Table.Th>
|
||||||
|
<Table.Th>Status</Table.Th>
|
||||||
|
<Table.Th>Qty</Table.Th>
|
||||||
|
<Table.Th>Weight</Table.Th>
|
||||||
|
<Table.Th>Arrived</Table.Th>
|
||||||
|
<Table.Th>Ready</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{results.map((result) => (
|
||||||
|
<Table.Tr key={result.id}>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{result.bookingNumber ?? result.bookingId.slice(0, 8)}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>{result.customerName ?? '—'}</Table.Td>
|
||||||
|
<Table.Td>{itemDescriptor(result)}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text size="sm">{result.warehouse?.name ?? '—'}</Text>
|
||||||
|
{result.warehouse?.code && (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{result.warehouse.code}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>{result.yard?.name ?? '—'}</Table.Td>
|
||||||
|
<Table.Td>{result.zone?.name ?? '—'}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<InventoryStatusBadge status={result.status} />
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>{formatNumber(result.quantity)}</Table.Td>
|
||||||
|
<Table.Td>{formatNumber(result.weight)}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="xs">{formatDate(result.arrivedAt)}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="xs">{formatDate(result.readyForLoadingAt)}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Table.ScrollContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { Badge, Button, Group, Table, Text, Tooltip } from '@mantine/core';
|
||||||
|
import { ClipboardCheck, PackageCheck } from 'lucide-react';
|
||||||
|
|
||||||
|
import type { WarehouseInventoryItem } from '@/types/warehouse';
|
||||||
|
import { InventoryStatusBadge } from './badges';
|
||||||
|
import { formatDate, formatNumber } from './options';
|
||||||
|
|
||||||
|
interface WarehouseInventoryTableProps {
|
||||||
|
items: WarehouseInventoryItem[];
|
||||||
|
onInspect: (item: WarehouseInventoryItem) => void;
|
||||||
|
onReadyForLoading: (item: WarehouseInventoryItem) => void;
|
||||||
|
busyId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemKind = (item: WarehouseInventoryItem) => {
|
||||||
|
if (item.containerId) return { label: 'Container', color: 'blue' };
|
||||||
|
if (item.cargoId) return { label: 'Cargo', color: 'grape' };
|
||||||
|
if (item.goodsId) return { label: 'Goods', color: 'orange' };
|
||||||
|
return { label: '—', color: 'gray' };
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WarehouseInventoryTable({
|
||||||
|
items,
|
||||||
|
onInspect,
|
||||||
|
onReadyForLoading,
|
||||||
|
busyId,
|
||||||
|
}: WarehouseInventoryTableProps) {
|
||||||
|
if (items.length === 0) {
|
||||||
|
return (
|
||||||
|
<Text c="dimmed" ta="center" py="xl">
|
||||||
|
No inventory items found.
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table.ScrollContainer minWidth={1100}>
|
||||||
|
<Table highlightOnHover verticalSpacing="sm" striped>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Booking</Table.Th>
|
||||||
|
<Table.Th>Warehouse</Table.Th>
|
||||||
|
<Table.Th>Yard</Table.Th>
|
||||||
|
<Table.Th>Zone</Table.Th>
|
||||||
|
<Table.Th>Item</Table.Th>
|
||||||
|
<Table.Th>Qty</Table.Th>
|
||||||
|
<Table.Th>Weight</Table.Th>
|
||||||
|
<Table.Th>Status</Table.Th>
|
||||||
|
<Table.Th>Arrived</Table.Th>
|
||||||
|
<Table.Th>Ready</Table.Th>
|
||||||
|
<Table.Th ta="right">Actions</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{items.map((item) => {
|
||||||
|
const kind = itemKind(item);
|
||||||
|
const busy = busyId === item.id;
|
||||||
|
return (
|
||||||
|
<Table.Tr key={item.id}>
|
||||||
|
<Table.Td>
|
||||||
|
<Tooltip label={item.bookingId} withArrow>
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{item.bookingId.slice(0, 8)}…
|
||||||
|
</Text>
|
||||||
|
</Tooltip>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>{item.warehouse?.code ?? '—'}</Table.Td>
|
||||||
|
<Table.Td>{item.yard?.code ?? '—'}</Table.Td>
|
||||||
|
<Table.Td>{item.zone?.code ?? '—'}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge color={kind.color} variant="light" size="sm" radius="md">
|
||||||
|
{kind.label}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>{formatNumber(item.quantity)}</Table.Td>
|
||||||
|
<Table.Td>{formatNumber(item.weight)}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<InventoryStatusBadge status={item.status} />
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="xs">{formatDate(item.arrivedAt)}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="xs">{formatDate(item.readyForLoadingAt)}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
variant="light"
|
||||||
|
color="cyan"
|
||||||
|
leftSection={<ClipboardCheck size={14} />}
|
||||||
|
disabled={item.status !== 'ARRIVED_AT_WAREHOUSE' || busy}
|
||||||
|
loading={busy}
|
||||||
|
onClick={() => onInspect(item)}
|
||||||
|
>
|
||||||
|
Inspect
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
variant="light"
|
||||||
|
color="green"
|
||||||
|
leftSection={<PackageCheck size={14} />}
|
||||||
|
disabled={item.status !== 'UNDER_INSPECTION' || busy}
|
||||||
|
loading={busy}
|
||||||
|
onClick={() => onReadyForLoading(item)}
|
||||||
|
>
|
||||||
|
Ready
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Table.ScrollContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { ActionIcon, Anchor, Group, Table, Text } from '@mantine/core';
|
||||||
|
import { Eye, Pencil } from 'lucide-react';
|
||||||
|
|
||||||
|
import type { Warehouse } from '@/types/warehouse';
|
||||||
|
import { WarehouseStatusBadge, WarehouseTypeBadge } from './badges';
|
||||||
|
import { formatCapacity } from './options';
|
||||||
|
|
||||||
|
interface WarehouseTableProps {
|
||||||
|
warehouses: Warehouse[];
|
||||||
|
onView: (warehouse: Warehouse) => void;
|
||||||
|
onEdit: (warehouse: Warehouse) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTableProps) {
|
||||||
|
if (warehouses.length === 0) {
|
||||||
|
return (
|
||||||
|
<Text c="dimmed" ta="center" py="xl">
|
||||||
|
No warehouses found.
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table.ScrollContainer minWidth={900}>
|
||||||
|
<Table highlightOnHover verticalSpacing="sm" striped>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Code</Table.Th>
|
||||||
|
<Table.Th>Name</Table.Th>
|
||||||
|
<Table.Th>Type</Table.Th>
|
||||||
|
<Table.Th>Location</Table.Th>
|
||||||
|
<Table.Th>Weight (cur / cap)</Table.Th>
|
||||||
|
<Table.Th>Containers (cur / cap)</Table.Th>
|
||||||
|
<Table.Th>Status</Table.Th>
|
||||||
|
<Table.Th ta="right">Actions</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{warehouses.map((warehouse) => (
|
||||||
|
<Table.Tr key={warehouse.id}>
|
||||||
|
<Table.Td>
|
||||||
|
<Anchor fw={600} size="sm" onClick={() => onView(warehouse)}>
|
||||||
|
{warehouse.code}
|
||||||
|
</Anchor>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>{warehouse.name}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<WarehouseTypeBadge type={warehouse.type} />
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>{warehouse.locationName ?? '—'}</Table.Td>
|
||||||
|
<Table.Td>{formatCapacity(warehouse.currentWeight, warehouse.capacityWeight)}</Table.Td>
|
||||||
|
<Table.Td>{formatCapacity(warehouse.currentContainers, warehouse.capacityContainers)}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<WarehouseStatusBadge status={warehouse.status} />
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||||
|
<ActionIcon variant="subtle" color="gray" onClick={() => onView(warehouse)} title="View">
|
||||||
|
<Eye size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
<ActionIcon variant="subtle" color="gray" onClick={() => onEdit(warehouse)} title="Edit">
|
||||||
|
<Pencil size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Table.ScrollContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { Badge } from '@mantine/core';
|
||||||
|
|
||||||
|
import type { InventoryStatus, WarehouseStatus, WarehouseType } from '@/types/warehouse';
|
||||||
|
|
||||||
|
const humanize = (value: string) =>
|
||||||
|
value
|
||||||
|
.toLowerCase()
|
||||||
|
.split('_')
|
||||||
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
const badgeStyle = {
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
letterSpacing: '0.04em',
|
||||||
|
whiteSpace: 'nowrap' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WarehouseTypeBadge({ type }: { type: WarehouseType }) {
|
||||||
|
const color = type === 'CLOSED_WAREHOUSE' ? 'indigo' : 'teal';
|
||||||
|
return (
|
||||||
|
<Badge color={color} variant="light" size="sm" radius="md" fw={600} style={badgeStyle}>
|
||||||
|
{humanize(type)}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WarehouseStatusBadge({ status }: { status: WarehouseStatus }) {
|
||||||
|
const color = status === 'ACTIVE' ? 'green' : 'gray';
|
||||||
|
return (
|
||||||
|
<Badge color={color} variant="light" size="sm" radius="md" tt="uppercase" fw={600} style={badgeStyle}>
|
||||||
|
{status}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inventoryStatusColor: Record<InventoryStatus, string> = {
|
||||||
|
ARRIVED_AT_WAREHOUSE: 'yellow',
|
||||||
|
UNDER_INSPECTION: 'cyan',
|
||||||
|
READY_FOR_LOADING: 'green',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function InventoryStatusBadge({ status }: { status: InventoryStatus }) {
|
||||||
|
const color = inventoryStatusColor[status] ?? 'gray';
|
||||||
|
return (
|
||||||
|
<Badge color={color} variant="light" size="sm" radius="md" fw={600} style={badgeStyle}>
|
||||||
|
{humanize(status)}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export * from './badges';
|
||||||
|
export * from './options';
|
||||||
|
export { WarehouseFilters } from './WarehouseFilters';
|
||||||
|
export type { WarehouseView } from './WarehouseFilters';
|
||||||
|
export { WarehouseTable } from './WarehouseTable';
|
||||||
|
export { WarehouseCardView } from './WarehouseCardView';
|
||||||
|
export { WarehouseInventoryTable } from './WarehouseInventoryTable';
|
||||||
|
export { WarehouseInquiryTable } from './WarehouseInquiryTable';
|
||||||
|
export { CreateWarehouseModal } from './CreateWarehouseModal';
|
||||||
|
export { CreateYardModal } from './CreateYardModal';
|
||||||
|
export { CreateZoneModal } from './CreateZoneModal';
|
||||||
|
export { ReceiveInventoryModal } from './ReceiveInventoryModal';
|
||||||
|
export { WarehouseInfoCard } from './WarehouseInfoCard';
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import {
|
||||||
|
WAREHOUSE_TYPES,
|
||||||
|
WAREHOUSE_YARD_TYPES,
|
||||||
|
WAREHOUSE_ZONE_TYPES,
|
||||||
|
WAREHOUSE_STATUSES,
|
||||||
|
INVENTORY_STATUSES,
|
||||||
|
} from '@/types/warehouse';
|
||||||
|
|
||||||
|
export const humanizeEnum = (value: string) =>
|
||||||
|
value
|
||||||
|
.toLowerCase()
|
||||||
|
.split('_')
|
||||||
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
const toOptions = (values: readonly string[]) =>
|
||||||
|
values.map((value) => ({ value, label: humanizeEnum(value) }));
|
||||||
|
|
||||||
|
export const warehouseTypeOptions = toOptions(WAREHOUSE_TYPES);
|
||||||
|
export const yardTypeOptions = toOptions(WAREHOUSE_YARD_TYPES);
|
||||||
|
export const zoneTypeOptions = toOptions(WAREHOUSE_ZONE_TYPES);
|
||||||
|
export const statusOptions = toOptions(WAREHOUSE_STATUSES);
|
||||||
|
export const inventoryStatusOptions = toOptions(INVENTORY_STATUSES);
|
||||||
|
|
||||||
|
export const formatNumber = (value: number | null | undefined) => {
|
||||||
|
if (value === null || value === undefined) return '—';
|
||||||
|
const num = Number(value);
|
||||||
|
if (Number.isNaN(num)) return '—';
|
||||||
|
return num.toLocaleString(undefined, { maximumFractionDigits: 3 });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatCapacity = (current: number, capacity: number | null | undefined) => {
|
||||||
|
const cur = formatNumber(current);
|
||||||
|
if (capacity === null || capacity === undefined) return cur;
|
||||||
|
return `${cur} / ${formatNumber(capacity)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatDate = (value: string | null | undefined) => {
|
||||||
|
if (!value) return '—';
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return '—';
|
||||||
|
return date.toLocaleString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const extractErrorMessage = (error: unknown, fallback = 'Something went wrong') => {
|
||||||
|
const responseData = (error as { response?: { data?: unknown } })?.response?.data;
|
||||||
|
const data = responseData && typeof responseData === 'object' ? (responseData as Record<string, unknown>) : undefined;
|
||||||
|
const rawMessage = data?.message ?? data?.error ?? (error as Error)?.message;
|
||||||
|
return Array.isArray(rawMessage) ? rawMessage.join(', ') : rawMessage ? String(rawMessage) : fallback;
|
||||||
|
};
|
||||||
@@ -183,4 +183,28 @@ export const URL_CONSTANTS = {
|
|||||||
CONTAINER_TYPES: '/api/reference/container-types',
|
CONTAINER_TYPES: '/api/reference/container-types',
|
||||||
CURRENCIES: '/api/reference/currencies',
|
CURRENCIES: '/api/reference/currencies',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
WAREHOUSES: {
|
||||||
|
BASE: '/warehouses',
|
||||||
|
BY_ID: (id: string) => `/warehouses/${id}`,
|
||||||
|
YARDS: (warehouseId: string) => `/warehouses/${warehouseId}/yards`,
|
||||||
|
},
|
||||||
|
|
||||||
|
WAREHOUSE_YARDS: {
|
||||||
|
BY_ID: (id: string) => `/warehouse-yards/${id}`,
|
||||||
|
ZONES: (yardId: string) => `/warehouse-yards/${yardId}/zones`,
|
||||||
|
},
|
||||||
|
|
||||||
|
WAREHOUSE_ZONES: {
|
||||||
|
BY_ID: (id: string) => `/warehouse-zones/${id}`,
|
||||||
|
},
|
||||||
|
|
||||||
|
WAREHOUSE_INVENTORY: {
|
||||||
|
BASE: '/warehouse-inventory',
|
||||||
|
RECEIVE: '/warehouse-inventory/receive',
|
||||||
|
READY_FOR_LOADING: '/warehouse-inventory/ready-for-loading',
|
||||||
|
INQUIRY: '/warehouse-inventory/inquiry',
|
||||||
|
INSPECT: (id: string) => `/warehouse-inventory/${id}/inspect`,
|
||||||
|
MARK_READY: (id: string) => `/warehouse-inventory/${id}/ready-for-loading`,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
162
apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts
Normal file
162
apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { warehouseService } from '@/services/warehouse.service';
|
||||||
|
import type {
|
||||||
|
InventoryFilter,
|
||||||
|
InventoryInquiryFilter,
|
||||||
|
ReceiveInventoryPayload,
|
||||||
|
SaveWarehousePayload,
|
||||||
|
SaveYardPayload,
|
||||||
|
SaveZonePayload,
|
||||||
|
WarehouseFilter,
|
||||||
|
} from '@/types/warehouse';
|
||||||
|
|
||||||
|
export const warehouseKeys = {
|
||||||
|
all: ['warehouses'] as const,
|
||||||
|
list: (filter?: WarehouseFilter) => ['warehouses', 'list', filter ?? {}] as const,
|
||||||
|
detail: (id: string) => ['warehouses', 'detail', id] as const,
|
||||||
|
yards: (warehouseId: string) => ['warehouses', warehouseId, 'yards'] as const,
|
||||||
|
zones: (yardId: string) => ['warehouse-yards', yardId, 'zones'] as const,
|
||||||
|
inventory: (filter?: InventoryFilter) => ['warehouse-inventory', 'list', filter ?? {}] as const,
|
||||||
|
inquiry: (filter: InventoryInquiryFilter) => ['warehouse-inventory', 'inquiry', filter] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Warehouses ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useWarehouses(filter?: WarehouseFilter) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: warehouseKeys.list(filter),
|
||||||
|
queryFn: () => warehouseService.list(filter).then((r) => r.data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWarehouse(id?: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: warehouseKeys.detail(id ?? ''),
|
||||||
|
queryFn: () => warehouseService.getById(id as string).then((r) => r.data),
|
||||||
|
enabled: Boolean(id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateWarehouse() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: SaveWarehousePayload) => warehouseService.create(payload),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: warehouseKeys.all }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateWarehouse() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, payload }: { id: string; payload: Partial<SaveWarehousePayload> }) =>
|
||||||
|
warehouseService.update(id, payload),
|
||||||
|
onSuccess: (_, { id }) => {
|
||||||
|
qc.invalidateQueries({ queryKey: warehouseKeys.all });
|
||||||
|
qc.invalidateQueries({ queryKey: warehouseKeys.detail(id) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Yards ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useWarehouseYards(warehouseId?: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: warehouseKeys.yards(warehouseId ?? ''),
|
||||||
|
queryFn: () => warehouseService.listYards(warehouseId as string).then((r) => r.data),
|
||||||
|
enabled: Boolean(warehouseId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateYard() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ warehouseId, payload }: { warehouseId: string; payload: SaveYardPayload }) =>
|
||||||
|
warehouseService.createYard(warehouseId, payload),
|
||||||
|
onSuccess: (_, { warehouseId }) => {
|
||||||
|
qc.invalidateQueries({ queryKey: warehouseKeys.yards(warehouseId) });
|
||||||
|
qc.invalidateQueries({ queryKey: warehouseKeys.detail(warehouseId) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateYard() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, payload }: { id: string; payload: Partial<SaveYardPayload> }) =>
|
||||||
|
warehouseService.updateYard(id, payload),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: warehouseKeys.all }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Zones ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useWarehouseZones(yardId?: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: warehouseKeys.zones(yardId ?? ''),
|
||||||
|
queryFn: () => warehouseService.listZones(yardId as string).then((r) => r.data),
|
||||||
|
enabled: Boolean(yardId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateZone() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ yardId, payload }: { yardId: string; payload: SaveZonePayload }) =>
|
||||||
|
warehouseService.createZone(yardId, payload),
|
||||||
|
onSuccess: (_, { yardId }) => qc.invalidateQueries({ queryKey: warehouseKeys.zones(yardId) }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateZone() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, payload }: { id: string; payload: Partial<SaveZonePayload> }) =>
|
||||||
|
warehouseService.updateZone(id, payload),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['warehouse-yards'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Inventory ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useWarehouseInventory(filter?: InventoryFilter) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: warehouseKeys.inventory(filter),
|
||||||
|
queryFn: () => warehouseService.listInventory(filter).then((r) => r.data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useReceiveInventory() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: ReceiveInventoryPayload) => warehouseService.receiveInventory(payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
|
||||||
|
qc.invalidateQueries({ queryKey: warehouseKeys.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useInspectInventory() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => warehouseService.inspectInventory(id),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['warehouse-inventory'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMarkReadyForLoading() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => warehouseService.markReadyForLoading(id),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['warehouse-inventory'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useInventoryInquiry(filter: InventoryInquiryFilter, enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: warehouseKeys.inquiry(filter),
|
||||||
|
queryFn: () => warehouseService.inquiry(filter).then((r) => r.data),
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import "@edr/ui-common/theme.css";
|
|||||||
import { Toaster } from "react-hot-toast";
|
import { Toaster } from "react-hot-toast";
|
||||||
|
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
|
import { ErrorBoundary } from "./components/ErrorBoundary";
|
||||||
import { AuthProvider } from "./auth/AuthProvider";
|
import { AuthProvider } from "./auth/AuthProvider";
|
||||||
import { queryClient } from "./lib/queryClient";
|
import { queryClient } from "./lib/queryClient";
|
||||||
import { freightMantineTheme } from "./theme/freight-brand";
|
import { freightMantineTheme } from "./theme/freight-brand";
|
||||||
@@ -48,7 +49,9 @@ createRoot(rootElement).render(
|
|||||||
<StrictMode>
|
<StrictMode>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<App />
|
<ErrorBoundary>
|
||||||
|
<App />
|
||||||
|
</ErrorBoundary>
|
||||||
<Toaster position="top-right" />
|
<Toaster position="top-right" />
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
BookingCargoCard,
|
BookingCargoCard,
|
||||||
BookingContractSummaryCard,
|
BookingContractSummaryCard,
|
||||||
} from "@/components/bookings/detail";
|
} from "@/components/bookings/detail";
|
||||||
|
import { WarehouseInfoCard } from "@/components/warehouses";
|
||||||
import { getStatusMeta } from "@/features/bookings/booking-status.config";
|
import { getStatusMeta } from "@/features/bookings/booking-status.config";
|
||||||
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
|
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
|
||||||
import { useBookingDetail, useBookingMutations } from "@/hooks/bookings/useBookings";
|
import { useBookingDetail, useBookingMutations } from "@/hooks/bookings/useBookings";
|
||||||
@@ -150,6 +151,7 @@ export default function BookingRequestDetailPage() {
|
|||||||
<Box style={{ position: "sticky", top: 24 }}>
|
<Box style={{ position: "sticky", top: 24 }}>
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<BookingPricingSummary booking={booking} />
|
<BookingPricingSummary booking={booking} />
|
||||||
|
<WarehouseInfoCard bookingId={booking.id} bookingReference={booking.reference} />
|
||||||
<BookingActionsToolbar booking={booking} mutations={mutations} />
|
<BookingActionsToolbar booking={booking} mutations={mutations} />
|
||||||
{showContractButton && (
|
{showContractButton && (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { Button, Card, Center, Container, Group, Loader, Select, Stack, Text, TextInput, Title } from '@mantine/core';
|
||||||
|
import { Search } from 'lucide-react';
|
||||||
|
|
||||||
|
import Breadcrumbs from '@/components/ui/Breadcrumbs';
|
||||||
|
import { WarehouseInquiryTable, inventoryStatusOptions } from '@/components/warehouses';
|
||||||
|
import {
|
||||||
|
useInventoryInquiry,
|
||||||
|
useWarehouseYards,
|
||||||
|
useWarehouseZones,
|
||||||
|
useWarehouses,
|
||||||
|
} from '@/hooks/useWarehouses';
|
||||||
|
import type { InventoryInquiryFilter, InventoryStatus } from '@/types/warehouse';
|
||||||
|
|
||||||
|
export default function InventoryInquiryPage() {
|
||||||
|
const [draft, setDraft] = useState<InventoryInquiryFilter>({});
|
||||||
|
const [applied, setApplied] = useState<InventoryInquiryFilter>({});
|
||||||
|
|
||||||
|
const warehousesQuery = useWarehouses();
|
||||||
|
const yardsQuery = useWarehouseYards(draft.warehouseId);
|
||||||
|
const zonesQuery = useWarehouseZones(draft.yardId);
|
||||||
|
|
||||||
|
const { data, isFetching } = useInventoryInquiry(applied);
|
||||||
|
const results = data ?? [];
|
||||||
|
|
||||||
|
const warehouseOptions = useMemo(
|
||||||
|
() => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })),
|
||||||
|
[warehousesQuery.data],
|
||||||
|
);
|
||||||
|
const yardOptions = useMemo(
|
||||||
|
() => (yardsQuery.data ?? []).map((y) => ({ value: y.id, label: `${y.name} (${y.code})` })),
|
||||||
|
[yardsQuery.data],
|
||||||
|
);
|
||||||
|
const zoneOptions = useMemo(
|
||||||
|
() => (zonesQuery.data ?? []).map((z) => ({ value: z.id, label: `${z.name} (${z.code})` })),
|
||||||
|
[zonesQuery.data],
|
||||||
|
);
|
||||||
|
|
||||||
|
const runSearch = () => setApplied(draft);
|
||||||
|
const reset = () => {
|
||||||
|
setDraft({});
|
||||||
|
setApplied({});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container size="xxl" py="lg">
|
||||||
|
<Breadcrumbs items={[{ label: 'Inventory inquiry' }]} />
|
||||||
|
|
||||||
|
<Stack gap="lg" mt="sm">
|
||||||
|
<div>
|
||||||
|
<Title order={2}>Inventory Inquiry</Title>
|
||||||
|
<Text c="dimmed" size="sm">
|
||||||
|
Locate any cargo, container or goods inside the warehouse network.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card withBorder radius="md" padding="lg">
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group gap="sm" wrap="wrap">
|
||||||
|
<TextInput
|
||||||
|
label="Booking number"
|
||||||
|
placeholder="e.g. BKG-00123"
|
||||||
|
value={draft.bookingNumber ?? ''}
|
||||||
|
onChange={(e) => setDraft((f) => ({ ...f, bookingNumber: e.currentTarget.value || undefined }))}
|
||||||
|
w={200}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Container number"
|
||||||
|
placeholder="e.g. MSKU1234567"
|
||||||
|
value={draft.containerNumber ?? ''}
|
||||||
|
onChange={(e) => setDraft((f) => ({ ...f, containerNumber: e.currentTarget.value || undefined }))}
|
||||||
|
w={200}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Goods name"
|
||||||
|
placeholder="e.g. Coffee"
|
||||||
|
value={draft.goodsName ?? ''}
|
||||||
|
onChange={(e) => setDraft((f) => ({ ...f, goodsName: e.currentTarget.value || undefined }))}
|
||||||
|
w={180}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Warehouse"
|
||||||
|
placeholder="Any"
|
||||||
|
clearable
|
||||||
|
searchable
|
||||||
|
data={warehouseOptions}
|
||||||
|
value={draft.warehouseId ?? null}
|
||||||
|
onChange={(value) =>
|
||||||
|
setDraft((f) => ({ ...f, warehouseId: value ?? undefined, yardId: undefined, zoneId: undefined }))
|
||||||
|
}
|
||||||
|
w={200}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Yard"
|
||||||
|
placeholder="Any"
|
||||||
|
clearable
|
||||||
|
searchable
|
||||||
|
disabled={!draft.warehouseId}
|
||||||
|
data={yardOptions}
|
||||||
|
value={draft.yardId ?? null}
|
||||||
|
onChange={(value) => setDraft((f) => ({ ...f, yardId: value ?? undefined, zoneId: undefined }))}
|
||||||
|
w={180}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Zone"
|
||||||
|
placeholder="Any"
|
||||||
|
clearable
|
||||||
|
searchable
|
||||||
|
disabled={!draft.yardId}
|
||||||
|
data={zoneOptions}
|
||||||
|
value={draft.zoneId ?? null}
|
||||||
|
onChange={(value) => setDraft((f) => ({ ...f, zoneId: value ?? undefined }))}
|
||||||
|
w={180}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Status"
|
||||||
|
placeholder="Any"
|
||||||
|
clearable
|
||||||
|
data={inventoryStatusOptions}
|
||||||
|
value={draft.status ?? null}
|
||||||
|
onChange={(value) => setDraft((f) => ({ ...f, status: (value as InventoryStatus) ?? undefined }))}
|
||||||
|
w={180}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group>
|
||||||
|
<Button leftSection={<Search size={16} />} onClick={runSearch}>
|
||||||
|
Search
|
||||||
|
</Button>
|
||||||
|
<Button variant="default" onClick={reset}>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card withBorder radius="md" padding="lg">
|
||||||
|
{isFetching ? (
|
||||||
|
<Center py="xl">
|
||||||
|
<Loader />
|
||||||
|
</Center>
|
||||||
|
) : (
|
||||||
|
<WarehouseInquiryTable results={results} />
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Center,
|
||||||
|
Container,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
SimpleGrid,
|
||||||
|
Stack,
|
||||||
|
Select,
|
||||||
|
Table,
|
||||||
|
Tabs,
|
||||||
|
Text,
|
||||||
|
Title,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { ArrowLeft, Boxes, LayoutGrid, Package, Pencil, Plus } from 'lucide-react';
|
||||||
|
|
||||||
|
import Breadcrumbs from '@/components/ui/Breadcrumbs';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import {
|
||||||
|
CreateYardModal,
|
||||||
|
CreateZoneModal,
|
||||||
|
WarehouseInventoryTable,
|
||||||
|
WarehouseStatusBadge,
|
||||||
|
WarehouseTypeBadge,
|
||||||
|
formatCapacity,
|
||||||
|
humanizeEnum,
|
||||||
|
} from '@/components/warehouses';
|
||||||
|
import {
|
||||||
|
useInspectInventory,
|
||||||
|
useMarkReadyForLoading,
|
||||||
|
useWarehouse,
|
||||||
|
useWarehouseInventory,
|
||||||
|
useWarehouseYards,
|
||||||
|
useWarehouseZones,
|
||||||
|
} from '@/hooks/useWarehouses';
|
||||||
|
import { extractErrorMessage } from '@/components/warehouses/options';
|
||||||
|
import type { WarehouseInventoryItem, WarehouseYard, WarehouseZone } from '@/types/warehouse';
|
||||||
|
|
||||||
|
function StatCard({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<Card withBorder radius="md" padding="md">
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text fw={700} size="lg" mt={4}>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WarehouseDetailPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const { data: warehouse, isLoading } = useWarehouse(id);
|
||||||
|
const yardsQuery = useWarehouseYards(id);
|
||||||
|
|
||||||
|
const [yardModalOpen, setYardModalOpen] = useState(false);
|
||||||
|
const [editingYard, setEditingYard] = useState<WarehouseYard | null>(null);
|
||||||
|
|
||||||
|
const [zoneModalOpen, setZoneModalOpen] = useState(false);
|
||||||
|
const [editingZone, setEditingZone] = useState<WarehouseZone | null>(null);
|
||||||
|
const [selectedYardId, setSelectedYardId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const zonesQuery = useWarehouseZones(selectedYardId ?? undefined);
|
||||||
|
|
||||||
|
const inventoryQuery = useWarehouseInventory(id ? { warehouseId: id } : undefined);
|
||||||
|
const inspectMutation = useInspectInventory();
|
||||||
|
const readyMutation = useMarkReadyForLoading();
|
||||||
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const yards = yardsQuery.data ?? [];
|
||||||
|
const yardOptions = useMemo(
|
||||||
|
() => yards.map((y) => ({ value: y.id, label: `${y.name} (${y.code})` })),
|
||||||
|
[yards],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleInspect = async (item: WarehouseInventoryItem) => {
|
||||||
|
setBusyId(item.id);
|
||||||
|
try {
|
||||||
|
await inspectMutation.mutateAsync(item.id);
|
||||||
|
toast({ title: 'Inventory under inspection' });
|
||||||
|
} catch (error) {
|
||||||
|
toast({ variant: 'destructive', title: 'Action failed', description: extractErrorMessage(error) });
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReady = async (item: WarehouseInventoryItem) => {
|
||||||
|
setBusyId(item.id);
|
||||||
|
try {
|
||||||
|
await readyMutation.mutateAsync(item.id);
|
||||||
|
toast({ title: 'Inventory ready for loading' });
|
||||||
|
} catch (error) {
|
||||||
|
toast({ variant: 'destructive', title: 'Action failed', description: extractErrorMessage(error) });
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Center mih="60vh">
|
||||||
|
<Loader />
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!warehouse) {
|
||||||
|
return (
|
||||||
|
<Container size="sm" py="xl">
|
||||||
|
<Stack align="center" gap="md">
|
||||||
|
<Text fw={700}>Warehouse not found</Text>
|
||||||
|
<Button variant="default" leftSection={<ArrowLeft size={16} />} onClick={() => navigate('/dashboard/warehouses')}>
|
||||||
|
Back to warehouses
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container size="xxl" py="lg">
|
||||||
|
<Breadcrumbs
|
||||||
|
items={[
|
||||||
|
{ label: 'Warehouses', href: '/dashboard/warehouses' },
|
||||||
|
{ label: warehouse.name },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Stack gap="lg" mt="sm">
|
||||||
|
<Group justify="space-between" align="flex-start">
|
||||||
|
<Group gap="md" align="center">
|
||||||
|
<ActionIcon variant="subtle" color="gray" onClick={() => navigate('/dashboard/warehouses')}>
|
||||||
|
<ArrowLeft size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
<div>
|
||||||
|
<Group gap="sm">
|
||||||
|
<Title order={2}>{warehouse.name}</Title>
|
||||||
|
<WarehouseTypeBadge type={warehouse.type} />
|
||||||
|
<WarehouseStatusBadge status={warehouse.status} />
|
||||||
|
</Group>
|
||||||
|
<Text c="dimmed" size="sm">
|
||||||
|
{warehouse.code}
|
||||||
|
{warehouse.locationName ? ` · ${warehouse.locationName}` : ''}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Tabs defaultValue="overview">
|
||||||
|
<Tabs.List>
|
||||||
|
<Tabs.Tab value="overview" leftSection={<LayoutGrid size={16} />}>
|
||||||
|
Overview
|
||||||
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="yards" leftSection={<Boxes size={16} />}>
|
||||||
|
Yards
|
||||||
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="zones" leftSection={<LayoutGrid size={16} />}>
|
||||||
|
Zones
|
||||||
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="inventory" leftSection={<Package size={16} />}>
|
||||||
|
Inventory
|
||||||
|
</Tabs.Tab>
|
||||||
|
</Tabs.List>
|
||||||
|
|
||||||
|
{/* OVERVIEW */}
|
||||||
|
<Tabs.Panel value="overview" pt="lg">
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="md">
|
||||||
|
<StatCard label="Type" value={humanizeEnum(warehouse.type)} />
|
||||||
|
<StatCard label="Yards" value={String(yards.length)} />
|
||||||
|
<StatCard
|
||||||
|
label="Weight (cur / cap)"
|
||||||
|
value={formatCapacity(warehouse.currentWeight, warehouse.capacityWeight)}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Containers (cur / cap)"
|
||||||
|
value={formatCapacity(warehouse.currentContainers, warehouse.capacityContainers)}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
{/* YARDS */}
|
||||||
|
<Tabs.Panel value="yards" pt="lg">
|
||||||
|
<Card withBorder radius="md" padding="lg">
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text fw={600}>Yards</Text>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
leftSection={<Plus size={16} />}
|
||||||
|
onClick={() => {
|
||||||
|
setEditingYard(null);
|
||||||
|
setYardModalOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Create Yard
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{yards.length === 0 ? (
|
||||||
|
<Text c="dimmed" ta="center" py="lg">
|
||||||
|
No yards yet.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Table highlightOnHover verticalSpacing="sm" striped>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Name</Table.Th>
|
||||||
|
<Table.Th>Code</Table.Th>
|
||||||
|
<Table.Th>Type</Table.Th>
|
||||||
|
<Table.Th>Weight (cur / cap)</Table.Th>
|
||||||
|
<Table.Th>Containers (cur / cap)</Table.Th>
|
||||||
|
<Table.Th>Status</Table.Th>
|
||||||
|
<Table.Th ta="right">Actions</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{yards.map((yard) => (
|
||||||
|
<Table.Tr key={yard.id}>
|
||||||
|
<Table.Td>{yard.name}</Table.Td>
|
||||||
|
<Table.Td>{yard.code}</Table.Td>
|
||||||
|
<Table.Td>{humanizeEnum(yard.type)}</Table.Td>
|
||||||
|
<Table.Td>{formatCapacity(yard.currentWeight, yard.capacityWeight)}</Table.Td>
|
||||||
|
<Table.Td>{formatCapacity(yard.currentContainers, yard.capacityContainers)}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<WarehouseStatusBadge status={yard.status} />
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="right">
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingYard(yard);
|
||||||
|
setYardModalOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
{/* ZONES */}
|
||||||
|
<Tabs.Panel value="zones" pt="lg">
|
||||||
|
<Card withBorder radius="md" padding="lg">
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group justify="space-between" align="flex-end">
|
||||||
|
<Select
|
||||||
|
label="Yard"
|
||||||
|
placeholder="Select a yard"
|
||||||
|
data={yardOptions}
|
||||||
|
value={selectedYardId}
|
||||||
|
onChange={setSelectedYardId}
|
||||||
|
w={280}
|
||||||
|
searchable
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
leftSection={<Plus size={16} />}
|
||||||
|
disabled={!selectedYardId}
|
||||||
|
onClick={() => {
|
||||||
|
setEditingZone(null);
|
||||||
|
setZoneModalOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Create Zone
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{!selectedYardId ? (
|
||||||
|
<Text c="dimmed" ta="center" py="lg">
|
||||||
|
Select a yard to view its zones.
|
||||||
|
</Text>
|
||||||
|
) : (zonesQuery.data ?? []).length === 0 ? (
|
||||||
|
<Text c="dimmed" ta="center" py="lg">
|
||||||
|
No zones in this yard yet.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Table highlightOnHover verticalSpacing="sm" striped>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Name</Table.Th>
|
||||||
|
<Table.Th>Code</Table.Th>
|
||||||
|
<Table.Th>Type</Table.Th>
|
||||||
|
<Table.Th>Weight (cur / cap)</Table.Th>
|
||||||
|
<Table.Th>Containers (cur / cap)</Table.Th>
|
||||||
|
<Table.Th>Status</Table.Th>
|
||||||
|
<Table.Th ta="right">Actions</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{(zonesQuery.data ?? []).map((zone) => (
|
||||||
|
<Table.Tr key={zone.id}>
|
||||||
|
<Table.Td>{zone.name}</Table.Td>
|
||||||
|
<Table.Td>{zone.code}</Table.Td>
|
||||||
|
<Table.Td>{humanizeEnum(zone.type)}</Table.Td>
|
||||||
|
<Table.Td>{formatCapacity(zone.currentWeight, zone.capacityWeight)}</Table.Td>
|
||||||
|
<Table.Td>{formatCapacity(zone.currentContainers, zone.capacityContainers)}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<WarehouseStatusBadge status={zone.status} />
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="right">
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingZone(zone);
|
||||||
|
setZoneModalOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
{/* INVENTORY */}
|
||||||
|
<Tabs.Panel value="inventory" pt="lg">
|
||||||
|
<Card withBorder radius="md" padding="lg">
|
||||||
|
{inventoryQuery.isLoading ? (
|
||||||
|
<Center py="xl">
|
||||||
|
<Loader />
|
||||||
|
</Center>
|
||||||
|
) : (
|
||||||
|
<WarehouseInventoryTable
|
||||||
|
items={inventoryQuery.data ?? []}
|
||||||
|
onInspect={handleInspect}
|
||||||
|
onReadyForLoading={handleReady}
|
||||||
|
busyId={busyId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Tabs.Panel>
|
||||||
|
</Tabs>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{id && (
|
||||||
|
<CreateYardModal
|
||||||
|
opened={yardModalOpen}
|
||||||
|
onClose={() => setYardModalOpen(false)}
|
||||||
|
warehouseId={id}
|
||||||
|
yard={editingYard}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{selectedYardId && (
|
||||||
|
<CreateZoneModal
|
||||||
|
opened={zoneModalOpen}
|
||||||
|
onClose={() => setZoneModalOpen(false)}
|
||||||
|
yardId={selectedYardId}
|
||||||
|
zone={editingZone}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { Button, Card, Center, Container, Group, Loader, Select, Stack, Text, TextInput, Title } from '@mantine/core';
|
||||||
|
import { useDebouncedValue } from '@mantine/hooks';
|
||||||
|
import { PackagePlus, Search } from 'lucide-react';
|
||||||
|
|
||||||
|
import Breadcrumbs from '@/components/ui/Breadcrumbs';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import {
|
||||||
|
ReceiveInventoryModal,
|
||||||
|
WarehouseInventoryTable,
|
||||||
|
inventoryStatusOptions,
|
||||||
|
} from '@/components/warehouses';
|
||||||
|
import { extractErrorMessage } from '@/components/warehouses/options';
|
||||||
|
import {
|
||||||
|
useInspectInventory,
|
||||||
|
useMarkReadyForLoading,
|
||||||
|
useWarehouseInventory,
|
||||||
|
useWarehouseYards,
|
||||||
|
useWarehouseZones,
|
||||||
|
useWarehouses,
|
||||||
|
} from '@/hooks/useWarehouses';
|
||||||
|
import type { InventoryFilter, InventoryStatus, WarehouseInventoryItem } from '@/types/warehouse';
|
||||||
|
|
||||||
|
export default function WarehouseInventoryPage() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [filter, setFilter] = useState<InventoryFilter>({});
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [debouncedSearch] = useDebouncedValue(search, 300);
|
||||||
|
const queryFilter = useMemo<InventoryFilter>(
|
||||||
|
() => ({ ...filter, search: debouncedSearch || undefined }),
|
||||||
|
[filter, debouncedSearch],
|
||||||
|
);
|
||||||
|
|
||||||
|
const warehousesQuery = useWarehouses();
|
||||||
|
const yardsQuery = useWarehouseYards(filter.warehouseId);
|
||||||
|
const zonesQuery = useWarehouseZones(filter.yardId);
|
||||||
|
const inventoryQuery = useWarehouseInventory(queryFilter);
|
||||||
|
|
||||||
|
const inspectMutation = useInspectInventory();
|
||||||
|
const readyMutation = useMarkReadyForLoading();
|
||||||
|
|
||||||
|
const warehouseOptions = useMemo(
|
||||||
|
() => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })),
|
||||||
|
[warehousesQuery.data],
|
||||||
|
);
|
||||||
|
const yardOptions = useMemo(
|
||||||
|
() => (yardsQuery.data ?? []).map((y) => ({ value: y.id, label: `${y.name} (${y.code})` })),
|
||||||
|
[yardsQuery.data],
|
||||||
|
);
|
||||||
|
const zoneOptions = useMemo(
|
||||||
|
() => (zonesQuery.data ?? []).map((z) => ({ value: z.id, label: `${z.name} (${z.code})` })),
|
||||||
|
[zonesQuery.data],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleInspect = async (item: WarehouseInventoryItem) => {
|
||||||
|
setBusyId(item.id);
|
||||||
|
try {
|
||||||
|
await inspectMutation.mutateAsync(item.id);
|
||||||
|
toast({ title: 'Inventory under inspection' });
|
||||||
|
} catch (error) {
|
||||||
|
toast({ variant: 'destructive', title: 'Action failed', description: extractErrorMessage(error) });
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReady = async (item: WarehouseInventoryItem) => {
|
||||||
|
setBusyId(item.id);
|
||||||
|
try {
|
||||||
|
await readyMutation.mutateAsync(item.id);
|
||||||
|
toast({ title: 'Inventory ready for loading' });
|
||||||
|
} catch (error) {
|
||||||
|
toast({ variant: 'destructive', title: 'Action failed', description: extractErrorMessage(error) });
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container size="xxl" py="lg">
|
||||||
|
<Breadcrumbs items={[{ label: 'Warehouse inventory' }]} />
|
||||||
|
|
||||||
|
<Stack gap="lg" mt="sm">
|
||||||
|
<Group justify="space-between" align="flex-end">
|
||||||
|
<div>
|
||||||
|
<Title order={2}>Warehouse Inventory</Title>
|
||||||
|
<Text c="dimmed" size="sm">
|
||||||
|
Track received items and move them through inspection to loading.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Button leftSection={<PackagePlus size={16} />} onClick={() => setModalOpen(true)}>
|
||||||
|
Receive Inventory
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Card withBorder radius="md" padding="lg">
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group gap="sm" wrap="wrap">
|
||||||
|
<TextInput
|
||||||
|
placeholder="Search notes"
|
||||||
|
leftSection={<Search size={16} />}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||||
|
w={220}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
placeholder="All warehouses"
|
||||||
|
clearable
|
||||||
|
searchable
|
||||||
|
data={warehouseOptions}
|
||||||
|
value={filter.warehouseId ?? null}
|
||||||
|
onChange={(value) =>
|
||||||
|
setFilter((f) => ({ ...f, warehouseId: value ?? undefined, yardId: undefined, zoneId: undefined }))
|
||||||
|
}
|
||||||
|
w={220}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
placeholder="All yards"
|
||||||
|
clearable
|
||||||
|
searchable
|
||||||
|
disabled={!filter.warehouseId}
|
||||||
|
data={yardOptions}
|
||||||
|
value={filter.yardId ?? null}
|
||||||
|
onChange={(value) => setFilter((f) => ({ ...f, yardId: value ?? undefined, zoneId: undefined }))}
|
||||||
|
w={200}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
placeholder="All zones"
|
||||||
|
clearable
|
||||||
|
searchable
|
||||||
|
disabled={!filter.yardId}
|
||||||
|
data={zoneOptions}
|
||||||
|
value={filter.zoneId ?? null}
|
||||||
|
onChange={(value) => setFilter((f) => ({ ...f, zoneId: value ?? undefined }))}
|
||||||
|
w={200}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
placeholder="All statuses"
|
||||||
|
clearable
|
||||||
|
data={inventoryStatusOptions}
|
||||||
|
value={filter.status ?? null}
|
||||||
|
onChange={(value) => setFilter((f) => ({ ...f, status: (value as InventoryStatus) ?? undefined }))}
|
||||||
|
w={200}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{inventoryQuery.isLoading ? (
|
||||||
|
<Center py="xl">
|
||||||
|
<Loader />
|
||||||
|
</Center>
|
||||||
|
) : (
|
||||||
|
<WarehouseInventoryTable
|
||||||
|
items={inventoryQuery.data ?? []}
|
||||||
|
onInspect={handleInspect}
|
||||||
|
onReadyForLoading={handleReady}
|
||||||
|
busyId={busyId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<ReceiveInventoryModal opened={modalOpen} onClose={() => setModalOpen(false)} />
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Button, Card, Center, Container, Group, Loader, Stack, Text, Title } from '@mantine/core';
|
||||||
|
import { useDebouncedValue } from '@mantine/hooks';
|
||||||
|
import { Plus } from 'lucide-react';
|
||||||
|
|
||||||
|
import Breadcrumbs from '@/components/ui/Breadcrumbs';
|
||||||
|
import {
|
||||||
|
CreateWarehouseModal,
|
||||||
|
WarehouseCardView,
|
||||||
|
WarehouseFilters,
|
||||||
|
WarehouseTable,
|
||||||
|
type WarehouseView,
|
||||||
|
} from '@/components/warehouses';
|
||||||
|
import { useWarehouses } from '@/hooks/useWarehouses';
|
||||||
|
import type { Warehouse, WarehouseFilter } from '@/types/warehouse';
|
||||||
|
|
||||||
|
export default function WarehouseListPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [filter, setFilter] = useState<WarehouseFilter>({});
|
||||||
|
const [view, setView] = useState<WarehouseView>('table');
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<Warehouse | null>(null);
|
||||||
|
|
||||||
|
const [debouncedSearch] = useDebouncedValue(filter.search, 300);
|
||||||
|
const queryFilter = useMemo<WarehouseFilter>(
|
||||||
|
() => ({ ...filter, search: debouncedSearch }),
|
||||||
|
[filter, debouncedSearch],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, isLoading, isError } = useWarehouses(queryFilter);
|
||||||
|
const warehouses = data ?? [];
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditing(null);
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
const openEdit = (warehouse: Warehouse) => {
|
||||||
|
setEditing(warehouse);
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
const openDetail = (warehouse: Warehouse) => navigate(`/dashboard/warehouses/${warehouse.id}`);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container size="xxl" py="lg">
|
||||||
|
<Breadcrumbs items={[{ label: 'Warehouses' }]} />
|
||||||
|
|
||||||
|
<Stack gap="lg" mt="sm">
|
||||||
|
<Group justify="space-between" align="flex-end">
|
||||||
|
<div>
|
||||||
|
<Title order={2}>Warehouses</Title>
|
||||||
|
<Text c="dimmed" size="sm">
|
||||||
|
Manage warehouses, yards and zones.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Button leftSection={<Plus size={16} />} onClick={openCreate}>
|
||||||
|
Create Warehouse
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Card withBorder radius="md" padding="lg">
|
||||||
|
<Stack gap="md">
|
||||||
|
<WarehouseFilters filter={filter} onChange={setFilter} view={view} onViewChange={setView} />
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Center py="xl">
|
||||||
|
<Loader />
|
||||||
|
</Center>
|
||||||
|
) : isError ? (
|
||||||
|
<Text c="red" ta="center" py="xl">
|
||||||
|
Failed to load warehouses.
|
||||||
|
</Text>
|
||||||
|
) : view === 'table' ? (
|
||||||
|
<WarehouseTable warehouses={warehouses} onView={openDetail} onEdit={openEdit} />
|
||||||
|
) : (
|
||||||
|
<WarehouseCardView warehouses={warehouses} onView={openDetail} onEdit={openEdit} />
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<CreateWarehouseModal opened={modalOpen} onClose={() => setModalOpen(false)} warehouse={editing} />
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { api as apiClient } from '../auth/http';
|
||||||
|
|
||||||
|
import { URL_CONSTANTS } from '@/constants/URLS';
|
||||||
|
import type {
|
||||||
|
InventoryFilter,
|
||||||
|
InventoryInquiryFilter,
|
||||||
|
InventoryInquiryResult,
|
||||||
|
ReceiveInventoryPayload,
|
||||||
|
SaveWarehousePayload,
|
||||||
|
SaveYardPayload,
|
||||||
|
SaveZonePayload,
|
||||||
|
Warehouse,
|
||||||
|
WarehouseFilter,
|
||||||
|
WarehouseInventoryItem,
|
||||||
|
WarehouseYard,
|
||||||
|
WarehouseZone,
|
||||||
|
} from '@/types/warehouse';
|
||||||
|
|
||||||
|
const cleanParams = (params: object) =>
|
||||||
|
Object.fromEntries(
|
||||||
|
Object.entries(params).filter(([, value]) => value !== undefined && value !== '' && value !== null),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const warehouseService = {
|
||||||
|
// ── Warehouses ──────────────────────────────────────────────────────────
|
||||||
|
list: (filter?: WarehouseFilter) =>
|
||||||
|
apiClient.get<Warehouse[]>(URL_CONSTANTS.WAREHOUSES.BASE, {
|
||||||
|
params: cleanParams(filter ?? {}),
|
||||||
|
}),
|
||||||
|
getById: (id: string) => apiClient.get<Warehouse>(URL_CONSTANTS.WAREHOUSES.BY_ID(id)),
|
||||||
|
create: (payload: SaveWarehousePayload) =>
|
||||||
|
apiClient.post<Warehouse>(URL_CONSTANTS.WAREHOUSES.BASE, payload),
|
||||||
|
update: (id: string, payload: Partial<SaveWarehousePayload>) =>
|
||||||
|
apiClient.patch<Warehouse>(URL_CONSTANTS.WAREHOUSES.BY_ID(id), payload),
|
||||||
|
|
||||||
|
// ── Yards ────────────────────────────────────────────────────────────────
|
||||||
|
listYards: (warehouseId: string) =>
|
||||||
|
apiClient.get<WarehouseYard[]>(URL_CONSTANTS.WAREHOUSES.YARDS(warehouseId)),
|
||||||
|
createYard: (warehouseId: string, payload: SaveYardPayload) =>
|
||||||
|
apiClient.post<WarehouseYard>(URL_CONSTANTS.WAREHOUSES.YARDS(warehouseId), payload),
|
||||||
|
getYard: (id: string) => apiClient.get<WarehouseYard>(URL_CONSTANTS.WAREHOUSE_YARDS.BY_ID(id)),
|
||||||
|
updateYard: (id: string, payload: Partial<SaveYardPayload>) =>
|
||||||
|
apiClient.patch<WarehouseYard>(URL_CONSTANTS.WAREHOUSE_YARDS.BY_ID(id), payload),
|
||||||
|
|
||||||
|
// ── Zones ──────────────────────────────────────────────────────────────
|
||||||
|
listZones: (yardId: string) =>
|
||||||
|
apiClient.get<WarehouseZone[]>(URL_CONSTANTS.WAREHOUSE_YARDS.ZONES(yardId)),
|
||||||
|
createZone: (yardId: string, payload: SaveZonePayload) =>
|
||||||
|
apiClient.post<WarehouseZone>(URL_CONSTANTS.WAREHOUSE_YARDS.ZONES(yardId), payload),
|
||||||
|
getZone: (id: string) => apiClient.get<WarehouseZone>(URL_CONSTANTS.WAREHOUSE_ZONES.BY_ID(id)),
|
||||||
|
updateZone: (id: string, payload: Partial<SaveZonePayload>) =>
|
||||||
|
apiClient.patch<WarehouseZone>(URL_CONSTANTS.WAREHOUSE_ZONES.BY_ID(id), payload),
|
||||||
|
|
||||||
|
// ── Inventory ──────────────────────────────────────────────────────────
|
||||||
|
listInventory: (filter?: InventoryFilter) =>
|
||||||
|
apiClient.get<WarehouseInventoryItem[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.BASE, {
|
||||||
|
params: cleanParams(filter ?? {}),
|
||||||
|
}),
|
||||||
|
receiveInventory: (payload: ReceiveInventoryPayload) =>
|
||||||
|
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RECEIVE, payload),
|
||||||
|
inspectInventory: (id: string) =>
|
||||||
|
apiClient.patch<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.INSPECT(id)),
|
||||||
|
markReadyForLoading: (id: string) =>
|
||||||
|
apiClient.patch<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.MARK_READY(id)),
|
||||||
|
listReadyForLoading: (filter?: InventoryFilter) =>
|
||||||
|
apiClient.get<WarehouseInventoryItem[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.READY_FOR_LOADING, {
|
||||||
|
params: cleanParams(filter ?? {}),
|
||||||
|
}),
|
||||||
|
inquiry: (filter: InventoryInquiryFilter) =>
|
||||||
|
apiClient.get<InventoryInquiryResult[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.INQUIRY, {
|
||||||
|
params: cleanParams(filter ?? {}),
|
||||||
|
}),
|
||||||
|
};
|
||||||
193
apps/edr-freight-web/backoffice/src/types/warehouse.ts
Normal file
193
apps/edr-freight-web/backoffice/src/types/warehouse.ts
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
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];
|
||||||
|
|
||||||
|
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_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 INVENTORY_STATUSES = [
|
||||||
|
'ARRIVED_AT_WAREHOUSE',
|
||||||
|
'UNDER_INSPECTION',
|
||||||
|
'READY_FOR_LOADING',
|
||||||
|
] as const;
|
||||||
|
export type InventoryStatus = (typeof INVENTORY_STATUSES)[number];
|
||||||
|
|
||||||
|
export interface WarehouseZone {
|
||||||
|
id: string;
|
||||||
|
yardId: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
type: WarehouseZoneType;
|
||||||
|
capacityWeight: number | null;
|
||||||
|
capacityContainers: number | null;
|
||||||
|
currentWeight: number;
|
||||||
|
currentContainers: number;
|
||||||
|
status: WarehouseStatus;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WarehouseYard {
|
||||||
|
id: string;
|
||||||
|
warehouseId: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
type: WarehouseYardType;
|
||||||
|
capacityWeight: number | null;
|
||||||
|
capacityContainers: number | null;
|
||||||
|
currentWeight: number;
|
||||||
|
currentContainers: number;
|
||||||
|
status: WarehouseStatus;
|
||||||
|
isActive: boolean;
|
||||||
|
zones?: WarehouseZone[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Warehouse {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
type: WarehouseType;
|
||||||
|
stationId: string | null;
|
||||||
|
locationName: string | null;
|
||||||
|
capacityWeight: number | null;
|
||||||
|
capacityContainers: number | null;
|
||||||
|
currentWeight: number;
|
||||||
|
currentContainers: number;
|
||||||
|
status: WarehouseStatus;
|
||||||
|
isActive: boolean;
|
||||||
|
yards?: WarehouseYard[];
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WarehouseInventoryItem {
|
||||||
|
id: string;
|
||||||
|
warehouseId: string;
|
||||||
|
yardId: string;
|
||||||
|
zoneId: string;
|
||||||
|
bookingId: string;
|
||||||
|
cargoId: string | null;
|
||||||
|
containerId: string | null;
|
||||||
|
goodsId: string | null;
|
||||||
|
quantity: number;
|
||||||
|
weight: number;
|
||||||
|
volume: number | null;
|
||||||
|
status: InventoryStatus;
|
||||||
|
arrivedAt: string | null;
|
||||||
|
inspectedAt: string | null;
|
||||||
|
readyForLoadingAt: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
warehouse?: Warehouse | null;
|
||||||
|
yard?: WarehouseYard | null;
|
||||||
|
zone?: WarehouseZone | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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: InventoryStatus;
|
||||||
|
quantity: number;
|
||||||
|
weight: number;
|
||||||
|
arrivedAt: string | null;
|
||||||
|
readyForLoadingAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Payloads ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface SaveWarehousePayload {
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
type: WarehouseType;
|
||||||
|
stationId?: string;
|
||||||
|
locationName?: string;
|
||||||
|
capacityWeight?: number;
|
||||||
|
capacityContainers?: number;
|
||||||
|
status?: WarehouseStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaveYardPayload {
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
type: WarehouseYardType;
|
||||||
|
capacityWeight?: number;
|
||||||
|
capacityContainers?: number;
|
||||||
|
status?: WarehouseStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaveZonePayload {
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
type: WarehouseZoneType;
|
||||||
|
capacityWeight?: number;
|
||||||
|
capacityContainers?: number;
|
||||||
|
status?: WarehouseStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReceiveInventoryPayload {
|
||||||
|
warehouseId: string;
|
||||||
|
yardId: string;
|
||||||
|
zoneId: string;
|
||||||
|
bookingId: string;
|
||||||
|
cargoId?: string;
|
||||||
|
containerId?: string;
|
||||||
|
goodsId?: string;
|
||||||
|
quantity: number;
|
||||||
|
weight: number;
|
||||||
|
volume?: number;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WarehouseFilter {
|
||||||
|
search?: string;
|
||||||
|
type?: WarehouseType;
|
||||||
|
stationId?: string;
|
||||||
|
status?: WarehouseStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InventoryFilter {
|
||||||
|
warehouseId?: string;
|
||||||
|
yardId?: string;
|
||||||
|
zoneId?: string;
|
||||||
|
bookingId?: string;
|
||||||
|
cargoId?: string;
|
||||||
|
containerId?: string;
|
||||||
|
goodsId?: string;
|
||||||
|
status?: InventoryStatus;
|
||||||
|
search?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InventoryInquiryFilter {
|
||||||
|
bookingNumber?: string;
|
||||||
|
containerNumber?: string;
|
||||||
|
cargoType?: string;
|
||||||
|
goodsName?: string;
|
||||||
|
warehouseId?: string;
|
||||||
|
yardId?: string;
|
||||||
|
zoneId?: string;
|
||||||
|
status?: InventoryStatus;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user