mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
feat(warehouse): batch 3 — loading, dispatch & train-departure visibility
- WarehouseLoading entity + Batch3 migration (wagon loading records) - wagon-aware load() with validation; dispatch moved to PATCH - read-only SchedulingReadFacade (schedule/wagon/departure) — never writes scheduling - new endpoints: GET /warehouse-loadings, loadable-wagons, booking schedule - Loading Queue / Loaded Inventory / Dispatch Queue pages + routes + sidebar - FreightVisual illustrations (page heroes + empty states) - booking detail: loaded/dispatched/wagon + read-only train schedule Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Batch 3 — Warehouse → Loading → Train Departure visibility.
|
||||
* Adds the warehouse_loadings record (inventory ↔ wagon). Does NOT touch any
|
||||
* scheduling / wagon tables — the warehouse only reads from those.
|
||||
*/
|
||||
export class WarehouseBatch31790000000002 implements MigrationInterface {
|
||||
name = 'WarehouseBatch31790000000002';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.warehouse_loadings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
warehouse_inventory_id UUID NOT NULL REFERENCES freight.warehouse_inventory(id) ON DELETE CASCADE,
|
||||
booking_id UUID NULL,
|
||||
wagon_id UUID NOT NULL,
|
||||
loaded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
loaded_by VARCHAR(120) NULL,
|
||||
loaded_weight NUMERIC(14,3) NULL,
|
||||
notes TEXT 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_loadings_inventory_id
|
||||
ON freight.warehouse_loadings(warehouse_inventory_id);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_warehouse_loadings_booking_id
|
||||
ON freight.warehouse_loadings(booking_id);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_warehouse_loadings_wagon_id
|
||||
ON freight.warehouse_loadings(wagon_id);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_loadings;`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
|
||||
|
||||
export class LoadInventoryDto {
|
||||
@ApiProperty({ format: 'uuid', description: 'Physical wagon the item is loaded onto' })
|
||||
@IsUUID()
|
||||
wagonId!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Weight loaded onto the wagon (kg)' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
loadedWeight?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
loadedBy?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
|
||||
import { Booking } from '../../bookings/entities/booking.entity';
|
||||
import { WarehouseInventory } from './warehouse-inventory.entity';
|
||||
|
||||
/**
|
||||
* Batch 3 — a record that a warehouse inventory item was physically loaded onto a wagon.
|
||||
* The warehouse OWNS this record. It only READS wagon/schedule data from the scheduling
|
||||
* domain (via SchedulingReadFacade); it never writes to wagons or train schedules.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'warehouse_loadings' })
|
||||
@Index(['warehouseInventoryId'])
|
||||
@Index(['bookingId'])
|
||||
@Index(['wagonId'])
|
||||
export class WarehouseLoading extends BaseEntity {
|
||||
@Column({ name: 'warehouse_inventory_id', type: 'uuid' })
|
||||
warehouseInventoryId!: string;
|
||||
|
||||
@ManyToOne(() => WarehouseInventory)
|
||||
@JoinColumn({ name: 'warehouse_inventory_id' })
|
||||
inventory?: WarehouseInventory;
|
||||
|
||||
@Column({ name: 'booking_id', type: 'uuid', nullable: true })
|
||||
bookingId?: string | null;
|
||||
|
||||
@ManyToOne(() => Booking, { nullable: true })
|
||||
@JoinColumn({ name: 'booking_id' })
|
||||
booking?: Booking | null;
|
||||
|
||||
/** Physical wagon the item was loaded onto. References freight.wagons (read-only link). */
|
||||
@Column({ name: 'wagon_id', type: 'uuid' })
|
||||
wagonId!: string;
|
||||
|
||||
@Column({ name: 'loaded_at', type: 'timestamptz' })
|
||||
loadedAt!: Date;
|
||||
|
||||
@Column({ name: 'loaded_by', type: 'varchar', length: 120, nullable: true })
|
||||
loadedBy?: string | null;
|
||||
|
||||
@Column({ name: 'loaded_weight', type: 'numeric', precision: 14, scale: 3, nullable: true })
|
||||
loadedWeight?: number | null;
|
||||
|
||||
@Column({ name: 'notes', type: 'text', nullable: true })
|
||||
notes?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
/**
|
||||
* READ-ONLY view into the train-scheduling / wagons domain for the warehouse module.
|
||||
*
|
||||
* IMPORTANT: this facade only ever runs SELECTs. The warehouse must never modify
|
||||
* wagon assignment, rescheduling, import_ready/export_ready, or locomotive flow.
|
||||
* It is intentionally decoupled (raw SQL) so it does not import the scheduling
|
||||
* services/entities and cannot accidentally write to them.
|
||||
*/
|
||||
export interface WagonView {
|
||||
id: string;
|
||||
wagonNumber: string;
|
||||
status: string;
|
||||
trainId: string | null;
|
||||
}
|
||||
|
||||
export interface BookingScheduleView {
|
||||
schedule: {
|
||||
id: string;
|
||||
status: string;
|
||||
scheduledDepartureDate: string | null;
|
||||
scheduledArrivalDate: string | null;
|
||||
originStationId: string | null;
|
||||
destinationStationId: string | null;
|
||||
} | null;
|
||||
wagon: {
|
||||
wagonId: string | null;
|
||||
wagonNumber: string | null;
|
||||
sequenceNo: number | null;
|
||||
allocatedWeightTons: number | null;
|
||||
} | null;
|
||||
/** Mirror of schedule.status — the headline "where is the train" indicator. */
|
||||
departureStatus: string | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SchedulingReadFacade {
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
/** Look up a single physical wagon. Returns null if it does not exist. */
|
||||
async findWagon(wagonId: string): Promise<WagonView | null> {
|
||||
const rows = await this.dataSource.query(
|
||||
`SELECT id, wagon_number AS "wagonNumber", status, train_id AS "trainId"
|
||||
FROM freight.wagons
|
||||
WHERE id = $1 AND deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[wagonId],
|
||||
);
|
||||
return rows?.[0] ?? null;
|
||||
}
|
||||
|
||||
/** True when the wagon is already part of a train set (selected by an existing schedule). */
|
||||
async isWagonScheduled(wagonId: string): Promise<boolean> {
|
||||
const rows = await this.dataSource.query(
|
||||
`SELECT 1 FROM freight.train_set_wagons
|
||||
WHERE physical_wagon_id = $1 AND deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[wagonId],
|
||||
);
|
||||
return (rows?.length ?? 0) > 0;
|
||||
}
|
||||
|
||||
/** List wagons usable for loading (available, or already assigned to a schedule). */
|
||||
listLoadableWagons(): Promise<WagonView[]> {
|
||||
return this.dataSource.query(
|
||||
`SELECT id, wagon_number AS "wagonNumber", status, train_id AS "trainId"
|
||||
FROM freight.wagons
|
||||
WHERE deleted_at IS NULL
|
||||
AND status NOT IN ('RETIRED', 'MAINTENANCE')
|
||||
ORDER BY wagon_number ASC`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a booking, return its related schedule, wagon assignment and departure status.
|
||||
* All fields are read straight from the scheduling tables — nothing is written.
|
||||
*/
|
||||
async getBookingSchedule(bookingId: string): Promise<BookingScheduleView> {
|
||||
const scheduleRows = await this.dataSource.query(
|
||||
`SELECT ts.id,
|
||||
ts.status,
|
||||
ts.scheduled_departure_date AS "scheduledDepartureDate",
|
||||
ts.scheduled_arrival_date AS "scheduledArrivalDate",
|
||||
ts.origin_station_id AS "originStationId",
|
||||
ts.destination_station_id AS "destinationStationId"
|
||||
FROM freight.train_schedule_bookings tsb
|
||||
INNER JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id
|
||||
WHERE tsb.booking_id = $1 AND ts.deleted_at IS NULL
|
||||
ORDER BY ts.scheduled_departure_date DESC NULLS LAST
|
||||
LIMIT 1`,
|
||||
[bookingId],
|
||||
);
|
||||
const schedule = scheduleRows?.[0] ?? null;
|
||||
|
||||
const wagonRows = await this.dataSource.query(
|
||||
`SELECT w.id AS "wagonId",
|
||||
w.wagon_number AS "wagonNumber",
|
||||
tsw.sequence_no AS "sequenceNo",
|
||||
wba.allocated_weight_tons AS "allocatedWeightTons"
|
||||
FROM freight.wagon_booking_allocations wba
|
||||
INNER JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id
|
||||
LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id
|
||||
WHERE wba.booking_id = $1
|
||||
ORDER BY tsw.sequence_no ASC NULLS LAST
|
||||
LIMIT 1`,
|
||||
[bookingId],
|
||||
);
|
||||
const wagon = wagonRows?.[0] ?? null;
|
||||
|
||||
return {
|
||||
schedule,
|
||||
wagon,
|
||||
departureStatus: schedule?.status ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,23 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Query } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto';
|
||||
import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto';
|
||||
import { LoadInventoryDto } from './dto/load-inventory.dto';
|
||||
import { MoveInventoryDto } from './dto/move-inventory.dto';
|
||||
import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
|
||||
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
|
||||
import { SchedulingReadFacade } from './scheduling-read.facade';
|
||||
import { WarehouseInventoryService } from './warehouse-inventory.service';
|
||||
|
||||
@ApiTags('warehouse-inventory')
|
||||
@ApiBearerAuth()
|
||||
@Controller('warehouse-inventory')
|
||||
export class WarehouseInventoryController {
|
||||
constructor(private readonly inventoryService: WarehouseInventoryService) {}
|
||||
constructor(
|
||||
private readonly inventoryService: WarehouseInventoryService,
|
||||
private readonly scheduling: SchedulingReadFacade,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List warehouse inventory' })
|
||||
@@ -32,6 +37,18 @@ export class WarehouseInventoryController {
|
||||
return this.inventoryService.inquiry(filter);
|
||||
}
|
||||
|
||||
@Get('loadable-wagons')
|
||||
@ApiOperation({ summary: 'List wagons usable for loading (read-only from scheduling)' })
|
||||
loadableWagons() {
|
||||
return this.scheduling.listLoadableWagons();
|
||||
}
|
||||
|
||||
@Get('booking/:bookingId/schedule')
|
||||
@ApiOperation({ summary: 'Read-only schedule + wagon + departure status for a booking' })
|
||||
bookingSchedule(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||
return this.scheduling.getBookingSchedule(bookingId);
|
||||
}
|
||||
|
||||
@Post('receive')
|
||||
@ApiOperation({ summary: 'Receive inventory at a warehouse location' })
|
||||
receive(@Body() dto: ReceiveWarehouseInventoryDto) {
|
||||
@@ -56,6 +73,12 @@ export class WarehouseInventoryController {
|
||||
return this.inventoryService.findActivity(id);
|
||||
}
|
||||
|
||||
@Get(':id/loadings')
|
||||
@ApiOperation({ summary: 'Loading records for an inventory item' })
|
||||
loadings(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.inventoryService.findLoadingsByInventory(id);
|
||||
}
|
||||
|
||||
@Post(':id/move')
|
||||
@ApiOperation({ summary: 'Move inventory to another warehouse/yard/zone' })
|
||||
move(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveInventoryDto) {
|
||||
@@ -75,13 +98,13 @@ export class WarehouseInventoryController {
|
||||
}
|
||||
|
||||
@Post(':id/load')
|
||||
@ApiOperation({ summary: 'Mark inventory LOADED' })
|
||||
load(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
|
||||
return this.inventoryService.load(id, performedBy);
|
||||
@ApiOperation({ summary: 'Load READY_FOR_LOADING inventory onto a wagon' })
|
||||
load(@Param('id', ParseUUIDPipe) id: string, @Body() dto: LoadInventoryDto) {
|
||||
return this.inventoryService.load(id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/dispatch')
|
||||
@ApiOperation({ summary: 'Mark inventory DISPATCHED' })
|
||||
@Patch(':id/dispatch')
|
||||
@ApiOperation({ summary: 'Mark loaded inventory DISPATCHED (left the terminal)' })
|
||||
dispatch(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
|
||||
return this.inventoryService.dispatch(id, performedBy);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { DataSource, EntityManager, FindManyOptions, ILike } from 'typeorm';
|
||||
|
||||
import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto';
|
||||
import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto';
|
||||
import { LoadInventoryDto } from './dto/load-inventory.dto';
|
||||
import { MoveInventoryDto } from './dto/move-inventory.dto';
|
||||
import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
|
||||
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
|
||||
@@ -13,11 +14,17 @@ import {
|
||||
WarehouseInventory,
|
||||
WarehouseInventoryStatus,
|
||||
} from './entities/warehouse-inventory.entity';
|
||||
import { WarehouseLoading } from './entities/warehouse-loading.entity';
|
||||
import { WarehouseYard } from './entities/warehouse-yard.entity';
|
||||
import { WarehouseZone } from './entities/warehouse-zone.entity';
|
||||
import { Warehouse } from './entities/warehouse.entity';
|
||||
import { SchedulingReadFacade } from './scheduling-read.facade';
|
||||
import { WarehouseActivityLogService } from './warehouse-activity-log.service';
|
||||
import { WarehouseInventoryRepository } from './warehouse-inventory.repository';
|
||||
import { WarehouseLoadingRepository } from './warehouse-loading.repository';
|
||||
|
||||
/** Wagon states that may receive a load (besides being part of an existing schedule). */
|
||||
const LOADABLE_WAGON_STATUSES = ['AVAILABLE', 'IMPORT_READY', 'EXPORT_READY', 'ASSIGNED'];
|
||||
|
||||
export interface InventoryInquiryResult {
|
||||
id: string;
|
||||
@@ -53,7 +60,9 @@ export class WarehouseInventoryService {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly inventoryRepository: WarehouseInventoryRepository,
|
||||
private readonly loadingRepository: WarehouseLoadingRepository,
|
||||
private readonly activityLog: WarehouseActivityLogService,
|
||||
private readonly scheduling: SchedulingReadFacade,
|
||||
) {}
|
||||
|
||||
// ── Listing ────────────────────────────────────────────────────────────
|
||||
@@ -215,12 +224,112 @@ export class WarehouseInventoryService {
|
||||
});
|
||||
}
|
||||
|
||||
load(id: string, performedBy?: string): Promise<WarehouseInventory> {
|
||||
return this.transition(id, 'LOADED', {
|
||||
timestampField: 'loadedAt',
|
||||
activityType: 'INVENTORY_LOADED',
|
||||
description: 'Inventory loaded',
|
||||
performedBy,
|
||||
/**
|
||||
* Load READY_FOR_LOADING inventory onto a wagon. Creates a WarehouseLoading record.
|
||||
* Reads wagon/schedule data read-only — never modifies scheduling.
|
||||
*/
|
||||
async load(id: string, dto: LoadInventoryDto): Promise<WarehouseInventory> {
|
||||
const item = await this.findById(id);
|
||||
|
||||
// 1. inventory status must be READY_FOR_LOADING (and not already LOADED).
|
||||
this.assertTransition(item.status, 'LOADED');
|
||||
|
||||
// 2. inventory is at a valid warehouse/yard/zone location.
|
||||
if (!item.warehouseId || !item.yardId || !item.zoneId) {
|
||||
throw new BadRequestException('Inventory must be at a warehouse/yard/zone before loading');
|
||||
}
|
||||
|
||||
// 3. wagon must exist.
|
||||
const wagon = await this.scheduling.findWagon(dto.wagonId);
|
||||
if (!wagon) {
|
||||
throw new NotFoundException(`Wagon ${dto.wagonId} not found`);
|
||||
}
|
||||
|
||||
// 4. wagon must be available, or already selected by an existing train schedule.
|
||||
const scheduled = await this.scheduling.isWagonScheduled(dto.wagonId);
|
||||
if (!LOADABLE_WAGON_STATUSES.includes(wagon.status) && !scheduled) {
|
||||
throw new BadRequestException(
|
||||
`Wagon ${wagon.wagonNumber} is not available for loading (status: ${wagon.status})`,
|
||||
);
|
||||
}
|
||||
|
||||
// 5. inventory must not already have a loading record.
|
||||
const existing = await this.loadingRepository.findAll({ where: { warehouseInventoryId: id } });
|
||||
if (existing.length > 0) {
|
||||
throw new BadRequestException('Inventory has already been loaded');
|
||||
}
|
||||
|
||||
const loadedWeight = dto.loadedWeight ?? (Number(item.weight) || 0);
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const now = new Date();
|
||||
await manager.getRepository(WarehouseInventory).update(id, {
|
||||
status: 'LOADED',
|
||||
loadedAt: now,
|
||||
});
|
||||
|
||||
await manager.getRepository(WarehouseLoading).save(
|
||||
manager.getRepository(WarehouseLoading).create({
|
||||
warehouseInventoryId: id,
|
||||
bookingId: item.bookingId ?? null,
|
||||
wagonId: dto.wagonId,
|
||||
loadedAt: now,
|
||||
loadedBy: dto.loadedBy ?? null,
|
||||
loadedWeight,
|
||||
notes: dto.notes?.trim() ?? null,
|
||||
}),
|
||||
);
|
||||
|
||||
await this.activityLog.record(
|
||||
{
|
||||
activityType: 'INVENTORY_LOADED',
|
||||
inventoryId: id,
|
||||
warehouseId: item.warehouseId,
|
||||
description: `Loaded onto wagon ${wagon.wagonNumber}`,
|
||||
performedBy: dto.loadedBy,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
});
|
||||
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
// ── Loading records (Batch 3) ─────────────────────────────────────────────
|
||||
|
||||
async findLoadings(
|
||||
filter: { bookingId?: string; wagonId?: string },
|
||||
): Promise<Array<WarehouseLoading & { wagonNumber: string | null }>> {
|
||||
const where = {
|
||||
...(filter.bookingId ? { bookingId: filter.bookingId } : {}),
|
||||
...(filter.wagonId ? { wagonId: filter.wagonId } : {}),
|
||||
};
|
||||
const loadings = await this.loadingRepository.findAll({
|
||||
where,
|
||||
relations: { inventory: { warehouse: true, yard: true, zone: true } },
|
||||
order: { loadedAt: 'DESC' },
|
||||
});
|
||||
|
||||
// Enrich with wagon numbers (read-only lookup into the scheduling domain).
|
||||
const wagonIds = [...new Set(loadings.map((l) => l.wagonId))];
|
||||
const wagonNumbers = new Map<string, string>();
|
||||
if (wagonIds.length > 0) {
|
||||
const rows: Array<{ id: string; wagon_number: string }> = await this.dataSource.query(
|
||||
'SELECT id, wagon_number FROM freight.wagons WHERE id = ANY($1)',
|
||||
[wagonIds],
|
||||
);
|
||||
rows.forEach((r) => wagonNumbers.set(r.id, r.wagon_number));
|
||||
}
|
||||
|
||||
return loadings.map((loading) =>
|
||||
Object.assign(loading, { wagonNumber: wagonNumbers.get(loading.wagonId) ?? null }),
|
||||
);
|
||||
}
|
||||
|
||||
findLoadingsByInventory(inventoryId: string): Promise<WarehouseLoading[]> {
|
||||
return this.loadingRepository.findAll({
|
||||
where: { warehouseInventoryId: inventoryId },
|
||||
order: { loadedAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 { WarehouseLoading } from './entities/warehouse-loading.entity';
|
||||
|
||||
@Injectable()
|
||||
export class WarehouseLoadingRepository extends BaseRepository<WarehouseLoading> {
|
||||
constructor(@InjectRepository(WarehouseLoading) repository: Repository<WarehouseLoading>) {
|
||||
super(repository);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { WarehouseInventoryService } from './warehouse-inventory.service';
|
||||
|
||||
@ApiTags('warehouse-loadings')
|
||||
@ApiBearerAuth()
|
||||
@Controller('warehouse-loadings')
|
||||
export class WarehouseLoadingsController {
|
||||
constructor(private readonly inventoryService: WarehouseInventoryService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List wagon loading records' })
|
||||
findAll(@Query('bookingId') bookingId?: string, @Query('wagonId') wagonId?: string) {
|
||||
return this.inventoryService.findLoadings({ bookingId, wagonId });
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,11 @@ 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 { WarehouseLoading } from './entities/warehouse-loading.entity';
|
||||
import { WarehouseYard } from './entities/warehouse-yard.entity';
|
||||
import { WarehouseZone } from './entities/warehouse-zone.entity';
|
||||
import { Warehouse } from './entities/warehouse.entity';
|
||||
import { SchedulingReadFacade } from './scheduling-read.facade';
|
||||
import { WarehouseActivityLogRepository } from './warehouse-activity-log.repository';
|
||||
import { WarehouseActivityLogService } from './warehouse-activity-log.service';
|
||||
import { WarehouseDashboardService } from './warehouse-dashboard.service';
|
||||
@@ -14,6 +16,8 @@ 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 { WarehouseLoadingRepository } from './warehouse-loading.repository';
|
||||
import { WarehouseLoadingsController } from './warehouse-loadings.controller';
|
||||
import { WarehouseSchedulingAdapterService } from './warehouse-scheduling-adapter.service';
|
||||
import { WarehouseYardsController } from './warehouse-yards.controller';
|
||||
import { WarehouseYardsRepository } from './warehouse-yards.repository';
|
||||
@@ -34,6 +38,7 @@ import { WarehousesService } from './warehouses.service';
|
||||
WarehouseInventory,
|
||||
WarehouseInventoryMovement,
|
||||
WarehouseActivityLog,
|
||||
WarehouseLoading,
|
||||
]),
|
||||
],
|
||||
controllers: [
|
||||
@@ -41,6 +46,7 @@ import { WarehousesService } from './warehouses.service';
|
||||
WarehouseYardsController,
|
||||
WarehouseZonesController,
|
||||
WarehouseInventoryController,
|
||||
WarehouseLoadingsController,
|
||||
],
|
||||
providers: [
|
||||
WarehousesRepository,
|
||||
@@ -49,6 +55,7 @@ import { WarehousesService } from './warehouses.service';
|
||||
WarehouseInventoryRepository,
|
||||
WarehouseInventoryMovementRepository,
|
||||
WarehouseActivityLogRepository,
|
||||
WarehouseLoadingRepository,
|
||||
WarehousesService,
|
||||
WarehouseYardsService,
|
||||
WarehouseZonesService,
|
||||
@@ -56,6 +63,7 @@ import { WarehousesService } from './warehouses.service';
|
||||
WarehouseActivityLogService,
|
||||
WarehouseDashboardService,
|
||||
WarehouseSchedulingAdapterService,
|
||||
SchedulingReadFacade,
|
||||
],
|
||||
exports: [
|
||||
WarehousesService,
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
LayoutDashboard,
|
||||
Network,
|
||||
Paperclip,
|
||||
PackageCheck,
|
||||
Send,
|
||||
Settings,
|
||||
SlidersHorizontal,
|
||||
Train,
|
||||
@@ -50,6 +52,9 @@ import WarehouseDetailPage from "./pages/warehouses/WarehouseDetailPage";
|
||||
import WarehouseInventoryPage from "./pages/warehouses/WarehouseInventoryPage";
|
||||
import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage";
|
||||
import WarehouseDashboardPage from "./pages/warehouses/WarehouseDashboardPage";
|
||||
import LoadingQueuePage from "./pages/warehouses/LoadingQueuePage";
|
||||
import LoadedInventoryPage from "./pages/warehouses/LoadedInventoryPage";
|
||||
import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage";
|
||||
|
||||
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
{
|
||||
@@ -137,6 +142,21 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
href: "/dashboard/warehouse-inventory",
|
||||
icon: <Package />,
|
||||
},
|
||||
{
|
||||
label: "Loading Queue",
|
||||
href: "/dashboard/loading-queue",
|
||||
icon: <Truck />,
|
||||
},
|
||||
{
|
||||
label: "Loaded Inventory",
|
||||
href: "/dashboard/loaded-inventory",
|
||||
icon: <PackageCheck />,
|
||||
},
|
||||
{
|
||||
label: "Dispatch Queue",
|
||||
href: "/dashboard/dispatch-queue",
|
||||
icon: <Send />,
|
||||
},
|
||||
{
|
||||
label: "Inventory Inquiry",
|
||||
href: "/dashboard/inventory-inquiry",
|
||||
@@ -289,6 +309,9 @@ const App = () => {
|
||||
<Route path="warehouses" element={<WarehouseListPage />} />
|
||||
<Route path="warehouses/:id" element={<WarehouseDetailPage />} />
|
||||
<Route path="warehouse-inventory" element={<WarehouseInventoryPage />} />
|
||||
<Route path="loading-queue" element={<LoadingQueuePage />} />
|
||||
<Route path="loaded-inventory" element={<LoadedInventoryPage />} />
|
||||
<Route path="dispatch-queue" element={<DispatchQueuePage />} />
|
||||
<Route path="inventory-inquiry" element={<InventoryInquiryPage />} />
|
||||
<Route path="warehouse-dashboard" element={<WarehouseDashboardPage />} />
|
||||
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import type { CSSProperties, ReactElement } from 'react';
|
||||
|
||||
export type FreightVisualVariant =
|
||||
| 'train'
|
||||
| 'warehouse'
|
||||
| 'container'
|
||||
| 'wagon'
|
||||
| 'cargo'
|
||||
| 'route'
|
||||
| 'empty';
|
||||
|
||||
interface FreightVisualProps {
|
||||
variant: FreightVisualVariant;
|
||||
/** Pixel size of the (square) artwork. Defaults to 64. */
|
||||
size?: number;
|
||||
style?: CSSProperties;
|
||||
className?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight railway/freight illustrations — minimal, enterprise-logistics style.
|
||||
* Inline SVG (no network cost) using EDR brand colors: green, yellow, dark text,
|
||||
* light gray. Purposely low-contrast so it never overpowers tables/forms.
|
||||
*
|
||||
* Use only in page headers, empty states, and KPI cards.
|
||||
*/
|
||||
const EDR = {
|
||||
green: '#2F9E44',
|
||||
greenSoft: '#D3F9D8',
|
||||
yellow: '#F59F00',
|
||||
yellowSoft: '#FFF3BF',
|
||||
dark: '#343A40',
|
||||
gray: '#ADB5BD',
|
||||
graySoft: '#E9ECEF',
|
||||
};
|
||||
|
||||
function Train() {
|
||||
return (
|
||||
<>
|
||||
{/* track */}
|
||||
<rect x="2" y="52" width="60" height="3" rx="1.5" fill={EDR.graySoft} />
|
||||
{/* locomotive body */}
|
||||
<rect x="6" y="20" width="26" height="26" rx="3" fill={EDR.green} />
|
||||
<rect x="10" y="24" width="8" height="8" rx="1.5" fill={EDR.greenSoft} />
|
||||
<rect x="22" y="24" width="6" height="8" rx="1.5" fill={EDR.greenSoft} />
|
||||
{/* cab roof */}
|
||||
<rect x="9" y="15" width="14" height="6" rx="2" fill={EDR.dark} />
|
||||
{/* wagon */}
|
||||
<rect x="36" y="26" width="22" height="20" rx="2.5" fill={EDR.yellow} />
|
||||
<rect x="40" y="30" width="14" height="6" rx="1" fill={EDR.yellowSoft} />
|
||||
{/* wheels */}
|
||||
{[12, 24, 42, 52].map((cx) => (
|
||||
<circle key={cx} cx={cx} cy={48} r={3.2} fill={EDR.dark} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Warehouse() {
|
||||
return (
|
||||
<>
|
||||
{/* ground */}
|
||||
<rect x="4" y="50" width="56" height="3" rx="1.5" fill={EDR.graySoft} />
|
||||
{/* roof */}
|
||||
<path d="M10 24 L32 12 L54 24 Z" fill={EDR.green} />
|
||||
{/* body */}
|
||||
<rect x="14" y="24" width="36" height="26" rx="1.5" fill={EDR.greenSoft} />
|
||||
{/* shutter door */}
|
||||
<rect x="26" y="32" width="12" height="18" rx="1" fill={EDR.dark} />
|
||||
<rect x="27.5" y="35" width="9" height="2" fill={EDR.gray} />
|
||||
<rect x="27.5" y="39" width="9" height="2" fill={EDR.gray} />
|
||||
<rect x="27.5" y="43" width="9" height="2" fill={EDR.gray} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Container() {
|
||||
return (
|
||||
<>
|
||||
{/* stacked containers */}
|
||||
<rect x="8" y="34" width="22" height="16" rx="1.5" fill={EDR.green} />
|
||||
<rect x="34" y="34" width="22" height="16" rx="1.5" fill={EDR.yellow} />
|
||||
<rect x="20" y="16" width="24" height="16" rx="1.5" fill={EDR.dark} />
|
||||
{/* corrugation lines */}
|
||||
{[12, 16, 20, 24].map((x) => (
|
||||
<rect key={`a${x}`} x={x} y="37" width="1.5" height="10" fill={EDR.greenSoft} />
|
||||
))}
|
||||
{[38, 42, 46, 50].map((x) => (
|
||||
<rect key={`b${x}`} x={x} y="37" width="1.5" height="10" fill={EDR.yellowSoft} />
|
||||
))}
|
||||
{[25, 29, 33, 37].map((x) => (
|
||||
<rect key={`c${x}`} x={x} y="19" width="1.5" height="10" fill={EDR.gray} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Wagon() {
|
||||
return (
|
||||
<>
|
||||
<rect x="4" y="50" width="56" height="3" rx="1.5" fill={EDR.graySoft} />
|
||||
{/* flatbed wagon */}
|
||||
<rect x="8" y="38" width="48" height="8" rx="1.5" fill={EDR.dark} />
|
||||
{/* cargo on wagon */}
|
||||
<rect x="14" y="22" width="16" height="16" rx="1.5" fill={EDR.green} />
|
||||
<rect x="34" y="26" width="16" height="12" rx="1.5" fill={EDR.yellow} />
|
||||
{/* wheels */}
|
||||
{[16, 26, 40, 50].map((cx) => (
|
||||
<circle key={cx} cx={cx} cy={48} r={3.2} fill={EDR.dark} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Cargo() {
|
||||
return (
|
||||
<>
|
||||
{/* cargo boxes */}
|
||||
<rect x="12" y="30" width="20" height="20" rx="2" fill={EDR.yellow} />
|
||||
<rect x="34" y="34" width="18" height="16" rx="2" fill={EDR.green} />
|
||||
{/* tape */}
|
||||
<rect x="21" y="30" width="2" height="20" fill={EDR.yellowSoft} />
|
||||
<rect x="12" y="38" width="20" height="2" fill={EDR.yellowSoft} />
|
||||
<rect x="42" y="34" width="2" height="16" fill={EDR.greenSoft} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Route() {
|
||||
return (
|
||||
<>
|
||||
{/* track line with stations */}
|
||||
<rect x="6" y="31" width="52" height="2" rx="1" fill={EDR.gray} />
|
||||
{[10, 22, 34, 46, 58].map((x) => (
|
||||
<rect key={x} x={x - 0.5} y="28" width="1.5" height="8" fill={EDR.graySoft} />
|
||||
))}
|
||||
<circle cx="10" cy="32" r="5" fill={EDR.green} />
|
||||
<circle cx="54" cy="32" r="5" fill={EDR.yellow} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Empty() {
|
||||
return (
|
||||
<>
|
||||
{/* empty open box */}
|
||||
<path d="M14 28 L32 22 L50 28 L50 30 L32 24 L14 30 Z" fill={EDR.gray} />
|
||||
<path d="M14 30 L32 36 L32 50 L14 44 Z" fill={EDR.graySoft} />
|
||||
<path d="M50 30 L32 36 L32 50 L50 44 Z" fill={EDR.graySoft} />
|
||||
<path d="M14 30 L32 24 L50 30 L32 36 Z" fill="#F8F9FA" />
|
||||
<circle cx="32" cy="14" r="2.5" fill={EDR.yellow} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const VARIANTS: Record<FreightVisualVariant, () => ReactElement> = {
|
||||
train: Train,
|
||||
warehouse: Warehouse,
|
||||
container: Container,
|
||||
wagon: Wagon,
|
||||
cargo: Cargo,
|
||||
route: Route,
|
||||
empty: Empty,
|
||||
};
|
||||
|
||||
export function FreightVisual({ variant, size = 64, style, className, title }: FreightVisualProps) {
|
||||
const Art = VARIANTS[variant];
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 64 64"
|
||||
fill="none"
|
||||
role="img"
|
||||
aria-label={title ?? `${variant} illustration`}
|
||||
className={className}
|
||||
style={style}
|
||||
>
|
||||
{title ? <title>{title}</title> : null}
|
||||
<Art />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -4,12 +4,12 @@ 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 { LoadInventoryModal } from './LoadInventoryModal';
|
||||
import { MoveInventoryModal } from './MoveInventoryModal';
|
||||
import { ReserveInventoryModal } from './ReserveInventoryModal';
|
||||
import { WarehouseInventoryTable } from './WarehouseInventoryTable';
|
||||
@@ -26,11 +26,11 @@ export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [moveItem, setMoveItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [reserveItem, setReserveItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [loadItem, setLoadItem] = 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) => {
|
||||
@@ -55,7 +55,8 @@ export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps
|
||||
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');
|
||||
setLoadItem(item);
|
||||
return;
|
||||
case 'dispatch':
|
||||
return runDirect(item, () => dispatchMutation.mutateAsync(item.id), 'Inventory dispatched');
|
||||
default:
|
||||
@@ -87,6 +88,7 @@ export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps
|
||||
onClose={() => setReserveItem(null)}
|
||||
item={reserveItem}
|
||||
/>
|
||||
<LoadInventoryModal opened={Boolean(loadItem)} onClose={() => setLoadItem(null)} item={loadItem} />
|
||||
<InventoryHistoryModal
|
||||
opened={Boolean(historyItem)}
|
||||
onClose={() => setHistoryItem(null)}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Alert, Button, Group, Modal, NumberInput, Stack, Text, Textarea } from '@mantine/core';
|
||||
import { Info } from 'lucide-react';
|
||||
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useLoadInventory } from '@/hooks/useWarehouses';
|
||||
import type { WarehouseInventoryItem } from '@/types/warehouse';
|
||||
import { WagonSelect } from './WagonSelect';
|
||||
import { extractErrorMessage } from './options';
|
||||
|
||||
interface LoadInventoryModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
item: WarehouseInventoryItem | null;
|
||||
}
|
||||
|
||||
/** Load READY_FOR_LOADING inventory onto a wagon (creates a loading record). */
|
||||
export function LoadInventoryModal({ opened, onClose, item }: LoadInventoryModalProps) {
|
||||
const { toast } = useToast();
|
||||
const loadMutation = useLoadInventory();
|
||||
const [wagonId, setWagonId] = useState('');
|
||||
const [loadedWeight, setLoadedWeight] = useState<number | ''>('');
|
||||
const [notes, setNotes] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
setWagonId('');
|
||||
setLoadedWeight(item?.weight ?? '');
|
||||
setNotes('');
|
||||
}
|
||||
}, [opened, item]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!item) return;
|
||||
if (!wagonId.trim()) {
|
||||
toast({ variant: 'destructive', title: 'Select a wagon' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await loadMutation.mutateAsync({
|
||||
id: item.id,
|
||||
payload: {
|
||||
wagonId: wagonId.trim(),
|
||||
loadedWeight: loadedWeight === '' ? undefined : Number(loadedWeight),
|
||||
notes: notes.trim() || undefined,
|
||||
},
|
||||
});
|
||||
toast({ title: 'Inventory loaded', description: 'Status set to LOADED' });
|
||||
onClose();
|
||||
} catch (error) {
|
||||
toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Load onto wagon" centered size="md">
|
||||
<Stack gap="md">
|
||||
<Alert icon={<Info size={16} />} color="blue" variant="light">
|
||||
<Text size="sm">
|
||||
The item must be <b>READY_FOR_LOADING</b> and the wagon must be available or already on a
|
||||
train schedule.
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<WagonSelect label="Wagon" required value={wagonId} onChange={setWagonId} />
|
||||
|
||||
<NumberInput
|
||||
label="Loaded weight (kg)"
|
||||
placeholder="Defaults to item weight"
|
||||
min={0}
|
||||
value={loadedWeight}
|
||||
onChange={(v) => setLoadedWeight(v === '' ? '' : Number(v))}
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label="Notes"
|
||||
placeholder="Optional"
|
||||
autosize
|
||||
minRows={2}
|
||||
value={notes}
|
||||
onChange={(e) => {
|
||||
const v = e.currentTarget.value;
|
||||
setNotes(v);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={onClose} disabled={loadMutation.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} loading={loadMutation.isPending}>
|
||||
Load
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Center, Stack, Text } from '@mantine/core';
|
||||
|
||||
import { FreightVisual, type FreightVisualVariant } from './FreightVisual';
|
||||
|
||||
interface VisualEmptyStateProps {
|
||||
variant?: FreightVisualVariant;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
}
|
||||
|
||||
/** Friendly empty state with a small freight illustration. */
|
||||
export function VisualEmptyState({
|
||||
variant = 'empty',
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
}: VisualEmptyStateProps) {
|
||||
return (
|
||||
<Center py="xl">
|
||||
<Stack align="center" gap="xs" maw={360}>
|
||||
<FreightVisual variant={variant} size={88} style={{ opacity: 0.85 }} />
|
||||
<Text fw={600} ta="center">
|
||||
{title}
|
||||
</Text>
|
||||
{description && (
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
{action}
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Select } from '@mantine/core';
|
||||
|
||||
import { useLoadableWagons } from '@/hooks/useWarehouses';
|
||||
|
||||
interface WagonSelectProps {
|
||||
value: string;
|
||||
onChange: (wagonId: string) => void;
|
||||
label?: string;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
/** Searchable wagon picker. Lists wagons that are loadable (read-only from scheduling). */
|
||||
export function WagonSelect({ value, onChange, label = 'Wagon', required }: WagonSelectProps) {
|
||||
const { data, isLoading } = useLoadableWagons();
|
||||
|
||||
const options = (data ?? []).map((w) => ({
|
||||
value: w.id,
|
||||
label: `${w.wagonNumber} · ${w.status}`,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Select
|
||||
label={label}
|
||||
required={required}
|
||||
searchable
|
||||
clearable
|
||||
data={options}
|
||||
value={value || null}
|
||||
onChange={(v) => onChange(v ?? '')}
|
||||
placeholder={isLoading ? 'Loading wagons…' : 'Search wagon number'}
|
||||
nothingFoundMessage="No loadable wagons found"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Box, Group, Stack, Text, Title } from '@mantine/core';
|
||||
|
||||
import { FreightVisual, type FreightVisualVariant } from './FreightVisual';
|
||||
|
||||
interface WarehouseHeroProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
/** Primary illustration shown on the right of the hero. */
|
||||
variant?: FreightVisualVariant;
|
||||
/** Optional secondary illustration tucked behind the primary. */
|
||||
secondaryVariant?: FreightVisualVariant;
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Page header hero with a lightweight freight illustration. Low-contrast,
|
||||
* minimal — sets context without overpowering the data below.
|
||||
*/
|
||||
export function WarehouseHero({
|
||||
title,
|
||||
subtitle,
|
||||
variant = 'warehouse',
|
||||
secondaryVariant,
|
||||
actions,
|
||||
}: WarehouseHeroProps) {
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #F8F9FA 0%, #F1F3F5 100%)',
|
||||
border: '1px solid #E9ECEF',
|
||||
borderRadius: 'var(--mantine-radius-md)',
|
||||
padding: 'var(--mantine-spacing-lg)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap" align="center">
|
||||
<Stack gap={4}>
|
||||
<Title order={3}>{title}</Title>
|
||||
{subtitle && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{subtitle}
|
||||
</Text>
|
||||
)}
|
||||
{actions && <Group mt="sm">{actions}</Group>}
|
||||
</Stack>
|
||||
|
||||
<Group gap="xs" wrap="nowrap" style={{ opacity: 0.95 }}>
|
||||
{secondaryVariant && (
|
||||
<FreightVisual variant={secondaryVariant} size={56} style={{ opacity: 0.7 }} />
|
||||
)}
|
||||
<FreightVisual variant={variant} size={84} />
|
||||
</Group>
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState } from 'react';
|
||||
import { Badge, Button, Card, Divider, Group, Stack, Text } from '@mantine/core';
|
||||
import { PackagePlus, Warehouse as WarehouseIcon } from 'lucide-react';
|
||||
import { PackagePlus, Train as TrainIcon, Warehouse as WarehouseIcon } from 'lucide-react';
|
||||
|
||||
import { useWarehouseInventory } from '@/hooks/useWarehouses';
|
||||
import { useBookingSchedule, useWarehouseInventory } from '@/hooks/useWarehouses';
|
||||
import { InventoryStatusBadge } from './badges';
|
||||
import { FreightVisual } from './FreightVisual';
|
||||
import { formatDate } from './options';
|
||||
import { ReceiveInventoryModal } from './ReceiveInventoryModal';
|
||||
|
||||
@@ -28,9 +29,14 @@ function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
export function WarehouseInfoCard({ bookingId, bookingReference }: WarehouseInfoCardProps) {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const { data, isLoading } = useWarehouseInventory({ bookingId });
|
||||
const { data: scheduleView } = useBookingSchedule(bookingId);
|
||||
|
||||
const items = data ?? [];
|
||||
const latest = items[0];
|
||||
const schedule = scheduleView?.schedule;
|
||||
const wagon = scheduleView?.wagon;
|
||||
const isLoadedOrDispatched =
|
||||
latest?.status === 'LOADED' || latest?.status === 'DISPATCHED';
|
||||
|
||||
return (
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
@@ -65,9 +71,52 @@ export function WarehouseInfoCard({ bookingId, bookingReference }: WarehouseInfo
|
||||
<Row label="Inventory Status" value={<InventoryStatusBadge status={latest.status} />} />
|
||||
<Row label="Arrived At" value={formatDate(latest.arrivedAt)} />
|
||||
<Row label="Ready For Loading At" value={formatDate(latest.readyForLoadingAt)} />
|
||||
{isLoadedOrDispatched && (
|
||||
<>
|
||||
<Row
|
||||
label="Wagon"
|
||||
value={wagon?.wagonNumber ?? '—'}
|
||||
/>
|
||||
<Row label="Loaded At" value={formatDate(latest.loadedAt)} />
|
||||
<Row label="Dispatched At" value={formatDate(latest.dispatchedAt)} />
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{schedule && (
|
||||
<>
|
||||
<Divider
|
||||
label={
|
||||
<Group gap={6}>
|
||||
<TrainIcon size={14} />
|
||||
<Text size="xs" c="dimmed">
|
||||
Train schedule (read-only)
|
||||
</Text>
|
||||
</Group>
|
||||
}
|
||||
labelPosition="left"
|
||||
/>
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
<FreightVisual variant="train" size={40} />
|
||||
<Stack gap="xs" style={{ flex: 1 }}>
|
||||
<Row
|
||||
label="Departure Status"
|
||||
value={
|
||||
<Badge variant="light" color="blue" size="sm">
|
||||
{schedule.status}
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
<Row label="Scheduled Departure" value={formatDate(schedule.scheduledDepartureDate)} />
|
||||
<Row label="Scheduled Arrival" value={formatDate(schedule.scheduledArrivalDate)} />
|
||||
{wagon?.wagonNumber && <Row label="Assigned Wagon" value={wagon.wagonNumber} />}
|
||||
{wagon?.sequenceNo != null && <Row label="Wagon Position" value={`#${wagon.sequenceNo}`} />}
|
||||
</Stack>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<PackagePlus size={16} />}
|
||||
|
||||
@@ -18,3 +18,9 @@ export { ActivityTimeline } from './ActivityTimeline';
|
||||
export { InventoryHistoryModal } from './InventoryHistoryModal';
|
||||
export { InventoryWorkbench } from './InventoryWorkbench';
|
||||
export { BookingSelect } from './BookingSelect';
|
||||
export { WagonSelect } from './WagonSelect';
|
||||
export { LoadInventoryModal } from './LoadInventoryModal';
|
||||
export { FreightVisual } from './FreightVisual';
|
||||
export type { FreightVisualVariant } from './FreightVisual';
|
||||
export { WarehouseHero } from './WarehouseHero';
|
||||
export { VisualEmptyState } from './VisualEmptyState';
|
||||
|
||||
@@ -206,12 +206,19 @@ export const URL_CONSTANTS = {
|
||||
RESERVE: '/warehouse-inventory/reserve',
|
||||
READY_FOR_LOADING: '/warehouse-inventory/ready-for-loading',
|
||||
INQUIRY: '/warehouse-inventory/inquiry',
|
||||
LOADABLE_WAGONS: '/warehouse-inventory/loadable-wagons',
|
||||
BOOKING_SCHEDULE: (bookingId: string) => `/warehouse-inventory/booking/${bookingId}/schedule`,
|
||||
MOVE: (id: string) => `/warehouse-inventory/${id}/move`,
|
||||
MOVEMENTS: (id: string) => `/warehouse-inventory/${id}/movements`,
|
||||
ACTIVITY: (id: string) => `/warehouse-inventory/${id}/activity`,
|
||||
LOADINGS: (id: string) => `/warehouse-inventory/${id}/loadings`,
|
||||
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`,
|
||||
},
|
||||
|
||||
WAREHOUSE_LOADINGS: {
|
||||
BASE: '/warehouse-loadings',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { warehouseService } from '@/services/warehouse.service';
|
||||
import type {
|
||||
InventoryFilter,
|
||||
InventoryInquiryFilter,
|
||||
LoadInventoryPayload,
|
||||
MoveInventoryPayload,
|
||||
ReceiveInventoryPayload,
|
||||
ReserveInventoryPayload,
|
||||
@@ -145,6 +146,7 @@ function useInventoryMutation<TArgs>(fn: (args: TArgs) => Promise<unknown>) {
|
||||
mutationFn: fn,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
|
||||
qc.invalidateQueries({ queryKey: ['warehouse-loadings'] });
|
||||
qc.invalidateQueries({ queryKey: warehouseKeys.all });
|
||||
},
|
||||
});
|
||||
@@ -155,13 +157,41 @@ 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 useLoadInventory = () =>
|
||||
useInventoryMutation((args: { id: string; payload: LoadInventoryPayload }) =>
|
||||
warehouseService.load(args.id, args.payload),
|
||||
);
|
||||
export const useDispatchInventory = () => useInventoryMutation((id: string) => warehouseService.dispatch(id));
|
||||
export const useMoveInventory = () =>
|
||||
useInventoryMutation((args: { id: string; payload: MoveInventoryPayload }) =>
|
||||
warehouseService.move(args.id, args.payload),
|
||||
);
|
||||
|
||||
// ── Loading (Batch 3) ────────────────────────────────────────────────────────
|
||||
|
||||
export function useLoadableWagons(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse', 'loadable-wagons'],
|
||||
queryFn: () => warehouseService.loadableWagons().then((r) => r.data),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useWarehouseLoadings(params?: { bookingId?: string; wagonId?: string }) {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-loadings', params ?? {}],
|
||||
queryFn: () => warehouseService.loadings(params).then((r) => r.data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useBookingSchedule(bookingId?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse', 'booking-schedule', bookingId ?? ''],
|
||||
queryFn: () => warehouseService.bookingSchedule(bookingId as string).then((r) => r.data),
|
||||
enabled: Boolean(bookingId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useInventoryMovements(id?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-inventory', id, 'movements'],
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Card, Container, Stack } from '@mantine/core';
|
||||
|
||||
import Breadcrumbs from '@/components/ui/Breadcrumbs';
|
||||
import { InventoryWorkbench, VisualEmptyState, WarehouseHero } from '@/components/warehouses';
|
||||
import { useWarehouseInventory } from '@/hooks/useWarehouses';
|
||||
|
||||
/** Items that are LOADED and awaiting dispatch (train departure). */
|
||||
export default function DispatchQueuePage() {
|
||||
const { data, isLoading } = useWarehouseInventory({ status: 'LOADED' });
|
||||
const items = data ?? [];
|
||||
|
||||
return (
|
||||
<Container size="xxl" py="lg">
|
||||
<Breadcrumbs items={[{ label: 'Dispatch queue' }]} />
|
||||
|
||||
<Stack gap="lg" mt="sm">
|
||||
<WarehouseHero
|
||||
variant="train"
|
||||
secondaryVariant="route"
|
||||
title="Dispatch Queue"
|
||||
subtitle="Loaded inventory awaiting train departure. Mark items dispatched once they leave."
|
||||
/>
|
||||
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
{!isLoading && items.length === 0 ? (
|
||||
<VisualEmptyState
|
||||
variant="train"
|
||||
title="Nothing to dispatch"
|
||||
description="Loaded items appear here, ready to mark as dispatched."
|
||||
/>
|
||||
) : (
|
||||
<InventoryWorkbench items={items} isLoading={isLoading} />
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { Button, Card, Center, Container, Group, Loader, Select, Stack, Text, Te
|
||||
import { Search } from 'lucide-react';
|
||||
|
||||
import Breadcrumbs from '@/components/ui/Breadcrumbs';
|
||||
import { WarehouseInquiryTable, inventoryStatusOptions } from '@/components/warehouses';
|
||||
import { VisualEmptyState, WarehouseInquiryTable, inventoryStatusOptions } from '@/components/warehouses';
|
||||
import {
|
||||
useInventoryInquiry,
|
||||
useWarehouseYards,
|
||||
@@ -139,6 +139,12 @@ export default function InventoryInquiryPage() {
|
||||
<Center py="xl">
|
||||
<Loader />
|
||||
</Center>
|
||||
) : results.length === 0 ? (
|
||||
<VisualEmptyState
|
||||
variant="container"
|
||||
title="No items found"
|
||||
description="Adjust your filters and search to locate cargo, containers or goods across the warehouse network."
|
||||
/>
|
||||
) : (
|
||||
<WarehouseInquiryTable results={results} />
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Badge, Card, Container, Group, Loader, Stack, Table, Text } from '@mantine/core';
|
||||
|
||||
import Breadcrumbs from '@/components/ui/Breadcrumbs';
|
||||
import { FreightVisual, VisualEmptyState, WarehouseHero, formatDate, formatNumber } from '@/components/warehouses';
|
||||
import { useWarehouseLoadings } from '@/hooks/useWarehouses';
|
||||
|
||||
/** Record of every inventory item loaded onto a wagon. */
|
||||
export default function LoadedInventoryPage() {
|
||||
const { data, isLoading } = useWarehouseLoadings();
|
||||
const loadings = data ?? [];
|
||||
|
||||
return (
|
||||
<Container size="xxl" py="lg">
|
||||
<Breadcrumbs items={[{ label: 'Loaded inventory' }]} />
|
||||
|
||||
<Stack gap="lg" mt="sm">
|
||||
<WarehouseHero
|
||||
variant="container"
|
||||
secondaryVariant="wagon"
|
||||
title="Loaded Inventory"
|
||||
subtitle="Items loaded onto wagons, with their loading records."
|
||||
/>
|
||||
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
) : loadings.length === 0 ? (
|
||||
<VisualEmptyState
|
||||
variant="container"
|
||||
title="No loaded inventory yet"
|
||||
description="Once items are loaded onto a wagon, their records show here."
|
||||
/>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={760}>
|
||||
<Table verticalSpacing="sm" highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Wagon</Table.Th>
|
||||
<Table.Th>Warehouse</Table.Th>
|
||||
<Table.Th>Zone</Table.Th>
|
||||
<Table.Th>Loaded Weight (kg)</Table.Th>
|
||||
<Table.Th>Loaded At</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{loadings.map((l) => (
|
||||
<Table.Tr key={l.id}>
|
||||
<Table.Td>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<FreightVisual variant="wagon" size={22} />
|
||||
<Text fw={600} size="sm">
|
||||
{l.wagonNumber ?? l.wagonId.slice(0, 8)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{l.inventory?.warehouse
|
||||
? `${l.inventory.warehouse.name} (${l.inventory.warehouse.code})`
|
||||
: '—'}
|
||||
</Table.Td>
|
||||
<Table.Td>{l.inventory?.zone ? l.inventory.zone.name : '—'}</Table.Td>
|
||||
<Table.Td>{formatNumber(l.loadedWeight)}</Table.Td>
|
||||
<Table.Td>{formatDate(l.loadedAt)}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
variant="light"
|
||||
color={l.inventory?.status === 'DISPATCHED' ? 'green' : 'teal'}
|
||||
size="sm"
|
||||
>
|
||||
{l.inventory?.status ?? 'LOADED'}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Card, Container, Stack } from '@mantine/core';
|
||||
|
||||
import Breadcrumbs from '@/components/ui/Breadcrumbs';
|
||||
import { InventoryWorkbench, VisualEmptyState, WarehouseHero } from '@/components/warehouses';
|
||||
import { useWarehouseInventory } from '@/hooks/useWarehouses';
|
||||
|
||||
/** Items that are READY_FOR_LOADING — load them onto a wagon from here. */
|
||||
export default function LoadingQueuePage() {
|
||||
const { data, isLoading } = useWarehouseInventory({ status: 'READY_FOR_LOADING' });
|
||||
const items = data ?? [];
|
||||
|
||||
return (
|
||||
<Container size="xxl" py="lg">
|
||||
<Breadcrumbs items={[{ label: 'Loading queue' }]} />
|
||||
|
||||
<Stack gap="lg" mt="sm">
|
||||
<WarehouseHero
|
||||
variant="wagon"
|
||||
secondaryVariant="cargo"
|
||||
title="Loading Queue"
|
||||
subtitle="Inventory that is ready for loading onto a wagon."
|
||||
/>
|
||||
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
{!isLoading && items.length === 0 ? (
|
||||
<VisualEmptyState
|
||||
variant="wagon"
|
||||
title="Nothing waiting to load"
|
||||
description="Items appear here once they are marked Ready For Loading."
|
||||
/>
|
||||
) : (
|
||||
<InventoryWorkbench items={items} isLoading={isLoading} />
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Card, Center, Container, Group, Loader, SimpleGrid, Stack, Text, ThemeIcon, Title } from '@mantine/core';
|
||||
import { Card, Center, Container, Group, Loader, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core';
|
||||
import {
|
||||
ClipboardCheck,
|
||||
PackageCheck,
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
|
||||
import Breadcrumbs from '@/components/ui/Breadcrumbs';
|
||||
import { WarehouseHero } from '@/components/warehouses';
|
||||
import { useWarehouseDashboard } from '@/hooks/useWarehouses';
|
||||
import type { WarehouseDashboard } from '@/types/warehouse';
|
||||
|
||||
@@ -40,12 +41,12 @@ export default function WarehouseDashboardPage() {
|
||||
<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>
|
||||
<WarehouseHero
|
||||
variant="train"
|
||||
secondaryVariant="warehouse"
|
||||
title="Warehouse Dashboard"
|
||||
subtitle="Live overview of warehouse capacity and inventory lifecycle."
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<Center py="xl">
|
||||
|
||||
@@ -2,10 +2,13 @@ import { api as apiClient } from '../auth/http';
|
||||
|
||||
import { URL_CONSTANTS } from '@/constants/URLS';
|
||||
import type {
|
||||
BookingScheduleView,
|
||||
InventoryFilter,
|
||||
InventoryInquiryFilter,
|
||||
InventoryInquiryResult,
|
||||
InventoryMovement,
|
||||
LoadableWagon,
|
||||
LoadInventoryPayload,
|
||||
MoveInventoryPayload,
|
||||
ReceiveInventoryPayload,
|
||||
ReserveInventoryPayload,
|
||||
@@ -17,6 +20,7 @@ import type {
|
||||
WarehouseDashboard,
|
||||
WarehouseFilter,
|
||||
WarehouseInventoryItem,
|
||||
WarehouseLoading,
|
||||
WarehouseYard,
|
||||
WarehouseZone,
|
||||
} from '@/types/warehouse';
|
||||
@@ -80,14 +84,26 @@ export const warehouseService = {
|
||||
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)),
|
||||
load: (id: string, payload: LoadInventoryPayload) =>
|
||||
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.LOAD(id), payload),
|
||||
dispatch: (id: string) =>
|
||||
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.DISPATCH(id)),
|
||||
apiClient.patch<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)),
|
||||
|
||||
// ── Loading (Batch 3) ─────────────────────────────────────────────────────
|
||||
loadableWagons: () =>
|
||||
apiClient.get<LoadableWagon[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.LOADABLE_WAGONS),
|
||||
bookingSchedule: (bookingId: string) =>
|
||||
apiClient.get<BookingScheduleView>(URL_CONSTANTS.WAREHOUSE_INVENTORY.BOOKING_SCHEDULE(bookingId)),
|
||||
inventoryLoadings: (id: string) =>
|
||||
apiClient.get<WarehouseLoading[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.LOADINGS(id)),
|
||||
loadings: (params?: { bookingId?: string; wagonId?: string }) =>
|
||||
apiClient.get<WarehouseLoading[]>(URL_CONSTANTS.WAREHOUSE_LOADINGS.BASE, {
|
||||
params: cleanParams(params ?? {}),
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -172,6 +172,55 @@ export interface WarehouseDashboard {
|
||||
dispatched: number;
|
||||
}
|
||||
|
||||
// ── Loading (Batch 3) ────────────────────────────────────────────────────────
|
||||
|
||||
export interface WarehouseLoading {
|
||||
id: string;
|
||||
warehouseInventoryId: string;
|
||||
bookingId: string | null;
|
||||
wagonId: string;
|
||||
wagonNumber?: string | null;
|
||||
loadedAt: string;
|
||||
loadedBy: string | null;
|
||||
loadedWeight: number | null;
|
||||
notes: string | null;
|
||||
inventory?: WarehouseInventoryItem | null;
|
||||
}
|
||||
|
||||
export interface LoadInventoryPayload {
|
||||
wagonId: string;
|
||||
loadedWeight?: number;
|
||||
loadedBy?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
/** Read-only wagon view exposed by the scheduling facade. */
|
||||
export interface LoadableWagon {
|
||||
id: string;
|
||||
wagonNumber: string;
|
||||
status: string;
|
||||
trainId: string | null;
|
||||
}
|
||||
|
||||
/** Read-only schedule + wagon + departure status for a booking. */
|
||||
export interface BookingScheduleView {
|
||||
schedule: {
|
||||
id: string;
|
||||
status: string;
|
||||
scheduledDepartureDate: string | null;
|
||||
scheduledArrivalDate: string | null;
|
||||
originStationId: string | null;
|
||||
destinationStationId: string | null;
|
||||
} | null;
|
||||
wagon: {
|
||||
wagonId: string | null;
|
||||
wagonNumber: string | null;
|
||||
sequenceNo: number | null;
|
||||
allocatedWeightTons: number | null;
|
||||
} | null;
|
||||
departureStatus: string | null;
|
||||
}
|
||||
|
||||
export interface MoveInventoryPayload {
|
||||
warehouseId: string;
|
||||
yardId: string;
|
||||
|
||||
Reference in New Issue
Block a user