From 95c6a7143859c4bf11329f969e26c3da6da80dc8 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 12 Jun 2026 12:44:40 +0000 Subject: [PATCH] =?UTF-8?q?feat(warehouse):=20batch=203=20=E2=80=94=20load?= =?UTF-8?q?ing,=20dispatch=20&=20train-departure=20visibility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../1790000000002-WarehouseBatch3.ts | 44 +++++ .../warehouses/dto/load-inventory.dto.ts | 25 +++ .../entities/warehouse-loading.entity.ts | 46 +++++ .../warehouses/scheduling-read.facade.ts | 118 +++++++++++ .../warehouse-inventory.controller.ts | 37 +++- .../warehouses/warehouse-inventory.service.ts | 121 +++++++++++- .../warehouse-loading.repository.ts | 13 ++ .../warehouse-loadings.controller.ts | 17 ++ .../modules/warehouses/warehouses.module.ts | 8 + apps/edr-freight-web/backoffice/src/App.tsx | 23 +++ .../components/warehouses/FreightVisual.tsx | 184 ++++++++++++++++++ .../warehouses/InventoryWorkbench.tsx | 8 +- .../warehouses/LoadInventoryModal.tsx | 98 ++++++++++ .../warehouses/VisualEmptyState.tsx | 36 ++++ .../src/components/warehouses/WagonSelect.tsx | 34 ++++ .../components/warehouses/WarehouseHero.tsx | 57 ++++++ .../warehouses/WarehouseInfoCard.tsx | 53 ++++- .../src/components/warehouses/index.ts | 6 + .../backoffice/src/constants/URLS.ts | 7 + .../backoffice/src/hooks/useWarehouses.ts | 32 ++- .../pages/warehouses/DispatchQueuePage.tsx | 38 ++++ .../pages/warehouses/InventoryInquiryPage.tsx | 8 +- .../pages/warehouses/LoadedInventoryPage.tsx | 86 ++++++++ .../src/pages/warehouses/LoadingQueuePage.tsx | 38 ++++ .../warehouses/WarehouseDashboardPage.tsx | 15 +- .../src/services/warehouse.service.ts | 22 ++- .../backoffice/src/types/warehouse.ts | 49 +++++ 27 files changed, 1193 insertions(+), 30 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1790000000002-WarehouseBatch3.ts create mode 100644 apps/edr-freight-api/src/modules/warehouses/dto/load-inventory.dto.ts create mode 100644 apps/edr-freight-api/src/modules/warehouses/entities/warehouse-loading.entity.ts create mode 100644 apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts create mode 100644 apps/edr-freight-api/src/modules/warehouses/warehouse-loading.repository.ts create mode 100644 apps/edr-freight-api/src/modules/warehouses/warehouse-loadings.controller.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/warehouses/FreightVisual.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/warehouses/LoadInventoryModal.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/warehouses/VisualEmptyState.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/warehouses/WagonSelect.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseHero.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/warehouses/DispatchQueuePage.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/warehouses/LoadedInventoryPage.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/warehouses/LoadingQueuePage.tsx diff --git a/apps/edr-freight-api/src/migrations/1790000000002-WarehouseBatch3.ts b/apps/edr-freight-api/src/migrations/1790000000002-WarehouseBatch3.ts new file mode 100644 index 000000000..4dcc9329c --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1790000000002-WarehouseBatch3.ts @@ -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 { + 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 { + await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_loadings;`); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/load-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/load-inventory.dto.ts new file mode 100644 index 000000000..063bb7d1d --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/load-inventory.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-loading.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-loading.entity.ts new file mode 100644 index 000000000..5f6952aec --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-loading.entity.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts b/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts new file mode 100644 index 000000000..de5a791c1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts @@ -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 { + 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 { + 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 { + 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 { + 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, + }; + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index 58b125209..b05475dab 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -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); } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 34ebc2171..f50089c1f 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -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 { - 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 { + 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> { + 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(); + 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 { + return this.loadingRepository.findAll({ + where: { warehouseInventoryId: inventoryId }, + order: { loadedAt: 'DESC' }, }); } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-loading.repository.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-loading.repository.ts new file mode 100644 index 000000000..45773c0f6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-loading.repository.ts @@ -0,0 +1,13 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { WarehouseLoading } from './entities/warehouse-loading.entity'; + +@Injectable() +export class WarehouseLoadingRepository extends BaseRepository { + constructor(@InjectRepository(WarehouseLoading) repository: Repository) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-loadings.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-loadings.controller.ts new file mode 100644 index 000000000..aab8e18b2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-loadings.controller.ts @@ -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 }); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts index 72719a620..527855aab 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts @@ -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, diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index c71f85aff..5b457bd64 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -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: , }, + { + label: "Loading Queue", + href: "/dashboard/loading-queue", + icon: , + }, + { + label: "Loaded Inventory", + href: "/dashboard/loaded-inventory", + icon: , + }, + { + label: "Dispatch Queue", + href: "/dashboard/dispatch-queue", + icon: , + }, { label: "Inventory Inquiry", href: "/dashboard/inventory-inquiry", @@ -289,6 +309,9 @@ const App = () => { } /> } /> } /> + } /> + } /> + } /> } /> } /> diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/FreightVisual.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/FreightVisual.tsx new file mode 100644 index 000000000..6d5f50a99 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/FreightVisual.tsx @@ -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 */} + + {/* locomotive body */} + + + + {/* cab roof */} + + {/* wagon */} + + + {/* wheels */} + {[12, 24, 42, 52].map((cx) => ( + + ))} + + ); +} + +function Warehouse() { + return ( + <> + {/* ground */} + + {/* roof */} + + {/* body */} + + {/* shutter door */} + + + + + + ); +} + +function Container() { + return ( + <> + {/* stacked containers */} + + + + {/* corrugation lines */} + {[12, 16, 20, 24].map((x) => ( + + ))} + {[38, 42, 46, 50].map((x) => ( + + ))} + {[25, 29, 33, 37].map((x) => ( + + ))} + + ); +} + +function Wagon() { + return ( + <> + + {/* flatbed wagon */} + + {/* cargo on wagon */} + + + {/* wheels */} + {[16, 26, 40, 50].map((cx) => ( + + ))} + + ); +} + +function Cargo() { + return ( + <> + {/* cargo boxes */} + + + {/* tape */} + + + + + ); +} + +function Route() { + return ( + <> + {/* track line with stations */} + + {[10, 22, 34, 46, 58].map((x) => ( + + ))} + + + + ); +} + +function Empty() { + return ( + <> + {/* empty open box */} + + + + + + + ); +} + +const VARIANTS: Record 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 ( + + {title ? {title} : null} + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx index f1840687a..7c6f35180 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx @@ -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(null); const [moveItem, setMoveItem] = useState(null); const [reserveItem, setReserveItem] = useState(null); + const [loadItem, setLoadItem] = useState(null); const [historyItem, setHistoryItem] = useState(null); const storeMutation = useStoreInventory(); const readyMutation = useMarkReadyForLoading(); - const loadMutation = useLoadInventory(); const dispatchMutation = useDispatchInventory(); const runDirect = async (item: WarehouseInventoryItem, fn: () => Promise, 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} /> + setLoadItem(null)} item={loadItem} /> setHistoryItem(null)} diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/LoadInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/LoadInventoryModal.tsx new file mode 100644 index 000000000..8def582cb --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/LoadInventoryModal.tsx @@ -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(''); + 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 ( + + + } color="blue" variant="light"> + + The item must be READY_FOR_LOADING and the wagon must be available or already on a + train schedule. + + + + + + setLoadedWeight(v === '' ? '' : Number(v))} + /> + +