Recieved invontory detals

This commit is contained in:
Hagernesh
2026-06-12 06:58:21 +00:00
parent c86118cd8d
commit 72e2e3c985
48 changed files with 1818 additions and 279 deletions

View File

@@ -0,0 +1,123 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class WarehouseBatch21790000000001 implements MigrationInterface {
name = 'WarehouseBatch21790000000001';
public async up(queryRunner: QueryRunner): Promise<void> {
// ── Capacity columns (weight + volume) on warehouse / yard / zone ──────
for (const table of ['warehouses', 'warehouse_yards', 'warehouse_zones']) {
await queryRunner.query(`
ALTER TABLE freight.${table}
ADD COLUMN IF NOT EXISTS max_weight NUMERIC(14,3) NULL,
ADD COLUMN IF NOT EXISTS max_volume NUMERIC(14,3) NULL,
ADD COLUMN IF NOT EXISTS current_volume NUMERIC(14,3) NOT NULL DEFAULT 0;
`);
// Backfill max_weight from the Batch 1 capacity_weight column.
await queryRunner.query(`
UPDATE freight.${table} SET max_weight = capacity_weight WHERE max_weight IS NULL;
`);
}
// ── Inventory lifecycle: migrate Batch 1 statuses to Batch 2 set ───────
await queryRunner.query(`
ALTER TABLE freight.warehouse_inventory
ALTER COLUMN status SET DEFAULT 'RECEIVED';
`);
await queryRunner.query(`
UPDATE freight.warehouse_inventory SET status = 'RECEIVED' WHERE status = 'ARRIVED_AT_WAREHOUSE';
`);
await queryRunner.query(`
UPDATE freight.warehouse_inventory SET status = 'STORED' WHERE status = 'UNDER_INSPECTION';
`);
// ── New lifecycle timestamps ──────────────────────────────────────────
await queryRunner.query(`
ALTER TABLE freight.warehouse_inventory
ADD COLUMN IF NOT EXISTS stored_at TIMESTAMPTZ NULL,
ADD COLUMN IF NOT EXISTS reserved_at TIMESTAMPTZ NULL,
ADD COLUMN IF NOT EXISTS loaded_at TIMESTAMPTZ NULL,
ADD COLUMN IF NOT EXISTS dispatched_at TIMESTAMPTZ NULL;
`);
// booking_id becomes nullable (inventory can exist before booking linkage).
await queryRunner.query(`
ALTER TABLE freight.warehouse_inventory ALTER COLUMN booking_id DROP NOT NULL;
`);
// ── Movement history ──────────────────────────────────────────────────
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.warehouse_inventory_movement (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
inventory_id UUID NOT NULL REFERENCES freight.warehouse_inventory(id) ON DELETE CASCADE,
from_warehouse_id UUID NOT NULL,
from_yard_id UUID NOT NULL,
from_zone_id UUID NOT NULL,
to_warehouse_id UUID NOT NULL,
to_yard_id UUID NOT NULL,
to_zone_id UUID NOT NULL,
remarks TEXT NULL,
moved_by VARCHAR(120) NULL,
moved_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ NULL
);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_warehouse_inventory_movement_inventory_id
ON freight.warehouse_inventory_movement(inventory_id);
`);
// ── Activity log ──────────────────────────────────────────────────────
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.warehouse_activity_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
inventory_id UUID NULL,
warehouse_id UUID NULL,
activity_type VARCHAR(40) NOT NULL,
description TEXT NULL,
performed_by VARCHAR(120) NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ NULL
);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_warehouse_activity_log_inventory_id
ON freight.warehouse_activity_log(inventory_id);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_warehouse_activity_log_warehouse_id
ON freight.warehouse_activity_log(warehouse_id);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_warehouse_activity_log_activity_type
ON freight.warehouse_activity_log(activity_type);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_activity_log;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_inventory_movement;`);
await queryRunner.query(`
ALTER TABLE freight.warehouse_inventory
DROP COLUMN IF EXISTS stored_at,
DROP COLUMN IF EXISTS reserved_at,
DROP COLUMN IF EXISTS loaded_at,
DROP COLUMN IF EXISTS dispatched_at;
`);
await queryRunner.query(`
ALTER TABLE freight.warehouse_inventory ALTER COLUMN status SET DEFAULT 'RECEIVED';
`);
for (const table of ['warehouses', 'warehouse_yards', 'warehouse_zones']) {
await queryRunner.query(`
ALTER TABLE freight.${table}
DROP COLUMN IF EXISTS max_weight,
DROP COLUMN IF EXISTS max_volume,
DROP COLUMN IF EXISTS current_volume;
`);
}
}
}

View File

@@ -34,4 +34,16 @@ export class CreateWarehouseYardDto {
@IsNumber()
@Min(0)
capacityContainers?: number;
@ApiPropertyOptional({ description: 'Max weight capacity (kg). Defaults to capacityWeight.' })
@IsOptional()
@IsNumber()
@Min(0)
maxWeight?: number;
@ApiPropertyOptional({ description: 'Max volume capacity (m³).' })
@IsOptional()
@IsNumber()
@Min(0)
maxVolume?: number;
}

View File

@@ -34,4 +34,16 @@ export class CreateWarehouseZoneDto {
@IsNumber()
@Min(0)
capacityContainers?: number;
@ApiPropertyOptional({ description: 'Max weight capacity (kg). Defaults to capacityWeight.' })
@IsOptional()
@IsNumber()
@Min(0)
maxWeight?: number;
@ApiPropertyOptional({ description: 'Max volume capacity (m³).' })
@IsOptional()
@IsNumber()
@Min(0)
maxVolume?: number;
}

View File

@@ -40,4 +40,16 @@ export class CreateWarehouseDto {
@IsNumber()
@Min(0)
capacityContainers?: number;
@ApiPropertyOptional({ description: 'Max weight capacity (kg). Defaults to capacityWeight.' })
@IsOptional()
@IsNumber()
@Min(0)
maxWeight?: number;
@ApiPropertyOptional({ description: 'Max volume capacity (m³).' })
@IsOptional()
@IsNumber()
@Min(0)
maxVolume?: number;
}

View File

@@ -0,0 +1,26 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, IsUUID } from 'class-validator';
export class MoveInventoryDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
warehouseId!: string;
@ApiProperty({ format: 'uuid' })
@IsUUID()
yardId!: string;
@ApiProperty({ format: 'uuid' })
@IsUUID()
zoneId!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
remarks?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
movedBy?: string;
}

View File

@@ -14,9 +14,10 @@ export class ReceiveWarehouseInventoryDto {
@IsUUID()
zoneId!: string;
@ApiProperty({ format: 'uuid' })
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
bookingId!: string;
bookingId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@@ -53,4 +54,9 @@ export class ReceiveWarehouseInventoryDto {
@IsOptional()
@IsString()
notes?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
performedBy?: string;
}

View File

@@ -0,0 +1,17 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, IsUUID } from 'class-validator';
export class ReserveInventoryDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
bookingId!: string;
@ApiProperty({ format: 'uuid' })
@IsUUID()
inventoryId!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
performedBy?: string;
}

View File

@@ -0,0 +1,34 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index } from 'typeorm';
export const WAREHOUSE_ACTIVITY_TYPES = [
'INVENTORY_RECEIVED',
'INVENTORY_STORED',
'INVENTORY_MOVED',
'INVENTORY_RESERVED',
'READY_FOR_LOADING',
'INVENTORY_LOADED',
'INVENTORY_DISPATCHED',
] as const;
export type WarehouseActivityType = (typeof WAREHOUSE_ACTIVITY_TYPES)[number];
@Entity({ schema: 'freight', name: 'warehouse_activity_log' })
@Index(['inventoryId'])
@Index(['warehouseId'])
@Index(['activityType'])
export class WarehouseActivityLog extends BaseEntity {
@Column({ name: 'inventory_id', type: 'uuid', nullable: true })
inventoryId?: string | null;
@Column({ name: 'warehouse_id', type: 'uuid', nullable: true })
warehouseId?: string | null;
@Column({ name: 'activity_type', type: 'varchar', length: 40 })
activityType!: WarehouseActivityType;
@Column({ name: 'description', type: 'text', nullable: true })
description?: string | null;
@Column({ name: 'performed_by', type: 'varchar', length: 120, nullable: true })
performedBy?: string | null;
}

View File

@@ -0,0 +1,42 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { WarehouseInventory } from './warehouse-inventory.entity';
@Entity({ schema: 'freight', name: 'warehouse_inventory_movement' })
@Index(['inventoryId'])
export class WarehouseInventoryMovement extends BaseEntity {
@Column({ name: 'inventory_id', type: 'uuid' })
inventoryId!: string;
@ManyToOne(() => WarehouseInventory, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'inventory_id' })
inventory?: WarehouseInventory;
@Column({ name: 'from_warehouse_id', type: 'uuid' })
fromWarehouseId!: string;
@Column({ name: 'from_yard_id', type: 'uuid' })
fromYardId!: string;
@Column({ name: 'from_zone_id', type: 'uuid' })
fromZoneId!: string;
@Column({ name: 'to_warehouse_id', type: 'uuid' })
toWarehouseId!: string;
@Column({ name: 'to_yard_id', type: 'uuid' })
toYardId!: string;
@Column({ name: 'to_zone_id', type: 'uuid' })
toZoneId!: string;
@Column({ name: 'remarks', type: 'text', nullable: true })
remarks?: string | null;
@Column({ name: 'moved_by', type: 'varchar', length: 120, nullable: true })
movedBy?: string | null;
@Column({ name: 'moved_at', type: 'timestamptz' })
movedAt!: Date;
}

View File

@@ -1,17 +1,35 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Booking } from '../../bookings/entities/booking.entity';
import { Cargo } from '../../cargoes/entities/cargoes.entity';
import { Container } from '../../container-management/entities/container.entity';
import { Warehouse } from './warehouse.entity';
import { WarehouseYard } from './warehouse-yard.entity';
import { WarehouseZone } from './warehouse-zone.entity';
// Batch 2 lifecycle. Supersedes the Batch 1 set
// (ARRIVED_AT_WAREHOUSE / UNDER_INSPECTION / READY_FOR_LOADING) — migrated in place.
export const WAREHOUSE_INVENTORY_STATUSES = [
'ARRIVED_AT_WAREHOUSE',
'UNDER_INSPECTION',
'RECEIVED',
'STORED',
'RESERVED',
'READY_FOR_LOADING',
'LOADED',
'DISPATCHED',
] as const;
export type WarehouseInventoryStatus = (typeof WAREHOUSE_INVENTORY_STATUSES)[number];
/** Allowed forward transitions for the inventory lifecycle. */
export const WAREHOUSE_INVENTORY_TRANSITIONS: Record<WarehouseInventoryStatus, WarehouseInventoryStatus[]> = {
RECEIVED: ['STORED'],
STORED: ['RESERVED'],
RESERVED: ['READY_FOR_LOADING'],
READY_FOR_LOADING: ['LOADED'],
LOADED: ['DISPATCHED'],
DISPATCHED: [],
};
@Entity({ schema: 'freight', name: 'warehouse_inventory' })
@Index(['warehouseId'])
@Index(['yardId'])
@@ -43,15 +61,27 @@ export class WarehouseInventory extends BaseEntity {
@JoinColumn({ name: 'zone_id' })
zone?: WarehouseZone;
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
@Column({ name: 'booking_id', type: 'uuid', nullable: true })
bookingId?: string | null;
@ManyToOne(() => Booking, { nullable: true })
@JoinColumn({ name: 'booking_id' })
booking?: Booking | null;
@Column({ name: 'cargo_id', type: 'uuid', nullable: true })
cargoId?: string | null;
@ManyToOne(() => Cargo, { nullable: true })
@JoinColumn({ name: 'cargo_id' })
cargo?: Cargo | null;
@Column({ name: 'container_id', type: 'uuid', nullable: true })
containerId?: string | null;
@ManyToOne(() => Container, { nullable: true })
@JoinColumn({ name: 'container_id' })
container?: Container | null;
@Column({ name: 'goods_id', type: 'uuid', nullable: true })
goodsId?: string | null;
@@ -64,18 +94,30 @@ export class WarehouseInventory extends BaseEntity {
@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' })
@Column({ name: 'status', type: 'varchar', length: 32, default: 'RECEIVED' })
status!: WarehouseInventoryStatus;
@Column({ name: 'arrived_at', type: 'timestamptz', nullable: true })
arrivedAt?: Date | null;
@Column({ name: 'stored_at', type: 'timestamptz', nullable: true })
storedAt?: Date | null;
@Column({ name: 'reserved_at', type: 'timestamptz', nullable: true })
reservedAt?: 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: 'loaded_at', type: 'timestamptz', nullable: true })
loadedAt?: Date | null;
@Column({ name: 'dispatched_at', type: 'timestamptz', nullable: true })
dispatchedAt?: Date | null;
@Column({ name: 'notes', type: 'text', nullable: true })
notes?: string | null;
}

View File

@@ -49,6 +49,15 @@ export class WarehouseYard extends BaseEntity {
@Column({ name: 'current_containers', type: 'int', default: 0 })
currentContainers!: number;
@Column({ name: 'max_weight', type: 'numeric', precision: 14, scale: 3, nullable: true })
maxWeight?: number | null;
@Column({ name: 'max_volume', type: 'numeric', precision: 14, scale: 3, nullable: true })
maxVolume?: number | null;
@Column({ name: 'current_volume', type: 'numeric', precision: 14, scale: 3, default: 0 })
currentVolume!: number;
@Column({ name: 'status', type: 'varchar', length: 16, default: 'ACTIVE' })
status!: WarehouseYardStatus;

View File

@@ -48,6 +48,15 @@ export class WarehouseZone extends BaseEntity {
@Column({ name: 'current_containers', type: 'int', default: 0 })
currentContainers!: number;
@Column({ name: 'max_weight', type: 'numeric', precision: 14, scale: 3, nullable: true })
maxWeight?: number | null;
@Column({ name: 'max_volume', type: 'numeric', precision: 14, scale: 3, nullable: true })
maxVolume?: number | null;
@Column({ name: 'current_volume', type: 'numeric', precision: 14, scale: 3, default: 0 })
currentVolume!: number;
@Column({ name: 'status', type: 'varchar', length: 16, default: 'ACTIVE' })
status!: WarehouseZoneStatus;

View File

@@ -42,6 +42,16 @@ export class Warehouse extends BaseEntity {
@Column({ name: 'current_containers', type: 'int', default: 0 })
currentContainers!: number;
// Batch 2 capacity (weight + volume). maxWeight backfilled from capacityWeight.
@Column({ name: 'max_weight', type: 'numeric', precision: 14, scale: 3, nullable: true })
maxWeight?: number | null;
@Column({ name: 'max_volume', type: 'numeric', precision: 14, scale: 3, nullable: true })
maxVolume?: number | null;
@Column({ name: 'current_volume', type: 'numeric', precision: 14, scale: 3, default: 0 })
currentVolume!: number;
@Column({ name: 'status', type: 'varchar', length: 16, default: 'ACTIVE' })
status!: WarehouseStatus;

View File

@@ -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 { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
@Injectable()
export class WarehouseActivityLogRepository extends BaseRepository<WarehouseActivityLog> {
constructor(@InjectRepository(WarehouseActivityLog) repository: Repository<WarehouseActivityLog>) {
super(repository);
}
}

View File

@@ -0,0 +1,48 @@
import { Injectable } from '@nestjs/common';
import { EntityManager } from 'typeorm';
import {
WarehouseActivityLog,
WarehouseActivityType,
} from './entities/warehouse-activity-log.entity';
import { WarehouseActivityLogRepository } from './warehouse-activity-log.repository';
interface LogInput {
activityType: WarehouseActivityType;
description?: string;
inventoryId?: string | null;
warehouseId?: string | null;
performedBy?: string | null;
}
@Injectable()
export class WarehouseActivityLogService {
constructor(private readonly logRepository: WarehouseActivityLogRepository) {}
/** Persist an activity record. Pass a transaction manager to enrol in the caller's transaction. */
async record(input: LogInput, manager?: EntityManager): Promise<void> {
const data = {
activityType: input.activityType,
description: input.description ?? null,
inventoryId: input.inventoryId ?? null,
warehouseId: input.warehouseId ?? null,
performedBy: input.performedBy ?? 'system',
};
if (manager) {
await manager.getRepository(WarehouseActivityLog).save(
manager.getRepository(WarehouseActivityLog).create(data),
);
return;
}
await this.logRepository.create(data);
}
findByInventory(inventoryId: string): Promise<WarehouseActivityLog[]> {
return this.logRepository.findAll({
where: { inventoryId },
order: { createdAt: 'DESC' },
});
}
}

View File

@@ -0,0 +1,55 @@
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { Warehouse } from './entities/warehouse.entity';
import { WarehouseInventory } from './entities/warehouse-inventory.entity';
export interface WarehouseDashboard {
totalWarehouses: number;
totalInventory: number;
receivedToday: number;
stored: number;
reserved: number;
readyForLoading: number;
loaded: number;
dispatched: number;
}
@Injectable()
export class WarehouseDashboardService {
constructor(private readonly dataSource: DataSource) {}
async getDashboard(): Promise<WarehouseDashboard> {
const warehouseRepo = this.dataSource.getRepository(Warehouse);
const inventoryRepo = this.dataSource.getRepository(WarehouseInventory);
const startOfToday = new Date();
startOfToday.setHours(0, 0, 0, 0);
const [totalWarehouses, totalInventory, stored, reserved, readyForLoading, loaded, dispatched, receivedToday] =
await Promise.all([
warehouseRepo.count(),
inventoryRepo.count(),
inventoryRepo.count({ where: { status: 'STORED' } }),
inventoryRepo.count({ where: { status: 'RESERVED' } }),
inventoryRepo.count({ where: { status: 'READY_FOR_LOADING' } }),
inventoryRepo.count({ where: { status: 'LOADED' } }),
inventoryRepo.count({ where: { status: 'DISPATCHED' } }),
inventoryRepo
.createQueryBuilder('inv')
.where('inv.arrived_at >= :start', { start: startOfToday })
.getCount(),
]);
return {
totalWarehouses,
totalInventory,
receivedToday,
stored,
reserved,
readyForLoading,
loaded,
dispatched,
};
}
}

View File

@@ -0,0 +1,15 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { WarehouseInventoryMovement } from './entities/warehouse-inventory-movement.entity';
@Injectable()
export class WarehouseInventoryMovementRepository extends BaseRepository<WarehouseInventoryMovement> {
constructor(
@InjectRepository(WarehouseInventoryMovement) repository: Repository<WarehouseInventoryMovement>,
) {
super(repository);
}
}

View File

@@ -1,9 +1,11 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { Body, Controller, Get, Param, ParseUUIDPipe, 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 { MoveInventoryDto } from './dto/move-inventory.dto';
import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
import { WarehouseInventoryService } from './warehouse-inventory.service';
@ApiTags('warehouse-inventory')
@@ -36,15 +38,51 @@ export class WarehouseInventoryController {
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);
@Post('reserve')
@ApiOperation({ summary: 'Reserve stored inventory for a PAID booking' })
reserve(@Body() dto: ReserveInventoryDto) {
return this.inventoryService.reserve(dto);
}
@Patch(':id/ready-for-loading')
@ApiOperation({ summary: 'Move inventory to READY_FOR_LOADING' })
readyForLoading(@Param('id', ParseUUIDPipe) id: string) {
return this.inventoryService.readyForLoading(id);
@Get(':id/movements')
@ApiOperation({ summary: 'Inventory movement history' })
movements(@Param('id', ParseUUIDPipe) id: string) {
return this.inventoryService.findMovements(id);
}
@Get(':id/activity')
@ApiOperation({ summary: 'Inventory activity log' })
activity(@Param('id', ParseUUIDPipe) id: string) {
return this.inventoryService.findActivity(id);
}
@Post(':id/move')
@ApiOperation({ summary: 'Move inventory to another warehouse/yard/zone' })
move(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveInventoryDto) {
return this.inventoryService.move(id, dto);
}
@Post(':id/store')
@ApiOperation({ summary: 'Mark received inventory as STORED' })
store(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
return this.inventoryService.store(id, performedBy);
}
@Post(':id/ready-for-loading')
@ApiOperation({ summary: 'Mark reserved inventory READY_FOR_LOADING' })
readyForLoading(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
return this.inventoryService.readyForLoading(id, performedBy);
}
@Post(':id/load')
@ApiOperation({ summary: 'Mark inventory LOADED' })
load(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
return this.inventoryService.load(id, performedBy);
}
@Post(':id/dispatch')
@ApiOperation({ summary: 'Mark inventory DISPATCHED' })
dispatch(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
return this.inventoryService.dispatch(id, performedBy);
}
}

View File

@@ -3,16 +3,25 @@ import { DataSource, EntityManager, FindManyOptions, ILike } from 'typeorm';
import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto';
import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto';
import { MoveInventoryDto } from './dto/move-inventory.dto';
import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
import { WarehouseInventory } from './entities/warehouse-inventory.entity';
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
import { WarehouseInventoryMovement } from './entities/warehouse-inventory-movement.entity';
import {
WAREHOUSE_INVENTORY_TRANSITIONS,
WarehouseInventory,
WarehouseInventoryStatus,
} 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 { WarehouseActivityLogService } from './warehouse-activity-log.service';
import { WarehouseInventoryRepository } from './warehouse-inventory.repository';
export interface InventoryInquiryResult {
id: string;
bookingId: string;
bookingId: string | null;
bookingNumber: string | null;
customerName: string | null;
containerNumber: string | null;
@@ -29,11 +38,22 @@ export interface InventoryInquiryResult {
readyForLoadingAt: Date | null;
}
interface LocationNode {
maxWeight?: number | null;
capacityWeight?: number | null;
maxVolume?: number | null;
capacityContainers?: number | null;
currentWeight: number;
currentVolume: number;
currentContainers: number;
}
@Injectable()
export class WarehouseInventoryService {
constructor(
private readonly dataSource: DataSource,
private readonly inventoryRepository: WarehouseInventoryRepository,
private readonly activityLog: WarehouseActivityLogService,
) {}
// ── Listing ────────────────────────────────────────────────────────────
@@ -78,20 +98,23 @@ export class WarehouseInventoryService {
return item;
}
// ── Receive (with location + capacity validation) ──────────────────────
// ── Receive ──────────────────────────────────────────────────────────────
async receive(dto: ReceiveWarehouseInventoryDto): Promise<WarehouseInventory> {
const weight = Number(dto.weight) || 0;
const volume = Number(dto.volume) || 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);
if (dto.bookingId) {
await this.assertBookingExists(manager, dto.bookingId);
}
this.assertCapacity('Warehouse', warehouse, weight, containerCount);
this.assertCapacity('Yard', yard, weight, containerCount);
this.assertCapacity('Zone', zone, weight, containerCount);
this.assertCapacity('Warehouse', warehouse, weight, volume, containerCount);
this.assertCapacity('Yard', yard, weight, volume, containerCount);
this.assertCapacity('Zone', zone, weight, volume, containerCount);
const now = new Date();
const saved = await manager.getRepository(WarehouseInventory).save(
@@ -99,20 +122,31 @@ export class WarehouseInventoryService {
warehouseId: dto.warehouseId,
yardId: dto.yardId,
zoneId: dto.zoneId,
bookingId: dto.bookingId,
bookingId: dto.bookingId ?? null,
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',
status: 'RECEIVED',
arrivedAt: now,
notes: dto.notes?.trim() ?? null,
}),
);
await this.applyCapacityDelta(manager, dto, weight, containerCount);
await this.applyCapacityDelta(manager, dto.warehouseId, dto.yardId, dto.zoneId, weight, volume, containerCount, +1);
await this.activityLog.record(
{
activityType: 'INVENTORY_RECEIVED',
inventoryId: saved.id,
warehouseId: dto.warehouseId,
description: `Received ${weight}kg at warehouse location`,
performedBy: dto.performedBy,
},
manager,
);
return saved.id;
});
@@ -120,43 +154,181 @@ export class WarehouseInventoryService {
return this.findById(id);
}
// ── Status transitions ────────────────────────────────────────────────
// ── Lifecycle transitions ────────────────────────────────────────────────
async inspect(id: string): Promise<WarehouseInventory> {
const item = await this.findById(id);
store(id: string, performedBy?: string): Promise<WarehouseInventory> {
return this.transition(id, 'STORED', {
timestampField: 'storedAt',
activityType: 'INVENTORY_STORED',
description: 'Inventory stored',
performedBy,
});
}
if (item.status !== 'ARRIVED_AT_WAREHOUSE') {
throw new BadRequestException(
`Only items in ARRIVED_AT_WAREHOUSE can be inspected (current: ${item.status})`,
);
async reserve(dto: ReserveInventoryDto): Promise<WarehouseInventory> {
const item = await this.findById(dto.inventoryId);
if (item.status !== 'STORED') {
throw new BadRequestException(`Inventory must be STORED to reserve (current: ${item.status})`);
}
await this.inventoryRepository.update(id, {
status: 'UNDER_INSPECTION',
inspectedAt: new Date(),
const status = await this.getBookingStatus(dto.bookingId);
if (!status) {
throw new NotFoundException(`Booking ${dto.bookingId} not found`);
}
if (status !== 'PAID') {
throw new BadRequestException(`Booking must be PAID to reserve inventory (current: ${status})`);
}
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(WarehouseInventory).update(dto.inventoryId, {
status: 'RESERVED',
bookingId: dto.bookingId,
reservedAt: new Date(),
});
await this.activityLog.record(
{
activityType: 'INVENTORY_RESERVED',
inventoryId: dto.inventoryId,
warehouseId: item.warehouseId,
description: `Reserved for booking ${dto.bookingId}`,
performedBy: dto.performedBy,
},
manager,
);
});
return this.findById(dto.inventoryId);
}
async readyForLoading(id: string, performedBy?: string): Promise<WarehouseInventory> {
const item = await this.findById(id);
if (!item.bookingId || !item.warehouseId || !item.yardId || !item.zoneId) {
throw new BadRequestException('Inventory must have booking, warehouse, yard and zone before loading prep');
}
return this.transition(id, 'READY_FOR_LOADING', {
timestampField: 'readyForLoadingAt',
activityType: 'READY_FOR_LOADING',
description: 'Inventory ready for loading',
performedBy,
preloaded: item,
});
}
load(id: string, performedBy?: string): Promise<WarehouseInventory> {
return this.transition(id, 'LOADED', {
timestampField: 'loadedAt',
activityType: 'INVENTORY_LOADED',
description: 'Inventory loaded',
performedBy,
});
}
async dispatch(id: string, performedBy?: string): Promise<WarehouseInventory> {
const item = await this.findById(id);
this.assertTransition(item.status, 'DISPATCHED');
const weight = Number(item.weight) || 0;
const volume = Number(item.volume) || 0;
const containerCount = item.containerId ? Math.round(Number(item.quantity) || 0) : 0;
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(WarehouseInventory).update(id, {
status: 'DISPATCHED',
dispatchedAt: new Date(),
});
// Item physically leaves the warehouse — free up capacity.
await this.applyCapacityDelta(manager, item.warehouseId, item.yardId, item.zoneId, weight, volume, containerCount, -1);
await this.activityLog.record(
{
activityType: 'INVENTORY_DISPATCHED',
inventoryId: id,
warehouseId: item.warehouseId,
description: 'Inventory dispatched',
performedBy,
},
manager,
);
});
return this.findById(id);
}
async readyForLoading(id: string): Promise<WarehouseInventory> {
const item = await this.findById(id);
// ── Movement ──────────────────────────────────────────────────────────────
if (item.status !== 'UNDER_INSPECTION') {
throw new BadRequestException(
`Only items in UNDER_INSPECTION can be marked READY_FOR_LOADING (current: ${item.status})`,
);
async move(id: string, dto: MoveInventoryDto): Promise<WarehouseInventory> {
const item = await this.findById(id);
if (item.status === 'DISPATCHED') {
throw new BadRequestException('Dispatched inventory cannot be moved');
}
await this.inventoryRepository.update(id, {
status: 'READY_FOR_LOADING',
readyForLoadingAt: new Date(),
const weight = Number(item.weight) || 0;
const volume = Number(item.volume) || 0;
const containerCount = item.containerId ? Math.round(Number(item.quantity) || 0) : 0;
const from = { warehouseId: item.warehouseId, yardId: item.yardId, zoneId: item.zoneId };
await this.dataSource.transaction(async (manager) => {
const { warehouse } = await this.validateLocation(manager, dto);
// Capacity check at the destination (item is added there).
const dest = await this.loadLocation(manager, dto);
this.assertCapacity('Warehouse', dest.warehouse, weight, volume, containerCount);
this.assertCapacity('Yard', dest.yard, weight, volume, containerCount);
this.assertCapacity('Zone', dest.zone, weight, volume, containerCount);
// Free the old location, occupy the new one.
await this.applyCapacityDelta(manager, from.warehouseId, from.yardId, from.zoneId, weight, volume, containerCount, -1);
await this.applyCapacityDelta(manager, dto.warehouseId, dto.yardId, dto.zoneId, weight, volume, containerCount, +1);
await manager.getRepository(WarehouseInventory).update(id, {
warehouseId: dto.warehouseId,
yardId: dto.yardId,
zoneId: dto.zoneId,
});
await manager.getRepository(WarehouseInventoryMovement).save(
manager.getRepository(WarehouseInventoryMovement).create({
inventoryId: id,
fromWarehouseId: from.warehouseId,
fromYardId: from.yardId,
fromZoneId: from.zoneId,
toWarehouseId: dto.warehouseId,
toYardId: dto.yardId,
toZoneId: dto.zoneId,
remarks: dto.remarks?.trim() ?? null,
movedBy: dto.movedBy ?? 'system',
movedAt: new Date(),
}),
);
await this.activityLog.record(
{
activityType: 'INVENTORY_MOVED',
inventoryId: id,
warehouseId: warehouse.id,
description: dto.remarks?.trim() || 'Inventory moved',
performedBy: dto.movedBy,
},
manager,
);
});
return this.findById(id);
}
// ── Inquiry ────────────────────────────────────────────────────────────
findMovements(id: string): Promise<WarehouseInventoryMovement[]> {
return this.dataSource.getRepository(WarehouseInventoryMovement).find({
where: { inventoryId: id },
order: { movedAt: 'DESC' },
});
}
findActivity(id: string): Promise<WarehouseActivityLog[]> {
return this.activityLog.findByInventory(id);
}
// ── Inquiry (Batch 1) ──────────────────────────────────────────────────
async inquiry(filter: InquiryWarehouseInventoryDto): Promise<InventoryInquiryResult[]> {
const qb = this.dataSource
@@ -187,21 +359,12 @@ export class WarehouseInventoryService {
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 });
}
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();
@@ -209,7 +372,7 @@ export class WarehouseInventoryService {
const row = raw[index] ?? {};
return {
id: inv.id,
bookingId: inv.bookingId,
bookingId: inv.bookingId ?? null,
bookingNumber: row.b_reference ?? null,
customerName: row.c_name ?? null,
containerNumber: row.ct_number ?? null,
@@ -232,40 +395,71 @@ export class WarehouseInventoryService {
// ── Helpers ──────────────────────────────────────────────────────────────
private async transition(
id: string,
to: WarehouseInventoryStatus,
opts: {
timestampField: keyof WarehouseInventory;
activityType: Parameters<WarehouseActivityLogService['record']>[0]['activityType'];
description: string;
performedBy?: string;
preloaded?: WarehouseInventory;
},
): Promise<WarehouseInventory> {
const item = opts.preloaded ?? (await this.findById(id));
this.assertTransition(item.status, to);
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(WarehouseInventory).update(id, {
status: to,
[opts.timestampField]: new Date(),
});
await this.activityLog.record(
{
activityType: opts.activityType,
inventoryId: id,
warehouseId: item.warehouseId,
description: opts.description,
performedBy: opts.performedBy,
},
manager,
);
});
return this.findById(id);
}
private assertTransition(from: WarehouseInventoryStatus, to: WarehouseInventoryStatus): void {
if (!WAREHOUSE_INVENTORY_TRANSITIONS[from]?.includes(to)) {
throw new BadRequestException(`Invalid transition ${from}${to}`);
}
}
private async validateLocation(
manager: EntityManager,
dto: ReceiveWarehouseInventoryDto,
dto: { warehouseId: string; yardId: string; zoneId: string },
): Promise<{ warehouse: Warehouse; yard: WarehouseYard; zone: WarehouseZone }> {
const { warehouse, yard, zone } = await this.loadLocation(manager, dto);
if (warehouse.status !== 'ACTIVE') throw new BadRequestException('Warehouse is not ACTIVE');
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');
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 loadLocation(
manager: EntityManager,
dto: { warehouseId: string; yardId: string; zoneId: string },
): 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');
}
if (!warehouse) throw new NotFoundException(`Warehouse ${dto.warehouseId} not found`);
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');
}
if (!yard) throw new NotFoundException(`Yard ${dto.yardId} not found`);
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');
}
if (!zone) throw new NotFoundException(`Zone ${dto.zoneId} not found`);
return { warehouse, yard, zone };
}
@@ -279,45 +473,63 @@ export class WarehouseInventoryService {
}
}
private async getBookingStatus(bookingId: string): Promise<string | null> {
const rows = await this.dataSource.query(
'SELECT status FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL LIMIT 1',
[bookingId],
);
return rows?.[0]?.status ?? null;
}
private assertCapacity(
label: string,
node: { capacityWeight?: number | null; capacityContainers?: number | null; currentWeight: number; currentContainers: number },
node: LocationNode,
weightAdd: number,
volumeAdd: number,
containerAdd: number,
): void {
if (node.capacityWeight != null) {
const maxWeight = node.maxWeight ?? node.capacityWeight;
if (maxWeight != null) {
const projected = Number(node.currentWeight) + weightAdd;
if (projected > Number(node.capacityWeight)) {
throw new BadRequestException(
`${label} weight capacity exceeded (${projected} / ${node.capacityWeight})`,
);
if (projected > Number(maxWeight)) {
throw new BadRequestException(`${label} weight capacity exceeded (${projected} / ${maxWeight})`);
}
}
if (node.maxVolume != null && volumeAdd > 0) {
const projected = Number(node.currentVolume) + volumeAdd;
if (projected > Number(node.maxVolume)) {
throw new BadRequestException(`${label} volume capacity exceeded (${projected} / ${node.maxVolume})`);
}
}
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})`,
);
throw new BadRequestException(`${label} container capacity exceeded (${projected} / ${node.capacityContainers})`);
}
}
}
private async applyCapacityDelta(
manager: EntityManager,
dto: ReceiveWarehouseInventoryDto,
weightAdd: number,
containerAdd: number,
warehouseId: string,
yardId: string,
zoneId: string,
weight: number,
volume: number,
containers: number,
sign: 1 | -1,
): 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);
const apply = sign === 1 ? manager.increment.bind(manager) : manager.decrement.bind(manager);
const targets: Array<[typeof Warehouse | typeof WarehouseYard | typeof WarehouseZone, string]> = [
[Warehouse, warehouseId],
[WarehouseYard, yardId],
[WarehouseZone, zoneId],
];
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);
for (const [entity, id] of targets) {
if (weight) await apply(entity, { id }, 'currentWeight', weight);
if (volume) await apply(entity, { id }, 'currentVolume', volume);
if (containers) await apply(entity, { id }, 'currentContainers', containers);
}
}
}

View File

@@ -0,0 +1,63 @@
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { WarehouseInventory } from './entities/warehouse-inventory.entity';
/**
* READ-ONLY bridge that exposes warehouse inventory to the Train Scheduling
* domain. It only reads warehouse data — it never assigns wagons, creates or
* mutates schedules, and is intentionally NOT imported by the scheduling module.
*/
@Injectable()
export class WarehouseSchedulingAdapterService {
constructor(private readonly dataSource: DataSource) {}
private get repo() {
return this.dataSource.getRepository(WarehouseInventory);
}
getReadyForLoadingInventory(): Promise<WarehouseInventory[]> {
return this.repo.find({
where: { status: 'READY_FOR_LOADING' },
relations: { warehouse: true, yard: true, zone: true },
order: { readyForLoadingAt: 'ASC' },
});
}
getReservedInventory(): Promise<WarehouseInventory[]> {
return this.repo.find({
where: { status: 'RESERVED' },
relations: { warehouse: true, yard: true, zone: true },
order: { reservedAt: 'ASC' },
});
}
getInventoryByBooking(bookingId: string): Promise<WarehouseInventory[]> {
return this.repo.find({
where: { bookingId },
relations: { warehouse: true, yard: true, zone: true },
order: { createdAt: 'DESC' },
});
}
/**
* Inventory whose origin booking runs on the given route. Best-effort, read-only:
* matches the route's origin/destination yards against the booking's yards.
*/
async getInventoryByRoute(routeId: string): Promise<WarehouseInventory[]> {
return this.repo
.createQueryBuilder('inv')
.leftJoinAndSelect('inv.warehouse', 'warehouse')
.leftJoinAndSelect('inv.yard', 'yard')
.leftJoinAndSelect('inv.zone', 'zone')
.innerJoin('freight.bookings', 'booking', 'booking.id = inv.booking_id')
.innerJoin(
'freight.routes',
'route',
'route.id = :routeId AND (route.origin_yard_id = booking.origin_yard_id OR route.destination_yard_id = booking.destination_yard_id)',
{ routeId },
)
.orderBy('inv.created_at', 'DESC')
.getMany();
}
}

View File

@@ -45,8 +45,11 @@ export class WarehouseYardsService {
type: dto.type,
capacityWeight: dto.capacityWeight ?? null,
capacityContainers: dto.capacityContainers ?? null,
maxWeight: dto.maxWeight ?? dto.capacityWeight ?? null,
maxVolume: dto.maxVolume ?? null,
currentWeight: 0,
currentContainers: 0,
currentVolume: 0,
status: 'ACTIVE',
isActive: true,
});
@@ -67,6 +70,8 @@ export class WarehouseYardsService {
type: dto.type ?? existing.type,
capacityWeight: dto.capacityWeight ?? existing.capacityWeight,
capacityContainers: dto.capacityContainers ?? existing.capacityContainers,
maxWeight: dto.maxWeight ?? existing.maxWeight,
maxVolume: dto.maxVolume ?? existing.maxVolume,
status,
isActive: status === 'ACTIVE',
});

View File

@@ -44,8 +44,11 @@ export class WarehouseZonesService {
type: dto.type,
capacityWeight: dto.capacityWeight ?? null,
capacityContainers: dto.capacityContainers ?? null,
maxWeight: dto.maxWeight ?? dto.capacityWeight ?? null,
maxVolume: dto.maxVolume ?? null,
currentWeight: 0,
currentContainers: 0,
currentVolume: 0,
status: 'ACTIVE',
isActive: true,
});
@@ -66,6 +69,8 @@ export class WarehouseZonesService {
type: dto.type ?? existing.type,
capacityWeight: dto.capacityWeight ?? existing.capacityWeight,
capacityContainers: dto.capacityContainers ?? existing.capacityContainers,
maxWeight: dto.maxWeight ?? existing.maxWeight,
maxVolume: dto.maxVolume ?? existing.maxVolume,
status,
isActive: status === 'ACTIVE',
});

View File

@@ -5,6 +5,7 @@ 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 { WarehouseDashboardService } from './warehouse-dashboard.service';
import { WarehouseYardsService } from './warehouse-yards.service';
import { WarehousesService } from './warehouses.service';
@@ -15,6 +16,7 @@ export class WarehousesController {
constructor(
private readonly warehousesService: WarehousesService,
private readonly yardsService: WarehouseYardsService,
private readonly dashboardService: WarehouseDashboardService,
) {}
@Get()
@@ -23,6 +25,12 @@ export class WarehousesController {
return this.warehousesService.findAll(filter);
}
@Get('dashboard')
@ApiOperation({ summary: 'Warehouse dashboard metrics' })
dashboard() {
return this.dashboardService.getDashboard();
}
@Post()
@ApiOperation({ summary: 'Create warehouse' })
create(@Body() dto: CreateWarehouseDto) {

View File

@@ -1,13 +1,20 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
import { WarehouseInventory } from './entities/warehouse-inventory.entity';
import { WarehouseInventoryMovement } from './entities/warehouse-inventory-movement.entity';
import { WarehouseYard } from './entities/warehouse-yard.entity';
import { WarehouseZone } from './entities/warehouse-zone.entity';
import { Warehouse } from './entities/warehouse.entity';
import { WarehouseActivityLogRepository } from './warehouse-activity-log.repository';
import { WarehouseActivityLogService } from './warehouse-activity-log.service';
import { WarehouseDashboardService } from './warehouse-dashboard.service';
import { WarehouseInventoryController } from './warehouse-inventory.controller';
import { WarehouseInventoryMovementRepository } from './warehouse-inventory-movement.repository';
import { WarehouseInventoryRepository } from './warehouse-inventory.repository';
import { WarehouseInventoryService } from './warehouse-inventory.service';
import { WarehouseSchedulingAdapterService } from './warehouse-scheduling-adapter.service';
import { WarehouseYardsController } from './warehouse-yards.controller';
import { WarehouseYardsRepository } from './warehouse-yards.repository';
import { WarehouseYardsService } from './warehouse-yards.service';
@@ -19,7 +26,16 @@ import { WarehousesRepository } from './warehouses.repository';
import { WarehousesService } from './warehouses.service';
@Module({
imports: [TypeOrmModule.forFeature([Warehouse, WarehouseYard, WarehouseZone, WarehouseInventory])],
imports: [
TypeOrmModule.forFeature([
Warehouse,
WarehouseYard,
WarehouseZone,
WarehouseInventory,
WarehouseInventoryMovement,
WarehouseActivityLog,
]),
],
controllers: [
WarehousesController,
WarehouseYardsController,
@@ -31,11 +47,22 @@ import { WarehousesService } from './warehouses.service';
WarehouseYardsRepository,
WarehouseZonesRepository,
WarehouseInventoryRepository,
WarehouseInventoryMovementRepository,
WarehouseActivityLogRepository,
WarehousesService,
WarehouseYardsService,
WarehouseZonesService,
WarehouseInventoryService,
WarehouseActivityLogService,
WarehouseDashboardService,
WarehouseSchedulingAdapterService,
],
exports: [
WarehousesService,
WarehouseYardsService,
WarehouseZonesService,
WarehouseInventoryService,
WarehouseSchedulingAdapterService,
],
exports: [WarehousesService, WarehouseYardsService, WarehouseZonesService, WarehouseInventoryService],
})
export class WarehousesModule {}

View File

@@ -56,8 +56,11 @@ export class WarehousesService {
locationName: dto.locationName?.trim() ?? null,
capacityWeight: dto.capacityWeight ?? null,
capacityContainers: dto.capacityContainers ?? null,
maxWeight: dto.maxWeight ?? dto.capacityWeight ?? null,
maxVolume: dto.maxVolume ?? null,
currentWeight: 0,
currentContainers: 0,
currentVolume: 0,
status: 'ACTIVE',
isActive: true,
});
@@ -80,6 +83,8 @@ export class WarehousesService {
locationName: dto.locationName?.trim() ?? existing.locationName,
capacityWeight: dto.capacityWeight ?? existing.capacityWeight,
capacityContainers: dto.capacityContainers ?? existing.capacityContainers,
maxWeight: dto.maxWeight ?? existing.maxWeight,
maxVolume: dto.maxVolume ?? existing.maxVolume,
status,
isActive: status === 'ACTIVE',
});

View File

@@ -49,6 +49,7 @@ import WarehouseListPage from "./pages/warehouses/WarehouseListPage";
import WarehouseDetailPage from "./pages/warehouses/WarehouseDetailPage";
import WarehouseInventoryPage from "./pages/warehouses/WarehouseInventoryPage";
import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage";
import WarehouseDashboardPage from "./pages/warehouses/WarehouseDashboardPage";
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
{
@@ -121,6 +122,11 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
{
title: "Warehouse Management",
items: [
{
label: "Warehouse Dashboard",
href: "/dashboard/warehouse-dashboard",
icon: <LayoutDashboard />,
},
{
label: "Warehouses",
href: "/dashboard/warehouses",
@@ -284,6 +290,7 @@ const App = () => {
<Route path="warehouses/:id" element={<WarehouseDetailPage />} />
<Route path="warehouse-inventory" element={<WarehouseInventoryPage />} />
<Route path="inventory-inquiry" element={<InventoryInquiryPage />} />
<Route path="warehouse-dashboard" element={<WarehouseDashboardPage />} />
<Route path="user-management" element={<UserManagementPage />} />
<Route path="user-management/users" element={<UsersPage />} />

View File

@@ -0,0 +1,62 @@
import { Center, Loader, Text, Timeline } from '@mantine/core';
import {
ArrowRightLeft,
ClipboardCheck,
PackageCheck,
PackagePlus,
Send,
Truck,
Warehouse,
} from 'lucide-react';
import { useInventoryActivity } from '@/hooks/useWarehouses';
import type { ActivityType } from '@/types/warehouse';
import { formatDate, humanizeEnum } from './options';
const activityIcon: Record<ActivityType, React.ReactNode> = {
INVENTORY_RECEIVED: <PackagePlus size={14} />,
INVENTORY_STORED: <Warehouse size={14} />,
INVENTORY_MOVED: <ArrowRightLeft size={14} />,
INVENTORY_RESERVED: <ClipboardCheck size={14} />,
READY_FOR_LOADING: <PackageCheck size={14} />,
INVENTORY_LOADED: <Truck size={14} />,
INVENTORY_DISPATCHED: <Send size={14} />,
};
export function ActivityTimeline({ inventoryId }: { inventoryId: string }) {
const { data, isLoading } = useInventoryActivity(inventoryId);
const items = data ?? [];
if (isLoading) {
return (
<Center py="lg">
<Loader size="sm" />
</Center>
);
}
if (items.length === 0) {
return (
<Text c="dimmed" ta="center" py="md" size="sm">
No activity recorded yet.
</Text>
);
}
return (
<Timeline active={items.length} bulletSize={24} lineWidth={2}>
{items.map((log) => (
<Timeline.Item key={log.id} bullet={activityIcon[log.activityType]} title={humanizeEnum(log.activityType)}>
{log.description && (
<Text size="sm" c="dimmed">
{log.description}
</Text>
)}
<Text size="xs" mt={4} c="dimmed">
{log.performedBy ?? 'system'} · {formatDate(log.createdAt)}
</Text>
</Timeline.Item>
))}
</Timeline>
);
}

View File

@@ -0,0 +1,41 @@
import { Select } from '@mantine/core';
import { useQuery } from '@tanstack/react-query';
import { bookingsService } from '@/services/bookings.service';
interface BookingSelectProps {
value: string;
onChange: (bookingId: string) => void;
label?: string;
required?: boolean;
/** Comma-separated statuses to restrict the list (e.g. "PAID" for reservations). */
statuses?: string;
}
/** Searchable booking picker — shows the human reference (e.g. BKG-BULK-002), submits the UUID. */
export function BookingSelect({ value, onChange, label = 'Booking', required, statuses }: BookingSelectProps) {
const { data, isLoading } = useQuery({
queryKey: ['bookings', 'options', statuses ?? 'all'],
queryFn: () =>
bookingsService.list({ pageSize: 200, ...(statuses ? { statuses } : {}) }).then((r) => r.items),
});
const options = (data ?? []).map((b) => ({
value: b.id,
label: b.status ? `${b.reference} · ${b.status}` : b.reference,
}));
return (
<Select
label={label}
required={required}
searchable
clearable
data={options}
value={value || null}
onChange={(v) => onChange(v ?? '')}
placeholder={isLoading ? 'Loading bookings…' : 'Search booking reference'}
nothingFoundMessage="No bookings found"
/>
);
}

View File

@@ -27,6 +27,7 @@ interface FormState {
locationName: string;
capacityWeight: number | '';
capacityContainers: number | '';
maxVolume: number | '';
status: 'ACTIVE' | 'INACTIVE';
}
@@ -37,6 +38,7 @@ const emptyForm = (): FormState => ({
locationName: '',
capacityWeight: '',
capacityContainers: '',
maxVolume: '',
status: 'ACTIVE',
});
@@ -58,6 +60,7 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
locationName: warehouse.locationName ?? '',
capacityWeight: warehouse.capacityWeight ?? '',
capacityContainers: warehouse.capacityContainers ?? '',
maxVolume: warehouse.maxVolume ?? '',
status: warehouse.status,
}
: emptyForm(),
@@ -80,6 +83,7 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
locationName: form.locationName.trim() || undefined,
capacityWeight: form.capacityWeight === '' ? undefined : Number(form.capacityWeight),
capacityContainers: form.capacityContainers === '' ? undefined : Number(form.capacityContainers),
maxVolume: form.maxVolume === '' ? undefined : Number(form.maxVolume),
};
try {
@@ -105,14 +109,14 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
placeholder="Modjo Open Warehouse"
required
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.currentTarget.value }))}
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, name: v })); }}
/>
<TextInput
label="Code"
placeholder="MODJO-OW"
required
value={form.code}
onChange={(e) => setForm((f) => ({ ...f, code: e.currentTarget.value }))}
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, code: v })); }}
/>
</Group>
@@ -139,7 +143,7 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
label="Location name"
placeholder="Modjo, Oromia"
value={form.locationName}
onChange={(e) => setForm((f) => ({ ...f, locationName: e.currentTarget.value }))}
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, locationName: v })); }}
/>
<Group grow>
@@ -157,6 +161,13 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
value={form.capacityContainers}
onChange={(value) => setForm((f) => ({ ...f, capacityContainers: value === '' ? '' : Number(value) }))}
/>
<NumberInput
label="Max volume (m³)"
placeholder="Optional"
min={0}
value={form.maxVolume}
onChange={(value) => setForm((f) => ({ ...f, maxVolume: value === '' ? '' : Number(value) }))}
/>
</Group>
<Group justify="flex-end" mt="sm">

View File

@@ -19,6 +19,7 @@ interface FormState {
type: WarehouseYardType;
capacityWeight: number | '';
capacityContainers: number | '';
maxVolume: number | '';
status: 'ACTIVE' | 'INACTIVE';
}
@@ -28,6 +29,7 @@ const emptyForm = (): FormState => ({
type: 'CONTAINER_YARD',
capacityWeight: '',
capacityContainers: '',
maxVolume: '',
status: 'ACTIVE',
});
@@ -48,6 +50,7 @@ export function CreateYardModal({ opened, onClose, warehouseId, yard }: CreateYa
type: yard.type,
capacityWeight: yard.capacityWeight ?? '',
capacityContainers: yard.capacityContainers ?? '',
maxVolume: yard.maxVolume ?? '',
status: yard.status,
}
: emptyForm(),
@@ -69,6 +72,7 @@ export function CreateYardModal({ opened, onClose, warehouseId, yard }: CreateYa
type: form.type,
capacityWeight: form.capacityWeight === '' ? undefined : Number(form.capacityWeight),
capacityContainers: form.capacityContainers === '' ? undefined : Number(form.capacityContainers),
maxVolume: form.maxVolume === '' ? undefined : Number(form.maxVolume),
};
try {
@@ -94,14 +98,14 @@ export function CreateYardModal({ opened, onClose, warehouseId, yard }: CreateYa
placeholder="Container Yard A"
required
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.currentTarget.value }))}
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, name: v })); }}
/>
<TextInput
label="Code"
placeholder="CY-A"
required
value={form.code}
onChange={(e) => setForm((f) => ({ ...f, code: e.currentTarget.value }))}
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, code: v })); }}
/>
</Group>
@@ -139,6 +143,13 @@ export function CreateYardModal({ opened, onClose, warehouseId, yard }: CreateYa
value={form.capacityContainers}
onChange={(value) => setForm((f) => ({ ...f, capacityContainers: value === '' ? '' : Number(value) }))}
/>
<NumberInput
label="Max volume (m³)"
placeholder="Optional"
min={0}
value={form.maxVolume}
onChange={(value) => setForm((f) => ({ ...f, maxVolume: value === '' ? '' : Number(value) }))}
/>
</Group>
<Group justify="flex-end" mt="sm">

View File

@@ -19,6 +19,7 @@ interface FormState {
type: WarehouseZoneType;
capacityWeight: number | '';
capacityContainers: number | '';
maxVolume: number | '';
status: 'ACTIVE' | 'INACTIVE';
}
@@ -28,6 +29,7 @@ const emptyForm = (): FormState => ({
type: 'CONTAINER_ZONE',
capacityWeight: '',
capacityContainers: '',
maxVolume: '',
status: 'ACTIVE',
});
@@ -48,6 +50,7 @@ export function CreateZoneModal({ opened, onClose, yardId, zone }: CreateZoneMod
type: zone.type,
capacityWeight: zone.capacityWeight ?? '',
capacityContainers: zone.capacityContainers ?? '',
maxVolume: zone.maxVolume ?? '',
status: zone.status,
}
: emptyForm(),
@@ -69,6 +72,7 @@ export function CreateZoneModal({ opened, onClose, yardId, zone }: CreateZoneMod
type: form.type,
capacityWeight: form.capacityWeight === '' ? undefined : Number(form.capacityWeight),
capacityContainers: form.capacityContainers === '' ? undefined : Number(form.capacityContainers),
maxVolume: form.maxVolume === '' ? undefined : Number(form.maxVolume),
};
try {
@@ -94,14 +98,14 @@ export function CreateZoneModal({ opened, onClose, yardId, zone }: CreateZoneMod
placeholder="Zone A-01"
required
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.currentTarget.value }))}
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, name: v })); }}
/>
<TextInput
label="Code"
placeholder="A-01"
required
value={form.code}
onChange={(e) => setForm((f) => ({ ...f, code: e.currentTarget.value }))}
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, code: v })); }}
/>
</Group>
@@ -139,6 +143,13 @@ export function CreateZoneModal({ opened, onClose, yardId, zone }: CreateZoneMod
value={form.capacityContainers}
onChange={(value) => setForm((f) => ({ ...f, capacityContainers: value === '' ? '' : Number(value) }))}
/>
<NumberInput
label="Max volume (m³)"
placeholder="Optional"
min={0}
value={form.maxVolume}
onChange={(value) => setForm((f) => ({ ...f, maxVolume: value === '' ? '' : Number(value) }))}
/>
</Group>
<Group justify="flex-end" mt="sm">

View File

@@ -0,0 +1,37 @@
import { Modal, Tabs } from '@mantine/core';
import { ArrowRightLeft, ListChecks } from 'lucide-react';
import type { WarehouseInventoryItem } from '@/types/warehouse';
import { ActivityTimeline } from './ActivityTimeline';
import { InventoryMovementHistoryTable } from './InventoryMovementHistoryTable';
interface InventoryHistoryModalProps {
opened: boolean;
onClose: () => void;
item: WarehouseInventoryItem | null;
}
export function InventoryHistoryModal({ opened, onClose, item }: InventoryHistoryModalProps) {
return (
<Modal opened={opened} onClose={onClose} title="Inventory history" centered size="xl">
{item && (
<Tabs defaultValue="activity">
<Tabs.List>
<Tabs.Tab value="activity" leftSection={<ListChecks size={16} />}>
Activity
</Tabs.Tab>
<Tabs.Tab value="movements" leftSection={<ArrowRightLeft size={16} />}>
Movements
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="activity" pt="md">
<ActivityTimeline inventoryId={item.id} />
</Tabs.Panel>
<Tabs.Panel value="movements" pt="md">
<InventoryMovementHistoryTable inventoryId={item.id} />
</Tabs.Panel>
</Tabs>
)}
</Modal>
);
}

View File

@@ -0,0 +1,64 @@
import { Center, Loader, Table, Text } from '@mantine/core';
import { useInventoryMovements } from '@/hooks/useWarehouses';
import { formatDate } from './options';
const shortId = (id?: string | null) => (id ? `${id.slice(0, 8)}` : '—');
export function InventoryMovementHistoryTable({ inventoryId }: { inventoryId: string }) {
const { data, isLoading } = useInventoryMovements(inventoryId);
const movements = data ?? [];
if (isLoading) {
return (
<Center py="lg">
<Loader size="sm" />
</Center>
);
}
if (movements.length === 0) {
return (
<Text c="dimmed" ta="center" py="md" size="sm">
No movements recorded for this item.
</Text>
);
}
return (
<Table.ScrollContainer minWidth={640}>
<Table verticalSpacing="xs" striped>
<Table.Thead>
<Table.Tr>
<Table.Th>From (W / Y / Z)</Table.Th>
<Table.Th>To (W / Y / Z)</Table.Th>
<Table.Th>Remarks</Table.Th>
<Table.Th>By</Table.Th>
<Table.Th>When</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{movements.map((m) => (
<Table.Tr key={m.id}>
<Table.Td>
<Text size="xs">
{shortId(m.fromWarehouseId)} / {shortId(m.fromYardId)} / {shortId(m.fromZoneId)}
</Text>
</Table.Td>
<Table.Td>
<Text size="xs">
{shortId(m.toWarehouseId)} / {shortId(m.toYardId)} / {shortId(m.toZoneId)}
</Text>
</Table.Td>
<Table.Td>{m.remarks ?? '—'}</Table.Td>
<Table.Td>{m.movedBy ?? '—'}</Table.Td>
<Table.Td>
<Text size="xs">{formatDate(m.movedAt)}</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
);
}

View File

@@ -0,0 +1,97 @@
import { useState } from 'react';
import { Center, Loader } from '@mantine/core';
import { useToast } from '@/hooks/use-toast';
import {
useDispatchInventory,
useLoadInventory,
useMarkReadyForLoading,
useStoreInventory,
} from '@/hooks/useWarehouses';
import type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse';
import { InventoryHistoryModal } from './InventoryHistoryModal';
import { MoveInventoryModal } from './MoveInventoryModal';
import { ReserveInventoryModal } from './ReserveInventoryModal';
import { WarehouseInventoryTable } from './WarehouseInventoryTable';
import { extractErrorMessage } from './options';
interface InventoryWorkbenchProps {
items: WarehouseInventoryItem[];
isLoading?: boolean;
}
/** Inventory table + all lifecycle actions (advance / move / reserve / history). */
export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps) {
const { toast } = useToast();
const [busyId, setBusyId] = useState<string | null>(null);
const [moveItem, setMoveItem] = useState<WarehouseInventoryItem | null>(null);
const [reserveItem, setReserveItem] = useState<WarehouseInventoryItem | null>(null);
const [historyItem, setHistoryItem] = useState<WarehouseInventoryItem | null>(null);
const storeMutation = useStoreInventory();
const readyMutation = useMarkReadyForLoading();
const loadMutation = useLoadInventory();
const dispatchMutation = useDispatchInventory();
const runDirect = async (item: WarehouseInventoryItem, fn: () => Promise<unknown>, label: string) => {
setBusyId(item.id);
try {
await fn();
toast({ title: label });
} catch (error) {
toast({ variant: 'destructive', title: 'Action failed', description: extractErrorMessage(error) });
} finally {
setBusyId(null);
}
};
const advance = (item: WarehouseInventoryItem, action: InventoryAction) => {
switch (action) {
case 'store':
return runDirect(item, () => storeMutation.mutateAsync(item.id), 'Inventory stored');
case 'reserve':
setReserveItem(item);
return;
case 'ready-for-loading':
return runDirect(item, () => readyMutation.mutateAsync(item.id), 'Ready for loading');
case 'load':
return runDirect(item, () => loadMutation.mutateAsync(item.id), 'Inventory loaded');
case 'dispatch':
return runDirect(item, () => dispatchMutation.mutateAsync(item.id), 'Inventory dispatched');
default:
return;
}
};
if (isLoading) {
return (
<Center py="xl">
<Loader />
</Center>
);
}
return (
<>
<WarehouseInventoryTable
items={items}
busyId={busyId}
onAdvance={advance}
onMove={setMoveItem}
onHistory={setHistoryItem}
/>
<MoveInventoryModal opened={Boolean(moveItem)} onClose={() => setMoveItem(null)} item={moveItem} />
<ReserveInventoryModal
opened={Boolean(reserveItem)}
onClose={() => setReserveItem(null)}
item={reserveItem}
/>
<InventoryHistoryModal
opened={Boolean(historyItem)}
onClose={() => setHistoryItem(null)}
item={historyItem}
/>
</>
);
}

View File

@@ -0,0 +1,125 @@
import { useEffect, useMemo, useState } from 'react';
import { Button, Group, Modal, Select, Stack, Textarea } from '@mantine/core';
import { useToast } from '@/hooks/use-toast';
import { useMoveInventory, useWarehouseYards, useWarehouseZones, useWarehouses } from '@/hooks/useWarehouses';
import type { WarehouseInventoryItem } from '@/types/warehouse';
import { extractErrorMessage } from './options';
interface MoveInventoryModalProps {
opened: boolean;
onClose: () => void;
item: WarehouseInventoryItem | null;
}
export function MoveInventoryModal({ opened, onClose, item }: MoveInventoryModalProps) {
const { toast } = useToast();
const moveMutation = useMoveInventory();
const [warehouseId, setWarehouseId] = useState('');
const [yardId, setYardId] = useState('');
const [zoneId, setZoneId] = useState('');
const [remarks, setRemarks] = useState('');
useEffect(() => {
if (opened) {
setWarehouseId('');
setYardId('');
setZoneId('');
setRemarks('');
}
}, [opened]);
const warehousesQuery = useWarehouses({ status: 'ACTIVE' });
const yardsQuery = useWarehouseYards(warehouseId || undefined);
const zonesQuery = useWarehouseZones(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 handleSubmit = async () => {
if (!item) return;
if (!warehouseId || !yardId || !zoneId) {
toast({ variant: 'destructive', title: 'Select destination warehouse, yard and zone' });
return;
}
try {
await moveMutation.mutateAsync({
id: item.id,
payload: { warehouseId, yardId, zoneId, remarks: remarks.trim() || undefined },
});
toast({ title: 'Inventory moved' });
onClose();
} catch (error) {
toast({ variant: 'destructive', title: 'Move failed', description: extractErrorMessage(error) });
}
};
return (
<Modal opened={opened} onClose={onClose} title="Move inventory" centered size="lg">
<Stack gap="md">
<Select
label="Destination warehouse"
placeholder="Select warehouse"
required
searchable
data={warehouseOptions}
value={warehouseId || null}
onChange={(v) => {
setWarehouseId(v ?? '');
setYardId('');
setZoneId('');
}}
/>
<Select
label="Destination yard"
placeholder={!warehouseId ? 'Select a warehouse first' : 'Select yard'}
required
searchable
disabled={!warehouseId}
data={yardOptions}
value={yardId || null}
onChange={(v) => {
setYardId(v ?? '');
setZoneId('');
}}
/>
<Select
label="Destination zone"
placeholder={!yardId ? 'Select a yard first' : 'Select zone'}
required
searchable
disabled={!yardId}
data={zoneOptions}
value={zoneId || null}
onChange={(v) => setZoneId(v ?? '')}
/>
<Textarea
label="Remarks"
placeholder="Reason for the move"
autosize
minRows={2}
value={remarks}
onChange={(e) => setRemarks(e.currentTarget.value)}
/>
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={onClose} disabled={moveMutation.isPending}>
Cancel
</Button>
<Button onClick={handleSubmit} loading={moveMutation.isPending}>
Move inventory
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -9,6 +9,7 @@ import {
useWarehouses,
} from '@/hooks/useWarehouses';
import type { ReceiveInventoryPayload } from '@/types/warehouse';
import { BookingSelect } from './BookingSelect';
import { extractErrorMessage } from './options';
interface ReceiveInventoryModalProps {
@@ -125,12 +126,10 @@ export function ReceiveInventoryModal({
{bookingId ? (
<TextInput label="Booking" value={bookingLabel ?? bookingId} readOnly />
) : (
<TextInput
label="Booking ID"
placeholder="Booking UUID"
required
<BookingSelect
label="Booking"
value={form.bookingId}
onChange={(e) => setForm((f) => ({ ...f, bookingId: e.currentTarget.value }))}
onChange={(v) => setForm((f) => ({ ...f, bookingId: v }))}
/>
)}
@@ -198,7 +197,7 @@ export function ReceiveInventoryModal({
autosize
minRows={2}
value={form.notes}
onChange={(e) => setForm((f) => ({ ...f, notes: e.currentTarget.value }))}
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, notes: v })); }}
/>
<Group justify="flex-end" mt="sm">

View File

@@ -0,0 +1,59 @@
import { useEffect, useState } from 'react';
import { Alert, Button, Group, Modal, Stack, Text } from '@mantine/core';
import { Info } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { useReserveInventory } from '@/hooks/useWarehouses';
import type { WarehouseInventoryItem } from '@/types/warehouse';
import { BookingSelect } from './BookingSelect';
import { extractErrorMessage } from './options';
interface ReserveInventoryModalProps {
opened: boolean;
onClose: () => void;
item: WarehouseInventoryItem | null;
}
export function ReserveInventoryModal({ opened, onClose, item }: ReserveInventoryModalProps) {
const { toast } = useToast();
const reserveMutation = useReserveInventory();
const [bookingId, setBookingId] = useState('');
useEffect(() => {
if (opened) setBookingId(item?.bookingId ?? '');
}, [opened, item]);
const handleSubmit = async () => {
if (!item) return;
if (!bookingId.trim()) {
toast({ variant: 'destructive', title: 'Booking is required' });
return;
}
try {
await reserveMutation.mutateAsync({ inventoryId: item.id, bookingId: bookingId.trim() });
toast({ title: 'Inventory reserved' });
onClose();
} catch (error) {
toast({ variant: 'destructive', title: 'Reserve failed', description: extractErrorMessage(error) });
}
};
return (
<Modal opened={opened} onClose={onClose} title="Reserve inventory" centered size="md">
<Stack gap="md">
<Alert icon={<Info size={16} />} color="blue" variant="light">
<Text size="sm">The booking must be in <b>PAID</b> status and the inventory must be <b>STORED</b>.</Text>
</Alert>
<BookingSelect label="Booking (PAID)" required statuses="PAID" value={bookingId} onChange={setBookingId} />
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={onClose} disabled={reserveMutation.isPending}>
Cancel
</Button>
<Button onClick={handleSubmit} loading={reserveMutation.isPending}>
Reserve
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -1,15 +1,17 @@
import { Badge, Button, Group, Table, Text, Tooltip } from '@mantine/core';
import { ClipboardCheck, PackageCheck } from 'lucide-react';
import { ActionIcon, Badge, Button, Group, Table, Text, Tooltip } from '@mantine/core';
import { ArrowRightLeft, History } from 'lucide-react';
import type { WarehouseInventoryItem } from '@/types/warehouse';
import type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse';
import { INVENTORY_NEXT_ACTION } from '@/types/warehouse';
import { InventoryStatusBadge } from './badges';
import { formatDate, formatNumber } from './options';
import { formatDate, formatNumber, humanizeEnum } from './options';
interface WarehouseInventoryTableProps {
items: WarehouseInventoryItem[];
onInspect: (item: WarehouseInventoryItem) => void;
onReadyForLoading: (item: WarehouseInventoryItem) => void;
busyId?: string | null;
onAdvance: (item: WarehouseInventoryItem, action: InventoryAction) => void;
onMove: (item: WarehouseInventoryItem) => void;
onHistory: (item: WarehouseInventoryItem) => void;
}
const itemKind = (item: WarehouseInventoryItem) => {
@@ -19,11 +21,20 @@ const itemKind = (item: WarehouseInventoryItem) => {
return { label: '—', color: 'gray' };
};
const actionColor: Record<InventoryAction, string> = {
store: 'blue',
reserve: 'grape',
'ready-for-loading': 'cyan',
load: 'teal',
dispatch: 'green',
};
export function WarehouseInventoryTable({
items,
onInspect,
onReadyForLoading,
busyId,
onAdvance,
onMove,
onHistory,
}: WarehouseInventoryTableProps) {
if (items.length === 0) {
return (
@@ -34,7 +45,7 @@ export function WarehouseInventoryTable({
}
return (
<Table.ScrollContainer minWidth={1100}>
<Table.ScrollContainer minWidth={1150}>
<Table highlightOnHover verticalSpacing="sm" striped>
<Table.Thead>
<Table.Tr>
@@ -47,7 +58,6 @@ export function WarehouseInventoryTable({
<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>
@@ -55,14 +65,21 @@ export function WarehouseInventoryTable({
{items.map((item) => {
const kind = itemKind(item);
const busy = busyId === item.id;
const nextAction = INVENTORY_NEXT_ACTION[item.status];
return (
<Table.Tr key={item.id}>
<Table.Td>
<Tooltip label={item.bookingId} withArrow>
<Text size="sm" fw={600}>
{item.bookingId.slice(0, 8)}
{item.bookingId ? (
<Tooltip label={item.bookingId} withArrow>
<Text size="sm" fw={600}>
{item.bookingId.slice(0, 8)}
</Text>
</Tooltip>
) : (
<Text size="sm" c="dimmed">
</Text>
</Tooltip>
)}
</Table.Td>
<Table.Td>{item.warehouse?.code ?? '—'}</Table.Td>
<Table.Td>{item.yard?.code ?? '—'}</Table.Td>
@@ -80,33 +97,31 @@ export function WarehouseInventoryTable({
<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>
{nextAction && (
<Button
size="compact-xs"
variant="light"
color={actionColor[nextAction]}
loading={busy}
onClick={() => onAdvance(item, nextAction)}
>
{humanizeEnum(nextAction.replace(/-/g, '_'))}
</Button>
)}
{item.status !== 'DISPATCHED' && (
<Tooltip label="Move" withArrow>
<ActionIcon variant="subtle" color="gray" onClick={() => onMove(item)}>
<ArrowRightLeft size={16} />
</ActionIcon>
</Tooltip>
)}
<Tooltip label="History" withArrow>
<ActionIcon variant="subtle" color="gray" onClick={() => onHistory(item)}>
<History size={16} />
</ActionIcon>
</Tooltip>
</Group>
</Table.Td>
</Table.Tr>

View File

@@ -34,9 +34,12 @@ export function WarehouseStatusBadge({ status }: { status: WarehouseStatus }) {
}
const inventoryStatusColor: Record<InventoryStatus, string> = {
ARRIVED_AT_WAREHOUSE: 'yellow',
UNDER_INSPECTION: 'cyan',
READY_FOR_LOADING: 'green',
RECEIVED: 'yellow',
STORED: 'blue',
RESERVED: 'grape',
READY_FOR_LOADING: 'cyan',
LOADED: 'teal',
DISPATCHED: 'green',
};
export function InventoryStatusBadge({ status }: { status: InventoryStatus }) {

View File

@@ -11,3 +11,10 @@ export { CreateYardModal } from './CreateYardModal';
export { CreateZoneModal } from './CreateZoneModal';
export { ReceiveInventoryModal } from './ReceiveInventoryModal';
export { WarehouseInfoCard } from './WarehouseInfoCard';
export { MoveInventoryModal } from './MoveInventoryModal';
export { ReserveInventoryModal } from './ReserveInventoryModal';
export { InventoryMovementHistoryTable } from './InventoryMovementHistoryTable';
export { ActivityTimeline } from './ActivityTimeline';
export { InventoryHistoryModal } from './InventoryHistoryModal';
export { InventoryWorkbench } from './InventoryWorkbench';
export { BookingSelect } from './BookingSelect';

View File

@@ -186,6 +186,7 @@ export const URL_CONSTANTS = {
WAREHOUSES: {
BASE: '/warehouses',
DASHBOARD: '/warehouses/dashboard',
BY_ID: (id: string) => `/warehouses/${id}`,
YARDS: (warehouseId: string) => `/warehouses/${warehouseId}/yards`,
},
@@ -202,9 +203,15 @@ export const URL_CONSTANTS = {
WAREHOUSE_INVENTORY: {
BASE: '/warehouse-inventory',
RECEIVE: '/warehouse-inventory/receive',
RESERVE: '/warehouse-inventory/reserve',
READY_FOR_LOADING: '/warehouse-inventory/ready-for-loading',
INQUIRY: '/warehouse-inventory/inquiry',
INSPECT: (id: string) => `/warehouse-inventory/${id}/inspect`,
MOVE: (id: string) => `/warehouse-inventory/${id}/move`,
MOVEMENTS: (id: string) => `/warehouse-inventory/${id}/movements`,
ACTIVITY: (id: string) => `/warehouse-inventory/${id}/activity`,
STORE: (id: string) => `/warehouse-inventory/${id}/store`,
MARK_READY: (id: string) => `/warehouse-inventory/${id}/ready-for-loading`,
LOAD: (id: string) => `/warehouse-inventory/${id}/load`,
DISPATCH: (id: string) => `/warehouse-inventory/${id}/dispatch`,
},
};

View File

@@ -4,7 +4,9 @@ import { warehouseService } from '@/services/warehouse.service';
import type {
InventoryFilter,
InventoryInquiryFilter,
MoveInventoryPayload,
ReceiveInventoryPayload,
ReserveInventoryPayload,
SaveWarehousePayload,
SaveYardPayload,
SaveZonePayload,
@@ -137,19 +139,49 @@ export function useReceiveInventory() {
});
}
export function useInspectInventory() {
function useInventoryMutation<TArgs>(fn: (args: TArgs) => Promise<unknown>) {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => warehouseService.inspectInventory(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ['warehouse-inventory'] }),
mutationFn: fn,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
qc.invalidateQueries({ queryKey: warehouseKeys.all });
},
});
}
export function useMarkReadyForLoading() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => warehouseService.markReadyForLoading(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ['warehouse-inventory'] }),
export const useStoreInventory = () => useInventoryMutation((id: string) => warehouseService.store(id));
export const useReserveInventory = () =>
useInventoryMutation((payload: ReserveInventoryPayload) => warehouseService.reserve(payload));
export const useMarkReadyForLoading = () =>
useInventoryMutation((id: string) => warehouseService.markReadyForLoading(id));
export const useLoadInventory = () => useInventoryMutation((id: string) => warehouseService.load(id));
export const useDispatchInventory = () => useInventoryMutation((id: string) => warehouseService.dispatch(id));
export const useMoveInventory = () =>
useInventoryMutation((args: { id: string; payload: MoveInventoryPayload }) =>
warehouseService.move(args.id, args.payload),
);
export function useInventoryMovements(id?: string) {
return useQuery({
queryKey: ['warehouse-inventory', id, 'movements'],
queryFn: () => warehouseService.movements(id as string).then((r) => r.data),
enabled: Boolean(id),
});
}
export function useInventoryActivity(id?: string) {
return useQuery({
queryKey: ['warehouse-inventory', id, 'activity'],
queryFn: () => warehouseService.activity(id as string).then((r) => r.data),
enabled: Boolean(id),
});
}
export function useWarehouseDashboard() {
return useQuery({
queryKey: ['warehouses', 'dashboard'],
queryFn: () => warehouseService.dashboard().then((r) => r.data),
});
}

View File

@@ -61,21 +61,21 @@ export default function InventoryInquiryPage() {
label="Booking number"
placeholder="e.g. BKG-00123"
value={draft.bookingNumber ?? ''}
onChange={(e) => setDraft((f) => ({ ...f, bookingNumber: e.currentTarget.value || undefined }))}
onChange={(e) => { const v = e.currentTarget.value; setDraft((f) => ({ ...f, bookingNumber: v || undefined })); }}
w={200}
/>
<TextInput
label="Container number"
placeholder="e.g. MSKU1234567"
value={draft.containerNumber ?? ''}
onChange={(e) => setDraft((f) => ({ ...f, containerNumber: e.currentTarget.value || undefined }))}
onChange={(e) => { const v = e.currentTarget.value; setDraft((f) => ({ ...f, containerNumber: v || undefined })); }}
w={200}
/>
<TextInput
label="Goods name"
placeholder="e.g. Coffee"
value={draft.goodsName ?? ''}
onChange={(e) => setDraft((f) => ({ ...f, goodsName: e.currentTarget.value || undefined }))}
onChange={(e) => { const v = e.currentTarget.value; setDraft((f) => ({ ...f, goodsName: v || undefined })); }}
w={180}
/>
<Select

View File

@@ -0,0 +1,78 @@
import { Card, Center, Container, Group, Loader, SimpleGrid, Stack, Text, ThemeIcon, Title } from '@mantine/core';
import {
ClipboardCheck,
PackageCheck,
PackagePlus,
Send,
Truck,
Warehouse as WarehouseIcon,
Boxes,
Layers,
} from 'lucide-react';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import { useWarehouseDashboard } from '@/hooks/useWarehouses';
import type { WarehouseDashboard } from '@/types/warehouse';
interface Metric {
key: keyof WarehouseDashboard;
label: string;
color: string;
icon: React.ReactNode;
}
const METRICS: Metric[] = [
{ key: 'totalWarehouses', label: 'Total Warehouses', color: 'indigo', icon: <WarehouseIcon size={20} /> },
{ key: 'totalInventory', label: 'Total Inventory', color: 'gray', icon: <Boxes size={20} /> },
{ key: 'receivedToday', label: 'Received Today', color: 'yellow', icon: <PackagePlus size={20} /> },
{ key: 'stored', label: 'Stored', color: 'blue', icon: <Layers size={20} /> },
{ key: 'reserved', label: 'Reserved', color: 'grape', icon: <ClipboardCheck size={20} /> },
{ key: 'readyForLoading', label: 'Ready For Loading', color: 'cyan', icon: <PackageCheck size={20} /> },
{ key: 'loaded', label: 'Loaded', color: 'teal', icon: <Truck size={20} /> },
{ key: 'dispatched', label: 'Dispatched', color: 'green', icon: <Send size={20} /> },
];
export default function WarehouseDashboardPage() {
const { data, isLoading } = useWarehouseDashboard();
return (
<Container size="xxl" py="lg">
<Breadcrumbs items={[{ label: 'Warehouse dashboard' }]} />
<Stack gap="lg" mt="sm">
<div>
<Title order={2}>Warehouse Dashboard</Title>
<Text c="dimmed" size="sm">
Live overview of warehouse capacity and inventory lifecycle.
</Text>
</div>
{isLoading ? (
<Center py="xl">
<Loader />
</Center>
) : (
<SimpleGrid cols={{ base: 1, xs: 2, md: 4 }} spacing="md">
{METRICS.map((metric) => (
<Card key={metric.key} withBorder radius="md" padding="lg">
<Group justify="space-between" align="flex-start">
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{metric.label}
</Text>
<Text fw={700} size="28px" mt={6}>
{data ? data[metric.key] : 0}
</Text>
</div>
<ThemeIcon variant="light" color={metric.color} size="lg" radius="md">
{metric.icon}
</ThemeIcon>
</Group>
</Card>
))}
</SimpleGrid>
)}
</Stack>
</Container>
);
}

View File

@@ -19,26 +19,22 @@ import {
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,
InventoryWorkbench,
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';
import type { WarehouseYard, WarehouseZone } from '@/types/warehouse';
function StatCard({ label, value }: { label: string; value: string }) {
return (
@@ -56,7 +52,6 @@ function StatCard({ label, value }: { label: string; value: string }) {
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);
@@ -71,9 +66,6 @@ export default function WarehouseDetailPage() {
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(
@@ -81,30 +73,6 @@ export default function WarehouseDetailPage() {
[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">
@@ -337,18 +305,7 @@ export default function WarehouseDetailPage() {
{/* 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}
/>
)}
<InventoryWorkbench items={inventoryQuery.data ?? []} isLoading={inventoryQuery.isLoading} />
</Card>
</Tabs.Panel>
</Tabs>

View File

@@ -1,32 +1,26 @@
import { useMemo, useState } from 'react';
import { Button, Card, Center, Container, Group, Loader, Select, Stack, Text, TextInput, Title } from '@mantine/core';
import { Button, Card, Container, Group, 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 {
InventoryWorkbench,
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';
import type { InventoryFilter, InventoryStatus } 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>(
@@ -39,9 +33,6 @@ export default function WarehouseInventoryPage() {
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],
@@ -55,30 +46,6 @@ export default function WarehouseInventoryPage() {
[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' }]} />
@@ -88,7 +55,7 @@ export default function WarehouseInventoryPage() {
<div>
<Title order={2}>Warehouse Inventory</Title>
<Text c="dimmed" size="sm">
Track received items and move them through inspection to loading.
Track received items through the storage, reservation, loading and dispatch lifecycle.
</Text>
</div>
<Button leftSection={<PackagePlus size={16} />} onClick={() => setModalOpen(true)}>
@@ -147,18 +114,7 @@ export default function WarehouseInventoryPage() {
/>
</Group>
{inventoryQuery.isLoading ? (
<Center py="xl">
<Loader />
</Center>
) : (
<WarehouseInventoryTable
items={inventoryQuery.data ?? []}
onInspect={handleInspect}
onReadyForLoading={handleReady}
busyId={busyId}
/>
)}
<InventoryWorkbench items={inventoryQuery.data ?? []} isLoading={inventoryQuery.isLoading} />
</Stack>
</Card>
</Stack>

View File

@@ -5,11 +5,16 @@ import type {
InventoryFilter,
InventoryInquiryFilter,
InventoryInquiryResult,
InventoryMovement,
MoveInventoryPayload,
ReceiveInventoryPayload,
ReserveInventoryPayload,
SaveWarehousePayload,
SaveYardPayload,
SaveZonePayload,
Warehouse,
WarehouseActivityLog,
WarehouseDashboard,
WarehouseFilter,
WarehouseInventoryItem,
WarehouseYard,
@@ -27,6 +32,7 @@ export const warehouseService = {
apiClient.get<Warehouse[]>(URL_CONSTANTS.WAREHOUSES.BASE, {
params: cleanParams(filter ?? {}),
}),
dashboard: () => apiClient.get<WarehouseDashboard>(URL_CONSTANTS.WAREHOUSES.DASHBOARD),
getById: (id: string) => apiClient.get<Warehouse>(URL_CONSTANTS.WAREHOUSES.BY_ID(id)),
create: (payload: SaveWarehousePayload) =>
apiClient.post<Warehouse>(URL_CONSTANTS.WAREHOUSES.BASE, payload),
@@ -58,10 +64,6 @@ export const warehouseService = {
}),
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 ?? {}),
@@ -70,4 +72,22 @@ export const warehouseService = {
apiClient.get<InventoryInquiryResult[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.INQUIRY, {
params: cleanParams(filter ?? {}),
}),
// ── Lifecycle (Batch 2) ──────────────────────────────────────────────────
store: (id: string) =>
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.STORE(id)),
reserve: (payload: ReserveInventoryPayload) =>
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RESERVE, payload),
markReadyForLoading: (id: string) =>
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.MARK_READY(id)),
load: (id: string) =>
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.LOAD(id)),
dispatch: (id: string) =>
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.DISPATCH(id)),
move: (id: string, payload: MoveInventoryPayload) =>
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.MOVE(id), payload),
movements: (id: string) =>
apiClient.get<InventoryMovement[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.MOVEMENTS(id)),
activity: (id: string) =>
apiClient.get<WarehouseActivityLog[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.ACTIVITY(id)),
};

View File

@@ -23,12 +23,27 @@ export const WAREHOUSE_ZONE_TYPES = [
export type WarehouseZoneType = (typeof WAREHOUSE_ZONE_TYPES)[number];
export const INVENTORY_STATUSES = [
'ARRIVED_AT_WAREHOUSE',
'UNDER_INSPECTION',
'RECEIVED',
'STORED',
'RESERVED',
'READY_FOR_LOADING',
'LOADED',
'DISPATCHED',
] as const;
export type InventoryStatus = (typeof INVENTORY_STATUSES)[number];
/** Next allowed lifecycle action keyed by current status. */
export const INVENTORY_NEXT_ACTION: Record<InventoryStatus, InventoryAction | null> = {
RECEIVED: 'store',
STORED: 'reserve',
RESERVED: 'ready-for-loading',
READY_FOR_LOADING: 'load',
LOADED: 'dispatch',
DISPATCHED: null,
};
export type InventoryAction = 'store' | 'reserve' | 'ready-for-loading' | 'load' | 'dispatch';
export interface WarehouseZone {
id: string;
yardId: string;
@@ -37,8 +52,11 @@ export interface WarehouseZone {
type: WarehouseZoneType;
capacityWeight: number | null;
capacityContainers: number | null;
maxWeight: number | null;
maxVolume: number | null;
currentWeight: number;
currentContainers: number;
currentVolume: number;
status: WarehouseStatus;
isActive: boolean;
}
@@ -51,8 +69,11 @@ export interface WarehouseYard {
type: WarehouseYardType;
capacityWeight: number | null;
capacityContainers: number | null;
maxWeight: number | null;
maxVolume: number | null;
currentWeight: number;
currentContainers: number;
currentVolume: number;
status: WarehouseStatus;
isActive: boolean;
zones?: WarehouseZone[];
@@ -67,8 +88,11 @@ export interface Warehouse {
locationName: string | null;
capacityWeight: number | null;
capacityContainers: number | null;
maxWeight: number | null;
maxVolume: number | null;
currentWeight: number;
currentContainers: number;
currentVolume: number;
status: WarehouseStatus;
isActive: boolean;
yards?: WarehouseYard[];
@@ -81,7 +105,7 @@ export interface WarehouseInventoryItem {
warehouseId: string;
yardId: string;
zoneId: string;
bookingId: string;
bookingId: string | null;
cargoId: string | null;
containerId: string | null;
goodsId: string | null;
@@ -90,14 +114,76 @@ export interface WarehouseInventoryItem {
volume: number | null;
status: InventoryStatus;
arrivedAt: string | null;
storedAt: string | null;
reservedAt: string | null;
inspectedAt: string | null;
readyForLoadingAt: string | null;
loadedAt: string | null;
dispatchedAt: string | null;
notes: string | null;
warehouse?: Warehouse | null;
yard?: WarehouseYard | null;
zone?: WarehouseZone | null;
}
export interface InventoryMovement {
id: string;
inventoryId: string;
fromWarehouseId: string;
fromYardId: string;
fromZoneId: string;
toWarehouseId: string;
toYardId: string;
toZoneId: string;
remarks: string | null;
movedBy: string | null;
movedAt: string;
}
export const ACTIVITY_TYPES = [
'INVENTORY_RECEIVED',
'INVENTORY_STORED',
'INVENTORY_MOVED',
'INVENTORY_RESERVED',
'READY_FOR_LOADING',
'INVENTORY_LOADED',
'INVENTORY_DISPATCHED',
] as const;
export type ActivityType = (typeof ACTIVITY_TYPES)[number];
export interface WarehouseActivityLog {
id: string;
inventoryId: string | null;
warehouseId: string | null;
activityType: ActivityType;
description: string | null;
performedBy: string | null;
createdAt: string;
}
export interface WarehouseDashboard {
totalWarehouses: number;
totalInventory: number;
receivedToday: number;
stored: number;
reserved: number;
readyForLoading: number;
loaded: number;
dispatched: number;
}
export interface MoveInventoryPayload {
warehouseId: string;
yardId: string;
zoneId: string;
remarks?: string;
}
export interface ReserveInventoryPayload {
bookingId: string;
inventoryId: string;
}
export interface InventoryInquiryResult {
id: string;
bookingId: string;
@@ -127,6 +213,8 @@ export interface SaveWarehousePayload {
locationName?: string;
capacityWeight?: number;
capacityContainers?: number;
maxWeight?: number;
maxVolume?: number;
status?: WarehouseStatus;
}
@@ -136,6 +224,8 @@ export interface SaveYardPayload {
type: WarehouseYardType;
capacityWeight?: number;
capacityContainers?: number;
maxWeight?: number;
maxVolume?: number;
status?: WarehouseStatus;
}
@@ -145,6 +235,8 @@ export interface SaveZonePayload {
type: WarehouseZoneType;
capacityWeight?: number;
capacityContainers?: number;
maxWeight?: number;
maxVolume?: number;
status?: WarehouseStatus;
}