diff --git a/.pnpm-store/v11/.pnpm-needs-build-marker b/.pnpm-store/v11/.pnpm-needs-build-marker new file mode 100644 index 000000000..e69de29bb diff --git a/.pnpm-store/v11/.tmp/pnpm-11.1.1-1781268424966/package.json b/.pnpm-store/v11/.tmp/pnpm-11.1.1-1781268424966/package.json new file mode 100644 index 000000000..1d4a3e729 --- /dev/null +++ b/.pnpm-store/v11/.tmp/pnpm-11.1.1-1781268424966/package.json @@ -0,0 +1 @@ +{"dependencies":{"pnpm":"11.1.1"}} \ No newline at end of file diff --git a/.pnpm-store/v11/index.db b/.pnpm-store/v11/index.db new file mode 100644 index 000000000..8fdf9e7d3 Binary files /dev/null and b/.pnpm-store/v11/index.db differ diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 792a268ed..737eb33ff 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -61,7 +61,6 @@ import { ContainersModule } from './modules/container-management/containers.modu import { CargoesModule } from './modules/cargoes/cargoes.module'; import { RoutesModule } from './modules/routes/routes.module'; import { WarehousesModule } from './modules/warehouses/warehouses.module'; -import { FacilitiesModule } from './modules/facilities/facilities.module'; import { OverviewModule } from './modules/overview/overview.module'; import { VehiclesModule } from './modules/vehicles/vehicles.module'; import { DriversModule } from './modules/drivers/drivers.module'; @@ -123,7 +122,6 @@ import { LastMileModule } from './modules/last-mile/last-mile.module'; ContainersModule, CargoesModule, RoutesModule, - FacilitiesModule, WarehousesModule, OverviewModule, VehiclesModule, diff --git a/apps/edr-freight-api/src/common/derive-trade-direction.util.ts b/apps/edr-freight-api/src/common/derive-trade-direction.util.ts index e9e183b25..b8a6f8afb 100644 --- a/apps/edr-freight-api/src/common/derive-trade-direction.util.ts +++ b/apps/edr-freight-api/src/common/derive-trade-direction.util.ts @@ -7,13 +7,13 @@ export function deriveTradeDirection( originYard: YardLike, destinationYard: YardLike, ): ScheduleTradeDirection { - const originCountry = originYard.country?.trim(); - const destinationCountry = destinationYard.country?.trim(); + const originCountry = originYard.country?.trim().toLowerCase(); + const destinationCountry = destinationYard.country?.trim().toLowerCase(); - if (originCountry === 'Djibouti') { + if (originCountry === 'djibouti') { return 'IMPORT'; } - if (destinationCountry === 'Djibouti' && originCountry !== 'Djibouti') { + if (destinationCountry === 'djibouti' && originCountry !== 'djibouti') { return 'EXPORT'; } return 'DOMESTIC'; diff --git a/apps/edr-freight-api/src/modules/payment/payment.module.ts b/apps/edr-freight-api/src/modules/payment/payment.module.ts index e496bb867..a0a4cd707 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.module.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.module.ts @@ -1,4 +1,4 @@ -import { Module, forwardRef } from "@nestjs/common"; +import { DynamicModule, Module, forwardRef } from "@nestjs/common"; import { TypeOrmModule } from "@nestjs/typeorm"; import { HttpModule } from "@nestjs/axios"; import { ConfigModule, ConfigService } from "@nestjs/config"; @@ -24,13 +24,10 @@ import { PaymentRefundEntity } from "./entities/payment-refund.entity"; const FREIGHT_QUEUE = PAYMENT_QUEUES[PaymentServiceEnum.FREIGHT]; -@Module({ - imports: [ - HttpModule.register({ timeout: 10_000 }), - ConfigModule, - DropdownSettingsModule, - forwardRef(() => TrainSchedulingModule), - TypeOrmModule.forFeature([PaymentWebhookEventEntity, PaymentRefundEntity]), +function rabbitMQImport(): DynamicModule[] { + if (!process.env.PAYMENT_RABBITMQ_URL) return []; + + return [ RabbitMQModule.forRootAsync({ inject: [ConfigService], useFactory: (config: ConfigService) => ({ @@ -51,6 +48,17 @@ const FREIGHT_QUEUE = PAYMENT_QUEUES[PaymentServiceEnum.FREIGHT]; connectionInitOptions: { wait: false }, }), }), + ]; +} + +@Module({ + imports: [ + HttpModule.register({ timeout: 10_000 }), + ConfigModule, + DropdownSettingsModule, + forwardRef(() => TrainSchedulingModule), + TypeOrmModule.forFeature([PaymentWebhookEventEntity, PaymentRefundEntity]), + ...rabbitMQImport(), ], providers: [ PaymentRepository, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index 0c01dd219..08fc74172 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -436,7 +436,7 @@ export class TrainSchedulingController { return this.trainSchedulingService.cancelTrainSchedule(id); } - @Post("bulk/schedules/:id/cancel") + @Post('bulk/schedules/:id/cancel') @TrainSchedulingManage() @ApiOperation({ summary: "Cancel bulk train schedule" }) cancelBulkTrainSchedule(@Param("id", ParseUUIDPipe) id: string) { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 239640624..5a5c7630f 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -1,4 +1,4 @@ -import { +import { AllocationLoadType, SchedulingStatus, TrainCheckpointKind, @@ -931,6 +931,19 @@ export class TrainSchedulingService { }); } + await manager.query( + `UPDATE freight.bookings b + SET status = $2, + scheduling_status = $3 + FROM freight.train_schedule_bookings tsb + WHERE tsb.booking_id = b.id + AND tsb.train_schedule_id = $1 + AND tsb.deleted_at IS NULL + AND b.deleted_at IS NULL + AND b.status NOT IN ('DRAFT', 'REJECTED', 'CANCELLED', 'COMPLETED')`, + [scheduleId, 'IN_TRANSIT', SchedulingStatus.Dispatched], + ); + if (schedule.trainSet?.locomotiveId) { const loco = await manager .getRepository(Locomotive) diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.repository.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.repository.ts index 7851f9485..1080480fa 100644 --- a/apps/edr-freight-api/src/modules/vehicles/vehicles.repository.ts +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.repository.ts @@ -67,13 +67,12 @@ export class VehiclesRepository extends BaseRepository { }; } - async createVehicle(vehicleData: any): Promise { + async createVehicle(vehicleData: Partial): Promise { const vehicle = this.repository.create(vehicleData); - const vehicles = await this.repository.save(vehicle); - return vehicles?.[0] as Vehicle; + return this.repository.save(vehicle); } async updateVehicle(vehicle: Vehicle): Promise { - return (await this.repository.save(vehicle)) as Vehicle; + return this.repository.save(vehicle); } } diff --git a/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts b/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts index 42db52231..195b4932b 100644 --- a/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts +++ b/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts @@ -11,6 +11,8 @@ import { Yard } from '../../rule-engine/entities/yard.entity'; export const WAGON_STATUSES = [ WagonStatus.Available, WagonStatus.Assigned, + WagonStatus.ImportReady, + WagonStatus.ExportReady, WagonStatus.Maintenance, WagonStatus.Retired, ] as const; @@ -65,7 +67,7 @@ export class Wagon extends BaseEntity { @JoinColumn({ name: 'current_train_schedule_id' }) currentTrainSchedule?: TrainSchedule | null; - /** Fleet master consist grouping — separate from operational train_schedules. */ + /** Fleet master consist grouping — separate from operational train_schedules. */ @ManyToOne(() => Train, (train) => train.wagons, { onDelete: 'SET NULL' }) @JoinColumn({ name: 'train_id' }) train!: Train | null; diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts index c867eec3c..9f89e7c2c 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts @@ -12,6 +12,11 @@ export class FilterWarehouseInventoryDto { @IsUUID() warehouseId?: string; + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + facilityId?: string; + @ApiPropertyOptional({ format: 'uuid' }) @IsOptional() @IsUUID() @@ -51,4 +56,14 @@ export class FilterWarehouseInventoryDto { @IsOptional() @IsString() search?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + dateFrom?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + dateTo?: string; } diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/inquiry-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/inquiry-inventory.dto.ts index cba259d00..4de117064 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/inquiry-inventory.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/inquiry-inventory.dto.ts @@ -10,6 +10,11 @@ export class InquiryWarehouseInventoryDto { @ApiPropertyOptional() @IsOptional() @IsString() + bookingReference?: string; + + @ApiPropertyOptional({ description: 'Legacy alias for bookingReference' }) + @IsOptional() + @IsString() bookingNumber?: string; @ApiPropertyOptional() diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-allocation.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-allocation.service.ts index f110dfcf7..1cfbc4661 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-allocation.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-allocation.service.ts @@ -78,17 +78,13 @@ export class WarehouseAllocationService { /** Resolve a concrete warehouse/yard/zone for the given criteria, or null if none configured. */ async resolveLocation(criteria: AllocationCriteria): Promise { const rule = await this.findMatchingRule(criteria); - const yardCode = rule?.targetYardCode; + if (!rule) return null; - // Resolve yard (by rule code, else first available yard with a zone). + // Resolve yard by rule code. const [yard] = await this.dataSource.query( - yardCode - ? `SELECT y.id, y.warehouse_id AS "warehouseId", y.name FROM freight.warehouse_yards y - WHERE y.code = $1 AND y.deleted_at IS NULL LIMIT 1` - : `SELECT y.id, y.warehouse_id AS "warehouseId", y.name FROM freight.warehouse_yards y - JOIN freight.warehouse_zones z ON z.yard_id = y.id AND z.deleted_at IS NULL - WHERE y.deleted_at IS NULL ORDER BY y.created_at ASC LIMIT 1`, - yardCode ? [yardCode] : [], + `SELECT y.id, y.warehouse_id AS "warehouseId", y.name FROM freight.warehouse_yards y + WHERE y.code = $1 AND y.deleted_at IS NULL LIMIT 1`, + [rule.targetYardCode], ); if (!yard) return null; diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-dashboard.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-dashboard.service.ts index fcc09f668..0bbcdee48 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-dashboard.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-dashboard.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { DataSource, IsNull } from 'typeorm'; +import { DataSource, FindManyOptions, IsNull, ObjectLiteral, Repository } from 'typeorm'; import { Warehouse } from './entities/warehouse.entity'; import { WarehouseInventory } from './entities/warehouse-inventory.entity'; @@ -26,6 +26,29 @@ export interface WarehouseDashboard { export class WarehouseDashboardService { constructor(private readonly dataSource: DataSource) {} + private async safeCount( + repo: Repository, + options?: FindManyOptions, + ): Promise { + try { + return await repo.count(options); + } catch { + return 0; + } + } + + private async safeReceivedToday(startOfToday: Date): Promise { + try { + return await this.dataSource + .getRepository(WarehouseInventory) + .createQueryBuilder('inv') + .where('inv.arrived_at >= :start', { start: startOfToday }) + .getCount(); + } catch { + return 0; + } + } + async getDashboard(): Promise { const warehouseRepo = this.dataSource.getRepository(Warehouse); const inventoryRepo = this.dataSource.getRepository(WarehouseInventory); @@ -47,21 +70,18 @@ export class WarehouseDashboardService { delivered, receivedToday, ] = await Promise.all([ - warehouseRepo.count(), - inventoryRepo.count(), - inventoryRepo.count({ where: { status: 'RECEIVED', inspectionStatus: IsNull() } }), - inventoryRepo.count({ where: { inspectionStatus: 'PASSED' } }), - inventoryRepo.count({ where: { status: 'STORED' } }), - inventoryRepo.count({ where: { status: 'RESERVED' } }), - inventoryRepo.count({ where: { status: 'READY_FOR_LOADING' } }), - inventoryRepo.count({ where: { status: 'LOADED' } }), - inventoryRepo.count({ where: { status: 'DISPATCHED' } }), - inventoryRepo.count({ where: { status: 'READY_FOR_PICKUP' } }), - inventoryRepo.count({ where: { status: 'DELIVERED' } }), - inventoryRepo - .createQueryBuilder('inv') - .where('inv.arrived_at >= :start', { start: startOfToday }) - .getCount(), + this.safeCount(warehouseRepo), + this.safeCount(inventoryRepo), + this.safeCount(inventoryRepo, { where: { status: 'RECEIVED', inspectionStatus: IsNull() } }), + this.safeCount(inventoryRepo, { where: { inspectionStatus: 'PASSED' } }), + this.safeCount(inventoryRepo, { where: { status: 'STORED' } }), + this.safeCount(inventoryRepo, { where: { status: 'RESERVED' } }), + this.safeCount(inventoryRepo, { where: { status: 'READY_FOR_LOADING' } }), + this.safeCount(inventoryRepo, { where: { status: 'LOADED' } }), + this.safeCount(inventoryRepo, { where: { status: 'DISPATCHED' } }), + this.safeCount(inventoryRepo, { where: { status: 'READY_FOR_PICKUP' } }), + this.safeCount(inventoryRepo, { where: { status: 'DELIVERED' } }), + this.safeReceivedToday(startOfToday), ]); return { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts index ccf66deb4..61f991d23 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts @@ -13,6 +13,8 @@ interface ItemAttributes { tradeDirection: string | null; cargoTypeCode: string | null; containerTypeCode: string | null; + inventoryQuantity: number; + bookingContainerCount: number; facilityId: string | null; warehouseId: string | null; yardId: string | null; @@ -31,6 +33,8 @@ export interface FeePreview { endIsOpen: boolean; // true when still accruing (no release/gate-clear yet) elapsedDays: number; chargeableDays: number; + containerCount: number; + billableUnits: number; amount: number; } @@ -67,6 +71,7 @@ export class WarehouseFeeService { `SELECT inv.arrived_at AS "arrivedAt", inv.gate_cleared_at AS "gateClearedAt", inv.release_date AS "releaseDate", + inv.quantity AS "inventoryQuantity", inv.warehouse_id AS "warehouseId", inv.yard_id AS "yardId", inv.zone_id AS "zoneId", @@ -74,7 +79,8 @@ export class WarehouseFeeService { b.freight_type AS "freightType", b.trade_direction AS "tradeDirection", cgt.code AS "cargoTypeCode", - ctt.code AS "containerTypeCode" + ctt.code AS "containerTypeCode", + COALESCE(container_lines.container_count, 0) AS "bookingContainerCount" FROM freight.warehouse_inventory inv LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id LEFT JOIN freight.bookings b ON b.id = inv.booking_id @@ -82,6 +88,12 @@ export class WarehouseFeeService { LEFT JOIN freight.cargo_types cgt ON cgt.id = cg.cargo_type_id LEFT JOIN freight.containers ct ON ct.id = inv.container_id LEFT JOIN freight.container_types ctt ON ctt.id = ct.container_type_id + LEFT JOIN LATERAL ( + SELECT COALESCE(SUM(bc.quantity), 0)::int AS container_count + FROM freight.booking_container bc + WHERE bc.booking_id = inv.booking_id + AND bc.deleted_at IS NULL + ) container_lines ON true WHERE inv.id = $1 AND inv.deleted_at IS NULL`, [inventoryId], ); @@ -131,12 +143,18 @@ export class WarehouseFeeService { const endIsOpen = !item.gateClearedAt && !item.releaseDate; const freeDays = rule?.freeDays ?? 0; const ratePerDay = Number(rule?.ratePerDay ?? 0); + const isContainer = (item.freightType ?? '').toUpperCase() === 'CONTAINER'; + const inventoryQuantity = Math.max(1, Math.round(Number(item.inventoryQuantity) || 1)); + const containerCount = isContainer + ? Math.max(1, Math.round(Number(item.bookingContainerCount) || inventoryQuantity)) + : 1; const elapsedDays = start ? Math.max(0, Math.ceil((new Date(endDate).getTime() - start.getTime()) / MS_PER_DAY)) : 0; const chargeableDays = Math.max(0, elapsedDays - freeDays); - const amount = Math.round(chargeableDays * ratePerDay * 100) / 100; + const billableUnits = chargeableDays * containerCount; + const amount = Math.round(billableUnits * ratePerDay * 100) / 100; return { ruleType, @@ -150,6 +168,8 @@ export class WarehouseFeeService { endIsOpen, elapsedDays, chargeableDays, + containerCount, + billableUnits, amount, }; } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts index 9d1d0f148..3ee45e2e1 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts @@ -18,7 +18,7 @@ export class WarehouseInspectionService { private readonly filesService: FilesService, ) {} - /** Create an inspection report for an inventory item and sync its inspectionStatus. */ + /** Create or update the inspection report for an inventory item and sync its inspectionStatus. */ async create(inventoryId: string, dto: CreateInspectionReportDto): Promise { const inventoryRepo = this.dataSource.getRepository(WarehouseInventory); const inventory = await inventoryRepo.findOne({ where: { id: inventoryId } }); @@ -29,8 +29,9 @@ export class WarehouseInspectionService { const expected = dto.expectedWeight ?? null; const actual = dto.actualWeight ?? null; const weightLoss = expected !== null && actual !== null ? Math.max(0, expected - actual) : null; + const inspectedAt = new Date(); - const report = await this.inspectionRepository.create({ + const payload = { inventoryId, bookingId: inventory.bookingId ?? null, reportType: dto.reportType, @@ -46,13 +47,27 @@ export class WarehouseInspectionService { missingItemsDescription: dto.missingItemsDescription ?? null, remarks: dto.remarks ?? null, inspectedById: dto.inspectedById ?? null, - inspectedAt: new Date(), + inspectedAt, + }; + + const [existingReport] = await this.inspectionRepository.findAll({ + where: { inventoryId }, + order: { createdAt: 'DESC' }, + take: 1, }); + let report: WarehouseInspectionReport; + if (existingReport) { + await this.inspectionRepository.update(existingReport.id, payload); + report = await this.findById(existingReport.id); + } else { + report = await this.inspectionRepository.create(payload); + } + // Mirror the latest outcome onto the inventory item so loading rules can read it. await inventoryRepo.update(inventoryId, { inspectionStatus: dto.inspectionStatus, - inspectedAt: new Date(), + inspectedAt, }); return report; 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 2699adbef..6e2f17b39 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,5 +1,6 @@ -import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common'; +import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import type { Response } from 'express'; import { BulkReceiveDto } from './dto/bulk-receive.dto'; import { BulkInspectDto } from './dto/bulk-inspect.dto'; @@ -226,6 +227,16 @@ export class WarehouseInventoryController { return this.inventoryService.release(id, dto); } + @Get(':id/release-document') + @ApiOperation({ summary: 'View warehouse release / exit paper PDF' }) + async releaseDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) { + const { filename, buffer } = await this.inventoryService.releaseDocument(id); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `inline; filename="${filename}"`); + res.setHeader('Content-Length', buffer.length); + return res.send(buffer); + } + @Post(':id/deliver') @ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' }) deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) { 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 31975b089..bc47e535c 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 @@ -1,8 +1,9 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; -import { DataSource, EntityManager, FindManyOptions, ILike } from 'typeorm'; +import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { Cargo } from '../cargoes/entities/cargoes.entity'; +import { ContractPdfService } from '../../contracts/contract-pdf.service'; import { BulkInspectDto } from './dto/bulk-inspect.dto'; import { BulkReceiveDto } from './dto/bulk-receive.dto'; import { DeliverInventoryDto } from './dto/deliver-inventory.dto'; @@ -38,8 +39,11 @@ const LOADABLE_WAGON_STATUSES = ['AVAILABLE', 'IMPORT_READY', 'EXPORT_READY', 'A export interface InventoryInquiryResult { id: string; + inventoryId: string | null; bookingId: string | null; + bookingReference: string | null; bookingNumber: string | null; + bookingStatus: string | null; customerName: string | null; containerNumber: string | null; cargoType: string | null; @@ -48,32 +52,32 @@ export interface InventoryInquiryResult { warehouse: { id: string; name: string; code: string } | null; yard: { id: string; name: string; code: string } | null; zone: { id: string; name: string; code: string } | null; - status: string; + status: string | null; + trainNumber: string | null; + trainStatus: string | null; + route: string | null; + locationSummary: string | null; quantity: number; weight: number; arrivedAt: Date | null; readyForLoadingAt: Date | null; } -interface LocationNode { - maxWeight?: number | null; - capacityWeight?: number | null; - maxVolume?: number | null; - capacityContainers?: number | null; - currentWeight: number; - currentVolume: number; - currentContainers: number; +interface BookingSummaryRow { + id: string; + reference: string | null; + status: string | null; + customer: string | null; } -// ── Batch 4.5 result/queue shapes ──────────────────────────────────────────── interface ArrivalQueueRow { bookingId: string; - bookingReference: string; + bookingReference: string | null; customer: string | null; cargo: string | null; container: string | null; arrivalDate: Date | null; - bookingStatus: string; + bookingStatus: string | null; inventoryId: string | null; currentStatus: string | null; inspectionStatus: string | null; @@ -85,7 +89,7 @@ interface ArrivalQueueRow { export interface ArrivalQueueItem { bookingId: string; - bookingReference: string; + bookingReference: string | null; customer: string | null; cargo: string | null; container: string | null; @@ -102,22 +106,71 @@ export interface ArrivalQueueItem { interface DefaultLocation { warehouseId: string; + facilityId?: string | null; yardId: string; zoneId: string; - facilityId: string | null; +} + +interface StorageAllocationLocation extends DefaultLocation { + path?: string | null; + rule?: { id: string; name: string; storageType: string | null } | null; +} + +interface InventoryAllocationCriteria { + freightType?: string | null; + tradeDirection?: string | null; + cargoTypeCode?: string | null; + containerStatus?: string | null; + requiresInspection?: boolean | null; } export interface AutoUnloadResult { processedCount: number; skippedCount: number; failedCount: number; - results: { bookingId: string; inventoryId?: string; status: string; reason?: string }[]; + results: Array<{ + bookingId: string; + inventoryId?: string; + status: 'PROCESSED' | 'FAILED'; + reason?: string; + }>; } export interface AutoLoadResult { loadedCount: number; skippedCount: number; - results: { inventoryId: string; status: string; reason?: string }[]; + results: Array<{ + inventoryId: string; + status: 'LOADED' | 'SKIPPED'; + reason?: string; + }>; +} + +interface WarehouseDashboardSummary { + totalWarehouses: number; + totalInventory: number; + receivedToday: number; + stored: number; + reserved: number; + readyForLoading: number; + loaded: number; + dispatched: number; +} + +interface LocationRef { + warehouseId: string; + yardId: string; + zoneId: string; +} + +interface LocationNode { + capacityWeight?: number | null; + capacityContainers?: number | null; + currentWeight: number; + maxWeight?: number | null; + maxVolume?: number | null; + currentVolume?: number | null; + currentContainers: number; } // ── Receive (Import/Export bulk) shapes ────────────────────────────────────── @@ -210,6 +263,7 @@ export class WarehouseInventoryService { private readonly allocation: WarehouseAllocationService, private readonly invoices: WarehouseInvoiceService, private readonly inspectionService: WarehouseInspectionService, + private readonly pdfService: ContractPdfService, ) {} /** @@ -242,7 +296,16 @@ export class WarehouseInventoryService { // ── Listing ──────────────────────────────────────────────────────────── - findAll(filter: FilterWarehouseInventoryDto): Promise { + async findAll(filter: FilterWarehouseInventoryDto): Promise { + const createdAt = + filter.dateFrom && filter.dateTo + ? Between(new Date(filter.dateFrom), new Date(filter.dateTo)) + : filter.dateFrom + ? MoreThanOrEqual(new Date(filter.dateFrom)) + : filter.dateTo + ? LessThanOrEqual(new Date(filter.dateTo)) + : undefined; + const base = { ...(filter.warehouseId ? { warehouseId: filter.warehouseId } : {}), ...(filter.yardId ? { yardId: filter.yardId } : {}), @@ -252,6 +315,8 @@ export class WarehouseInventoryService { ...(filter.containerId ? { containerId: filter.containerId } : {}), ...(filter.goodsId ? { goodsId: filter.goodsId } : {}), ...(filter.status ? { status: filter.status } : {}), + ...(createdAt ? { createdAt } : {}), + ...(filter.facilityId ? { warehouse: { stationId: filter.facilityId } } : {}), }; const search = filter.search?.trim(); @@ -259,11 +324,13 @@ export class WarehouseInventoryService { ? { ...base, notes: ILike(`%${search}%`) } : base; - return this.inventoryRepository.findAll({ + const items = await this.inventoryRepository.findAll({ where, - relations: { warehouse: { facility: true }, yard: true, zone: true, booking: true }, + relations: { warehouse: true, yard: true, zone: true }, order: { createdAt: 'DESC' }, }); + await this.attachBookingSummaries(items); + return items; } findReadyForLoading(filter: FilterWarehouseInventoryDto): Promise { @@ -272,7 +339,7 @@ export class WarehouseInventoryService { async findById(id: string): Promise { const item = await this.inventoryRepository.findById(id, { - relations: { warehouse: true, yard: true, zone: true }, + relations: { warehouse: { facility: true }, yard: true, zone: true }, }); if (!item) { @@ -800,13 +867,8 @@ export class WarehouseInventoryService { return result; } - /** Booking statuses eligible to be unloaded off an arrived import train (Batch 8). */ - private readonly IMPORT_UNLOAD_ELIGIBLE_STATUSES = [ - 'IN_TRANSIT', - 'ARRIVED_AT_INDODE', - 'ARRIVED_AT_DESTINATION', - 'ARRIVED_AT_FACILITY', - ]; + /** Booking statuses that must never be unloaded into warehouse inventory. */ + private readonly IMPORT_UNLOAD_BLOCKED_STATUSES = ['DRAFT', 'REJECTED', 'CANCELLED', 'COMPLETED']; /** * Batch 8 — unload all eligible assigned bookings of an ARRIVED import train into UNLOADED state. @@ -875,8 +937,8 @@ export class WarehouseInventoryService { result.results.push({ bookingId: booking.id, status: 'FAILED', reason }); }; - if (!this.IMPORT_UNLOAD_ELIGIBLE_STATUSES.includes(booking.status)) { - skip(`Booking status ${booking.status} is not unload-eligible`); + if (this.IMPORT_UNLOAD_BLOCKED_STATUSES.includes(booking.status)) { + skip(`Booking status ${booking.status} cannot be unloaded`); continue; } @@ -1063,7 +1125,7 @@ export class WarehouseInventoryService { }), ); - await this.applyCapacityDelta(manager, dto.warehouseId, dto.yardId, dto.zoneId, weight, volume, containerCount, +1); + await this.applyCapacityDelta(manager, dto, weight, volume, containerCount); await this.activityLog.record( { @@ -1082,15 +1144,148 @@ export class WarehouseInventoryService { return this.findById(id); } + async move(id: string, dto: MoveInventoryDto): Promise { + const movedId = await this.dataSource.transaction(async (manager) => { + const item = await manager.getRepository(WarehouseInventory).findOne({ + where: { id }, + lock: { mode: 'pessimistic_write' }, + }); + if (!item) { + throw new NotFoundException(`Inventory item ${id} not found`); + } + + if ( + item.warehouseId === dto.warehouseId && + item.yardId === dto.yardId && + item.zoneId === dto.zoneId + ) { + throw new BadRequestException('Destination location is the same as current location'); + } + + const { warehouse, yard, zone } = await this.validateLocation(manager, dto); + const weight = Number(item.weight) || 0; + const containerCount = item.containerId ? Math.round(Number(item.quantity) || 0) : 0; + + if (item.warehouseId !== dto.warehouseId) { + this.assertCapacity('Warehouse', warehouse, weight, Number(item.volume) || 0, containerCount); + } + if (item.yardId !== dto.yardId) { + this.assertCapacity('Yard', yard, weight, Number(item.volume) || 0, containerCount); + } + this.assertCapacity('Zone', zone, weight, Number(item.volume) || 0, containerCount); + + await this.applyCapacityDelta( + manager, + { + warehouseId: item.warehouseId, + yardId: item.yardId, + zoneId: item.zoneId, + }, + -weight, + -(Number(item.volume) || 0), + -containerCount, + ); + + await this.applyCapacityDelta(manager, dto, weight, Number(item.volume) || 0, containerCount); + + item.warehouseId = dto.warehouseId; + item.yardId = dto.yardId; + item.zoneId = dto.zoneId; + if (dto.remarks?.trim()) { + const existingNotes = item.notes?.trim(); + item.notes = existingNotes + ? `${existingNotes}\nMove: ${dto.remarks.trim()}` + : `Move: ${dto.remarks.trim()}`; + } + + const saved = await manager.getRepository(WarehouseInventory).save(item); + return saved.id; + }); + + return this.findById(movedId); + } + // ── Lifecycle transitions ──────────────────────────────────────────────── - store(id: string, performedBy?: string): Promise { - return this.transition(id, 'STORED', { - timestampField: 'storedAt', - activityType: 'INVENTORY_STORED', - description: 'Inventory stored', - performedBy, + async store(id: string, performedBy?: string): Promise { + const item = await this.findById(id); + this.assertTransition(item.status, 'STORED'); + + const criteria = await this.getInventoryAllocationCriteria(item); + const ruleLocation = await this.allocation.resolveLocation(criteria); + const location = ruleLocation ?? (await this.pickCapacityBalancedStorageLocation(item, criteria)); + + if (!location) { + throw new BadRequestException('No active warehouse yard/zone is available for this inventory item'); + } + + const weight = Number(item.weight) || 0; + const volume = Number(item.volume) || 0; + const containerCount = item.containerId ? Math.max(1, Math.round(Number(item.quantity) || 1)) : 0; + + await this.dataSource.transaction(async (manager) => { + const locked = await manager.getRepository(WarehouseInventory).findOne({ + where: { id }, + lock: { mode: 'pessimistic_write' }, + }); + if (!locked) { + throw new NotFoundException(`Inventory item ${id} not found`); + } + this.assertTransition(locked.status, 'STORED'); + + if ( + locked.warehouseId !== location.warehouseId || + locked.yardId !== location.yardId || + locked.zoneId !== location.zoneId + ) { + const { warehouse, yard, zone } = await this.validateLocation(manager, location); + this.assertCapacity('Warehouse', warehouse, weight, volume, containerCount); + this.assertCapacity('Yard', yard, weight, volume, containerCount); + this.assertCapacity('Zone', zone, weight, volume, containerCount); + + await this.applyCapacityDelta( + manager, + { + warehouseId: locked.warehouseId, + yardId: locked.yardId, + zoneId: locked.zoneId, + }, + -weight, + -volume, + -containerCount, + ); + await this.applyCapacityDelta(manager, location, weight, volume, containerCount); + } + + await manager.getRepository(WarehouseInventory).update(id, { + status: 'STORED', + storedAt: new Date(), + warehouseId: location.warehouseId, + yardId: location.yardId, + zoneId: location.zoneId, + notes: this.appendNote( + locked.notes, + ruleLocation?.rule + ? `Stored by allocation rule "${ruleLocation.rule.name}" -> ${ruleLocation.path}` + : `Stored by capacity-balanced allocation -> ${location.path ?? 'assigned yard/zone'}`, + ), + }); + + await this.activityLog.record( + { + activityType: 'INVENTORY_STORED', + inventoryId: id, + warehouseId: location.warehouseId, + description: ruleLocation?.rule + ? `Inventory stored by rule "${ruleLocation.rule.name}" at ${ruleLocation.path}` + : `Inventory stored at ${location.path ?? 'assigned yard/zone'}`, + performedBy, + }, + manager, + ); }); + + return this.findById(id); } async reserve(dto: ReserveInventoryDto): Promise { @@ -1204,6 +1399,80 @@ export class WarehouseInventoryService { return this.findById(id); } + async releaseDocument(id: string): Promise<{ filename: string; buffer: Buffer }> { + const item = await this.findById(id); + if (!item.releaseDate) { + throw new BadRequestException('A release order must be issued before downloading the exit paper'); + } + + const [row] = await this.dataSource.query( + `SELECT inv.id, + inv.release_order_reference AS "releaseOrderReference", + inv.release_date AS "releaseDate", + inv.quantity, + inv.weight, + inv.status, + b.id AS "bookingId", + b.reference AS "bookingReference", + b.status AS "bookingStatus", + b.freight_type AS "freightType", + b.trade_direction AS "tradeDirection", + company.name AS "customerName", + container.container_number AS "containerNumber", + COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription", + wh.name AS "warehouseName", + wh.code AS "warehouseCode", + yard.name AS "yardName", + yard.code AS "yardCode", + zone.name AS "zoneName", + zone.code AS "zoneCode" + FROM freight.warehouse_inventory inv + LEFT JOIN freight.bookings b ON b.id = inv.booking_id + LEFT JOIN freight.companies company ON company.id = b.company_id + LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id + LEFT JOIN freight.warehouse_yards yard ON yard.id = inv.yard_id + LEFT JOIN freight.warehouse_zones zone ON zone.id = inv.zone_id + LEFT JOIN freight.containers container ON ( + (inv.container_id IS NOT NULL AND container.id = inv.container_id) + OR (inv.container_id IS NULL AND container.booking_id = b.id) + ) AND container.deleted_at IS NULL + LEFT JOIN freight.cargoes cargo ON ( + (inv.cargo_id IS NOT NULL AND cargo.id = inv.cargo_id) + OR (inv.cargo_id IS NULL AND cargo.booking_id = b.id) + ) AND cargo.deleted_at IS NULL + LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id) + WHERE inv.id = $1 AND inv.deleted_at IS NULL + LIMIT 1`, + [id], + ); + + const reference = row?.releaseOrderReference || `REL-${id.slice(0, 8).toUpperCase()}`; + const bookingReference = row?.bookingReference || item.bookingId || 'N/A'; + const issuedAt = row?.releaseDate ? new Date(row.releaseDate) : new Date(); + const html = this.buildReleaseDocumentHtml({ + reference, + issuedAt, + bookingReference, + bookingStatus: row?.bookingStatus ?? null, + customerName: row?.customerName ?? null, + freightType: row?.freightType ?? null, + tradeDirection: row?.tradeDirection ?? null, + containerNumber: row?.containerNumber ?? null, + cargoDescription: row?.cargoDescription ?? null, + quantity: Number(row?.quantity ?? item.quantity ?? 0), + weight: Number(row?.weight ?? item.weight ?? 0), + warehouse: [row?.warehouseName, row?.warehouseCode].filter(Boolean).join(' / ') || null, + yard: [row?.yardName, row?.yardCode].filter(Boolean).join(' / ') || null, + zone: [row?.zoneName, row?.zoneCode].filter(Boolean).join(' / ') || null, + inventoryStatus: row?.status ?? item.status, + }); + + return { + filename: `release-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`, + buffer: await this.pdfService.htmlToPdfBuffer(html), + }; + } + /** Hand import goods to the customer + capture proof of delivery (READY_FOR_PICKUP → DELIVERED). */ async deliver(id: string, dto: DeliverInventoryDto): Promise { const item = await this.findById(id); @@ -1226,7 +1495,17 @@ export class WarehouseInventoryService { }); // Goods physically leave the warehouse on pickup — free up capacity. - await this.applyCapacityDelta(manager, item.warehouseId, item.yardId, item.zoneId, weight, volume, containerCount, -1); + await this.applyCapacityDelta( + manager, + { + warehouseId: item.warehouseId, + yardId: item.yardId, + zoneId: item.zoneId, + }, + -weight, + -volume, + -containerCount, + ); // Proof of delivery is captured on the linked cargo. if (item.cargoId) { @@ -1361,97 +1640,48 @@ export class WarehouseInventoryService { }); } - async dispatch(id: string, performedBy?: string): Promise { - const item = await this.findById(id); - this.assertTransition(item.status, 'DISPATCHED'); - - const weight = Number(item.weight) || 0; - const volume = Number(item.volume) || 0; - const containerCount = item.containerId ? Math.round(Number(item.quantity) || 0) : 0; - - await this.dataSource.transaction(async (manager) => { - await manager.getRepository(WarehouseInventory).update(id, { - status: 'DISPATCHED', - dispatchedAt: new Date(), - }); - // Item physically leaves the warehouse — free up capacity. - await this.applyCapacityDelta(manager, item.warehouseId, item.yardId, item.zoneId, weight, volume, containerCount, -1); - await this.activityLog.record( - { - activityType: 'INVENTORY_DISPATCHED', - inventoryId: id, - warehouseId: item.warehouseId, - description: 'Inventory dispatched', - performedBy, - }, - manager, - ); + dispatch(id: string, performedBy?: string): Promise { + return this.transition(id, 'DISPATCHED', { + timestampField: 'dispatchedAt', + activityType: 'INVENTORY_DISPATCHED', + description: 'Inventory dispatched', + performedBy, }); - - return this.findById(id); } - // ── Movement ────────────────────────────────────────────────────────────── - - async move(id: string, dto: MoveInventoryDto): Promise { - const item = await this.findById(id); - if (item.status === 'DISPATCHED') { - throw new BadRequestException('Dispatched inventory cannot be moved'); - } - - const weight = Number(item.weight) || 0; - const volume = Number(item.volume) || 0; - const containerCount = item.containerId ? Math.round(Number(item.quantity) || 0) : 0; - - const from = { warehouseId: item.warehouseId, yardId: item.yardId, zoneId: item.zoneId }; - - await this.dataSource.transaction(async (manager) => { - const { warehouse } = await this.validateLocation(manager, dto); - - // Capacity check at the destination (item is added there). - const dest = await this.loadLocation(manager, dto); - this.assertCapacity('Warehouse', dest.warehouse, weight, volume, containerCount); - this.assertCapacity('Yard', dest.yard, weight, volume, containerCount); - this.assertCapacity('Zone', dest.zone, weight, volume, containerCount); - - // Free the old location, occupy the new one. - await this.applyCapacityDelta(manager, from.warehouseId, from.yardId, from.zoneId, weight, volume, containerCount, -1); - await this.applyCapacityDelta(manager, dto.warehouseId, dto.yardId, dto.zoneId, weight, volume, containerCount, +1); - - await manager.getRepository(WarehouseInventory).update(id, { - warehouseId: dto.warehouseId, - yardId: dto.yardId, - zoneId: dto.zoneId, - }); - - await manager.getRepository(WarehouseInventoryMovement).save( - manager.getRepository(WarehouseInventoryMovement).create({ - inventoryId: id, - fromWarehouseId: from.warehouseId, - fromYardId: from.yardId, - fromZoneId: from.zoneId, - toWarehouseId: dto.warehouseId, - toYardId: dto.yardId, - toZoneId: dto.zoneId, - remarks: dto.remarks?.trim() ?? null, - movedBy: dto.movedBy ?? 'system', - movedAt: new Date(), - }), - ); - - await this.activityLog.record( - { - activityType: 'INVENTORY_MOVED', - inventoryId: id, - warehouseId: warehouse.id, - description: dto.remarks?.trim() || 'Inventory moved', - performedBy: dto.movedBy, - }, - manager, - ); + async dashboardSummary(filter: FilterWarehouseInventoryDto): Promise { + const warehouses = await this.dataSource.getRepository(Warehouse).find({ + where: { + status: 'ACTIVE', + ...(filter.facilityId ? { stationId: filter.facilityId } : {}), + ...(filter.warehouseId ? { id: filter.warehouseId } : {}), + }, }); + const inventory = await this.findAll(filter); + const today = new Date(); - return this.findById(id); + const byStatus = inventory.reduce>((acc, item) => { + acc[item.status] = (acc[item.status] ?? 0) + 1; + return acc; + }, {}); + + return { + totalWarehouses: warehouses.length, + totalInventory: inventory.length, + receivedToday: inventory.filter((item) => { + const arrivedAt = item.arrivedAt ?? item.createdAt; + return ( + arrivedAt.getFullYear() === today.getFullYear() && + arrivedAt.getMonth() === today.getMonth() && + arrivedAt.getDate() === today.getDate() + ); + }).length, + stored: byStatus.STORED ?? 0, + reserved: byStatus.RESERVED ?? 0, + readyForLoading: byStatus.READY_FOR_LOADING ?? 0, + loaded: byStatus.LOADED ?? 0, + dispatched: byStatus.DISPATCHED ?? 0, + }; } findMovements(id: string): Promise { @@ -1468,6 +1698,145 @@ export class WarehouseInventoryService { // ── Inquiry (Batch 1) ────────────────────────────────────────────────── async inquiry(filter: InquiryWarehouseInventoryDto): Promise { + const bookingReference = (filter.bookingReference ?? filter.bookingNumber)?.trim(); + if (bookingReference) { + const params: unknown[] = [`%${bookingReference}%`]; + const where = ['b.reference ILIKE $1', 'b.deleted_at IS NULL']; + + if (filter.containerNumber?.trim()) { + params.push(`%${filter.containerNumber.trim()}%`); + where.push(`container.container_number ILIKE $${params.length}`); + } + if (filter.cargoType?.trim()) { + params.push(`%${filter.cargoType.trim()}%`); + where.push(`cargo_type.cargo_type_name ILIKE $${params.length}`); + } + if (filter.goodsName?.trim()) { + params.push(`%${filter.goodsName.trim()}%`); + where.push(`(inv.notes ILIKE $${params.length} OR cargo.description ILIKE $${params.length})`); + } + if (filter.warehouseId) { + params.push(filter.warehouseId); + where.push(`inv.warehouse_id = $${params.length}`); + } + if (filter.yardId) { + params.push(filter.yardId); + where.push(`inv.yard_id = $${params.length}`); + } + if (filter.zoneId) { + params.push(filter.zoneId); + where.push(`inv.zone_id = $${params.length}`); + } + if (filter.status) { + params.push(filter.status); + where.push(`inv.status = $${params.length}`); + } + + const rows = await this.dataSource.query( + `SELECT COALESCE(inv.id::text, b.id::text) AS "id", + inv.id AS "inventoryId", + b.id AS "bookingId", + b.reference AS "bookingReference", + b.reference AS "bookingNumber", + b.status AS "bookingStatus", + company.name AS "customerName", + container.container_number AS "containerNumber", + cargo_type.cargo_type_name AS "cargoType", + cargo.description AS "cargoDescription", + inv.goods_id AS "goodsId", + wh.id AS "warehouseId", + wh.name AS "warehouseName", + wh.code AS "warehouseCode", + yard.id AS "yardId", + yard.name AS "yardName", + yard.code AS "yardCode", + zone.id AS "zoneId", + zone.name AS "zoneName", + zone.code AS "zoneCode", + inv.status, + ts.train_number AS "trainNumber", + ts.status AS "trainStatus", + oy.code AS "originCode", + dy.code AS "destinationCode", + CASE + WHEN inv.id IS NOT NULL THEN concat_ws(' / ', wh.code, yard.code, zone.code) + WHEN ts.status = 'ARRIVED' THEN concat('Arrived at ', COALESCE(dy.code, 'destination'), ' - awaiting unload') + WHEN ts.status = 'DISPATCHED' THEN concat('In transit: ', COALESCE(oy.code, '?'), ' -> ', COALESCE(dy.code, '?')) + WHEN ts.id IS NOT NULL THEN concat('Scheduled: ', COALESCE(oy.code, '?'), ' -> ', COALESCE(dy.code, '?')) + ELSE 'No warehouse inventory yet' + END AS "locationSummary", + COALESCE(inv.quantity, 0) AS quantity, + COALESCE(inv.weight, b.cargo_total_weight_vgm, 0) AS weight, + inv.arrived_at AS "arrivedAt", + inv.ready_for_loading_at AS "readyForLoadingAt" + FROM freight.bookings b + LEFT JOIN freight.companies company ON company.id = b.company_id + LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL + LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id + LEFT JOIN freight.warehouse_yards yard ON yard.id = inv.yard_id + LEFT JOIN freight.warehouse_zones zone ON zone.id = inv.zone_id + LEFT JOIN freight.containers container ON ( + (inv.container_id IS NOT NULL AND container.id = inv.container_id) + OR (inv.container_id IS NULL AND container.booking_id = b.id) + ) AND container.deleted_at IS NULL + LEFT JOIN freight.cargoes cargo ON ( + (inv.cargo_id IS NOT NULL AND cargo.id = inv.cargo_id) + OR (inv.cargo_id IS NULL AND cargo.booking_id = b.id) + ) AND cargo.deleted_at IS NULL + LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id) + LEFT JOIN LATERAL ( + SELECT ts_inner.* + FROM freight.train_schedule_bookings tsb + JOIN freight.train_schedules ts_inner ON ts_inner.id = tsb.train_schedule_id + WHERE tsb.booking_id = b.id + AND tsb.deleted_at IS NULL + AND ts_inner.deleted_at IS NULL + ORDER BY ts_inner.scheduled_departure_date DESC NULLS LAST + LIMIT 1 + ) ts ON TRUE + LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id + LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id + WHERE ${where.join(' AND ')} + ORDER BY inv.created_at DESC NULLS LAST, b.created_at DESC`, + params, + ); + + return rows.map((row: Record) => ({ + id: String(row.id), + inventoryId: (row.inventoryId as string | null) ?? null, + bookingId: (row.bookingId as string | null) ?? null, + bookingReference: (row.bookingReference as string | null) ?? null, + bookingNumber: (row.bookingNumber as string | null) ?? null, + bookingStatus: (row.bookingStatus as string | null) ?? null, + customerName: (row.customerName as string | null) ?? null, + containerNumber: (row.containerNumber as string | null) ?? null, + cargoType: (row.cargoType as string | null) ?? null, + cargoDescription: (row.cargoDescription as string | null) ?? null, + goodsId: (row.goodsId as string | null) ?? null, + warehouse: row.warehouseId + ? { id: row.warehouseId as string, name: row.warehouseName as string, code: row.warehouseCode as string } + : null, + yard: row.yardId + ? { id: row.yardId as string, name: row.yardName as string, code: row.yardCode as string } + : null, + zone: row.zoneId + ? { id: row.zoneId as string, name: row.zoneName as string, code: row.zoneCode as string } + : null, + status: (row.status as string | null) ?? null, + trainNumber: (row.trainNumber as string | null) ?? null, + trainStatus: (row.trainStatus as string | null) ?? null, + route: + row.originCode || row.destinationCode + ? `${row.originCode ?? '?'} -> ${row.destinationCode ?? '?'}` + : null, + locationSummary: (row.locationSummary as string | null) ?? null, + quantity: Number(row.quantity) || 0, + weight: Number(row.weight) || 0, + arrivedAt: (row.arrivedAt as Date | null) ?? null, + readyForLoadingAt: (row.readyForLoadingAt as Date | null) ?? null, + })); + } + const qb = this.dataSource .getRepository(WarehouseInventory) .createQueryBuilder('inv') @@ -1476,8 +1845,20 @@ export class WarehouseInventoryService { .leftJoinAndSelect('inv.zone', 'zone') .leftJoin('freight.bookings', 'booking', 'booking.id = inv.booking_id') .leftJoin('freight.companies', 'company', 'company.id = booking.company_id') - .leftJoin('freight.containers', 'container', 'container.id = inv.container_id') - .leftJoin('freight.cargoes', 'cargo', 'cargo.id = inv.cargo_id') + .leftJoin( + 'freight.containers', + 'container', + `((inv.container_id IS NOT NULL AND container.id = inv.container_id) + OR (inv.container_id IS NULL AND container.booking_id = inv.booking_id)) + AND container.deleted_at IS NULL`, + ) + .leftJoin( + 'freight.cargoes', + 'cargo', + `((inv.cargo_id IS NOT NULL AND cargo.id = inv.cargo_id) + OR (inv.cargo_id IS NULL AND cargo.booking_id = inv.booking_id)) + AND cargo.deleted_at IS NULL`, + ) .leftJoin('freight.cargo_types', 'cargo_type', 'cargo_type.id = cargo.cargo_type_id') .addSelect('booking.reference', 'b_reference') .addSelect('company.name', 'c_name') @@ -1486,9 +1867,6 @@ export class WarehouseInventoryService { .addSelect('cargo_type.cargo_type_name', 'cgt_name') .orderBy('inv.created_at', 'DESC'); - if (filter.bookingNumber?.trim()) { - qb.andWhere('booking.reference ILIKE :bn', { bn: `%${filter.bookingNumber.trim()}%` }); - } if (filter.containerNumber?.trim()) { qb.andWhere('container.container_number ILIKE :cn', { cn: `%${filter.containerNumber.trim()}%` }); } @@ -1509,8 +1887,11 @@ export class WarehouseInventoryService { const row = raw[index] ?? {}; return { id: inv.id, + inventoryId: inv.id, bookingId: inv.bookingId ?? null, + bookingReference: row.b_reference ?? null, bookingNumber: row.b_reference ?? null, + bookingStatus: null, customerName: row.c_name ?? null, containerNumber: row.ct_number ?? null, cargoType: row.cgt_name ?? null, @@ -1522,6 +1903,12 @@ export class WarehouseInventoryService { yard: inv.yard ? { id: inv.yard.id, name: inv.yard.name, code: inv.yard.code } : null, zone: inv.zone ? { id: inv.zone.id, name: inv.zone.name, code: inv.zone.code } : null, status: inv.status, + trainNumber: null, + trainStatus: null, + route: null, + locationSummary: inv.warehouse + ? [inv.warehouse.code, inv.yard?.code, inv.zone?.code].filter(Boolean).join(' / ') + : null, quantity: Number(inv.quantity), weight: Number(inv.weight), arrivedAt: inv.arrivedAt ?? null, @@ -1566,6 +1953,109 @@ export class WarehouseInventoryService { return this.findById(id); } + private buildReleaseDocumentHtml(data: { + reference: string; + issuedAt: Date; + bookingReference: string; + bookingStatus: string | null; + customerName: string | null; + freightType: string | null; + tradeDirection: string | null; + containerNumber: string | null; + cargoDescription: string | null; + quantity: number; + weight: number; + warehouse: string | null; + yard: string | null; + zone: string | null; + inventoryStatus: string | null; + }): string { + const esc = (value: unknown) => + String(value ?? '-') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + const issuedAt = data.issuedAt.toLocaleString('en-GB', { + year: 'numeric', + month: 'short', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); + const rows = [ + ['Booking reference', data.bookingReference], + ['Customer', data.customerName], + ['Booking status', data.bookingStatus], + ['Freight type', data.freightType], + ['Trade direction', data.tradeDirection], + ['Container number', data.containerNumber], + ['Cargo / goods', data.cargoDescription], + ['Quantity', data.quantity], + ['Weight', `${data.weight.toLocaleString()} kg`], + ['Warehouse', data.warehouse], + ['Yard', data.yard], + ['Zone', data.zone], + ['Inventory status', data.inventoryStatus], + ]; + + return ` + + + + Warehouse Release Exit Paper + + + +
+
+
+
EDR Warehouse Operations
+

Warehouse Release / Exit Paper

+
+
+ Release reference + ${esc(data.reference)} + Issued: ${esc(issuedAt)} +
+
+
+ This document authorizes the listed booking/goods to leave the warehouse after release checks. +
+ + + ${rows.map(([label, value]) => ``).join('')} + +
${esc(label)}${esc(value)}
+
+
Warehouse officer name / signature / date
+
Customer or driver name / signature / date
+
+ +
+ +`; + } + private assertTransition(from: WarehouseInventoryStatus, to: WarehouseInventoryStatus): void { if (!WAREHOUSE_INVENTORY_TRANSITIONS[from]?.includes(to)) { throw new BadRequestException(`Invalid transition ${from} → ${to}`); @@ -1574,22 +2064,7 @@ export class WarehouseInventoryService { private async validateLocation( manager: EntityManager, - dto: { warehouseId: string; yardId: string; zoneId: string }, - ): Promise<{ warehouse: Warehouse; yard: WarehouseYard; zone: WarehouseZone }> { - const { warehouse, yard, zone } = await this.loadLocation(manager, dto); - - if (warehouse.status !== 'ACTIVE') throw new BadRequestException('Warehouse is not ACTIVE'); - if (yard.warehouseId !== warehouse.id) throw new BadRequestException('Yard does not belong to the selected warehouse'); - if (yard.status !== 'ACTIVE') throw new BadRequestException('Yard is not ACTIVE'); - if (zone.yardId !== yard.id) throw new BadRequestException('Zone does not belong to the selected yard'); - if (zone.status !== 'ACTIVE') throw new BadRequestException('Zone is not ACTIVE'); - - return { warehouse, yard, zone }; - } - - private async loadLocation( - manager: EntityManager, - dto: { warehouseId: string; yardId: string; zoneId: string }, + dto: LocationRef, ): Promise<{ warehouse: Warehouse; yard: WarehouseYard; zone: WarehouseZone }> { const warehouse = await manager.getRepository(Warehouse).findOne({ where: { id: dto.warehouseId } }); if (!warehouse) throw new NotFoundException(`Warehouse ${dto.warehouseId} not found`); @@ -1610,12 +2085,210 @@ export class WarehouseInventoryService { } } + private appendNote(existing: string | null | undefined, note: string): string { + const trimmed = existing?.trim(); + return trimmed ? `${trimmed}\n${note}` : note; + } + + private async getInventoryAllocationCriteria(item: WarehouseInventory): Promise { + const fallbackFreightType = item.containerId ? 'CONTAINER' : item.cargoId ? 'BULK' : null; + + if (!item.bookingId) { + return { + freightType: fallbackFreightType, + requiresInspection: item.inspectionStatus !== 'PASSED', + }; + } + + const [row]: Array<{ + freightType: string | null; + tradeDirection: string | null; + cargoTypeCode: string | null; + containerStatus: string | null; + originCountry: string | null; + destinationCountry: string | null; + }> = await this.dataSource.query( + `SELECT b.freight_type AS "freightType", + b.trade_direction AS "tradeDirection", + cgt.code AS "cargoTypeCode", + COALESCE(selected_container.status, booking_container.status) AS "containerStatus", + oy.country AS "originCountry", + dy.country AS "destinationCountry" + FROM freight.bookings b + LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id + LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id + LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id + LEFT JOIN freight.containers selected_container + ON selected_container.id = $2 AND selected_container.deleted_at IS NULL + LEFT JOIN LATERAL ( + SELECT c.status + FROM freight.containers c + WHERE c.booking_id = b.id AND c.deleted_at IS NULL + ORDER BY c.created_at ASC + LIMIT 1 + ) booking_container ON true + WHERE b.id = $1 AND b.deleted_at IS NULL + LIMIT 1`, + [item.bookingId, item.containerId], + ); + + if (!row) { + return { + freightType: fallbackFreightType, + requiresInspection: item.inspectionStatus !== 'PASSED', + }; + } + + const derivedDirection = deriveTradeDirection( + { country: row.originCountry }, + { country: row.destinationCountry }, + ); + + return { + freightType: row.freightType ?? fallbackFreightType, + tradeDirection: row.tradeDirection ?? derivedDirection, + cargoTypeCode: row.cargoTypeCode, + containerStatus: row.containerStatus, + requiresInspection: item.inspectionStatus !== 'PASSED', + }; + } + + private yardTypeFor(criteria: InventoryAllocationCriteria): string { + const freightType = criteria.freightType?.toUpperCase(); + if (freightType === 'CONTAINER') return 'CONTAINER_YARD'; + if (freightType === 'BULK') return 'BULK_YARD'; + return 'GENERAL_CARGO_YARD'; + } + + private zoneTypeFor(criteria: InventoryAllocationCriteria): string { + const freightType = criteria.freightType?.toUpperCase(); + if (freightType === 'CONTAINER') return 'CONTAINER_ZONE'; + if (freightType === 'BULK') return 'BULK_ZONE'; + return 'GENERAL_CARGO_ZONE'; + } + + private async pickCapacityBalancedStorageLocation( + item: WarehouseInventory, + criteria: InventoryAllocationCriteria, + ): Promise { + const weight = Number(item.weight) || 0; + const containerCount = item.containerId ? Math.max(1, Math.round(Number(item.quantity) || 1)) : 0; + const yardType = this.yardTypeFor(criteria); + const zoneType = this.zoneTypeFor(criteria); + + const query = async (warehouseId: string | null) => { + const [row]: Array<{ + warehouseId: string; + facilityId: string | null; + warehouseName: string | null; + yardId: string; + yardName: string | null; + yardCode: string | null; + zoneId: string; + zoneName: string | null; + zoneCode: string | null; + }> = await this.dataSource.query( + `SELECT wh.id AS "warehouseId", + wh.facility_id AS "facilityId", + wh.name AS "warehouseName", + yard.id AS "yardId", + yard.name AS "yardName", + yard.code AS "yardCode", + zone.id AS "zoneId", + zone.name AS "zoneName", + zone.code AS "zoneCode" + FROM freight.warehouses wh + JOIN freight.warehouse_yards yard + ON yard.warehouse_id = wh.id + AND yard.deleted_at IS NULL + AND yard.status = 'ACTIVE' + AND yard.is_active = true + JOIN freight.warehouse_zones zone + ON zone.yard_id = yard.id + AND zone.deleted_at IS NULL + AND zone.status = 'ACTIVE' + AND zone.is_active = true + WHERE wh.deleted_at IS NULL + AND wh.status = 'ACTIVE' + AND wh.is_active = true + AND ($1::uuid IS NULL OR wh.id = $1::uuid) + AND (COALESCE(yard.max_weight, yard.capacity_weight) IS NULL + OR yard.current_weight::numeric + $4::numeric <= COALESCE(yard.max_weight, yard.capacity_weight)) + AND (COALESCE(zone.max_weight, zone.capacity_weight) IS NULL + OR zone.current_weight::numeric + $4::numeric <= COALESCE(zone.max_weight, zone.capacity_weight)) + AND (yard.capacity_containers IS NULL + OR yard.current_containers + $5::int <= yard.capacity_containers) + AND (zone.capacity_containers IS NULL + OR zone.current_containers + $5::int <= zone.capacity_containers) + ORDER BY + CASE WHEN yard.type = $2 THEN 0 ELSE 1 END, + CASE WHEN zone.type = $3 THEN 0 ELSE 1 END, + ( + CASE WHEN yard.capacity_weight IS NULL OR yard.capacity_weight = 0 THEN 0 + ELSE yard.current_weight::numeric / yard.capacity_weight::numeric END + + + CASE WHEN yard.capacity_containers IS NULL OR yard.capacity_containers = 0 THEN 0 + ELSE yard.current_containers::numeric / yard.capacity_containers::numeric END + + + CASE WHEN zone.capacity_weight IS NULL OR zone.capacity_weight = 0 THEN 0 + ELSE zone.current_weight::numeric / zone.capacity_weight::numeric END + + + CASE WHEN zone.capacity_containers IS NULL OR zone.capacity_containers = 0 THEN 0 + ELSE zone.current_containers::numeric / zone.capacity_containers::numeric END + ) ASC, + yard.code ASC, + zone.code ASC + LIMIT 1`, + [warehouseId, yardType, zoneType, weight, containerCount], + ); + return row; + }; + + const row = (await query(item.warehouseId)) ?? (await query(null)); + if (!row) return null; + + return { + warehouseId: row.warehouseId, + facilityId: row.facilityId, + yardId: row.yardId, + zoneId: row.zoneId, + rule: null, + path: [row.warehouseName, row.yardCode ?? row.yardName, row.zoneCode ?? row.zoneName] + .filter(Boolean) + .join(' -> '), + }; + } + private async getBookingStatus(bookingId: string): Promise { - const rows = await this.dataSource.query( + const [row]: Array<{ status: string | null }> = await this.dataSource.query( 'SELECT status FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL LIMIT 1', [bookingId], ); - return rows?.[0]?.status ?? null; + return row?.status ?? null; + } + + private async attachBookingSummaries(items: WarehouseInventory[]): Promise { + const bookingIds = [...new Set(items.map((item) => item.bookingId).filter(Boolean))] as string[]; + if (bookingIds.length === 0) return; + + const rows: BookingSummaryRow[] = await this.dataSource.query( + `SELECT b.id, b.reference, b.status, company.name AS customer + FROM freight.bookings b + LEFT JOIN freight.companies company ON company.id = b.company_id + WHERE b.id = ANY($1) AND b.deleted_at IS NULL`, + [bookingIds], + ); + const summaries = new Map(rows.map((row) => [row.id, row])); + + items.forEach((item) => { + const summary = item.bookingId ? summaries.get(item.bookingId) : undefined; + if (!summary) return; + Object.assign(item, { + bookingReference: summary.reference, + bookingStatus: summary.status, + customerName: summary.customer, + }); + }); } /** IMPORT | EXPORT | DOMESTIC derived from the booking ROUTE (yard countries), or null if missing. */ @@ -1665,25 +2338,24 @@ export class WarehouseInventoryService { private async applyCapacityDelta( manager: EntityManager, - warehouseId: string, - yardId: string, - zoneId: string, - weight: number, - volume: number, - containers: number, - sign: 1 | -1, + location: LocationRef, + weightAdd: number, + volumeAdd: number, + containerAdd: number, ): Promise { - const apply = sign === 1 ? manager.increment.bind(manager) : manager.decrement.bind(manager); const targets: Array<[typeof Warehouse | typeof WarehouseYard | typeof WarehouseZone, string]> = [ - [Warehouse, warehouseId], - [WarehouseYard, yardId], - [WarehouseZone, zoneId], + [Warehouse, location.warehouseId], + [WarehouseYard, location.yardId], + [WarehouseZone, location.zoneId], ]; for (const [entity, id] of targets) { - if (weight) await apply(entity, { id }, 'currentWeight', weight); - if (volume) await apply(entity, { id }, 'currentVolume', volume); - if (containers) await apply(entity, { id }, 'currentContainers', containers); + if (weightAdd > 0) await manager.increment(entity, { id }, 'currentWeight', weightAdd); + if (weightAdd < 0) await manager.decrement(entity, { id }, 'currentWeight', Math.abs(weightAdd)); + if (volumeAdd > 0) await manager.increment(entity, { id }, 'currentVolume', volumeAdd); + if (volumeAdd < 0) await manager.decrement(entity, { id }, 'currentVolume', Math.abs(volumeAdd)); + if (containerAdd > 0) await manager.increment(entity, { id }, 'currentContainers', containerAdd); + if (containerAdd < 0) await manager.decrement(entity, { id }, 'currentContainers', Math.abs(containerAdd)); } } } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts index d58a74b7d..abe514d80 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts @@ -75,9 +75,9 @@ export class WarehouseInvoiceService { feeType, description: p.ruleType === 'STORAGE_FEE' - ? `Storage fee — ${p.chargeableDays} chargeable day(s) after ${p.freeDays} free` - : `${isContainer ? 'Container' : 'Bulk'} demurrage — ${p.chargeableDays} chargeable day(s) after ${p.freeDays} free`, - quantity: p.chargeableDays, + ? `Storage fee - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s) after ${p.freeDays} free` + : `${isContainer ? 'Container' : 'Bulk'} demurrage - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s) after ${p.freeDays} free`, + quantity: p.billableUnits, unitRate: p.ratePerDay, amount: p.amount, currency: p.currency, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts index 3ee0dde82..c14cea7a4 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts @@ -15,6 +15,12 @@ export class WarehouseYardsController { private readonly zonesService: WarehouseZonesService, ) {} + @Get() + @ApiOperation({ summary: 'List all warehouse yards' }) + findAll() { + return this.yardsService.findAll(); + } + @Get(':id') @ApiOperation({ summary: 'Get warehouse yard by ID' }) findOne(@Param('id', ParseUUIDPipe) id: string) { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts index f65e4593e..3279e9092 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts @@ -13,6 +13,13 @@ export class WarehouseYardsService { private readonly warehousesService: WarehousesService, ) {} + findAll(): Promise { + return this.yardsRepository.findAll({ + relations: { warehouse: true, zones: true }, + order: { code: 'ASC' }, + }); + } + findByWarehouse(warehouseId: string): Promise { return this.yardsRepository.findAll({ where: { warehouseId }, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts index 30c4407f6..7d51feac3 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts @@ -10,6 +10,12 @@ import { WarehouseZonesService } from './warehouse-zones.service'; export class WarehouseZonesController { constructor(private readonly zonesService: WarehouseZonesService) {} + @Get() + @ApiOperation({ summary: 'List all warehouse zones' }) + findAll() { + return this.zonesService.findAll(); + } + @Get(':id') @ApiOperation({ summary: 'Get warehouse zone by ID' }) findOne(@Param('id', ParseUUIDPipe) id: string) { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.service.ts index a2f3800cd..b4ae2e0de 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.service.ts @@ -13,6 +13,13 @@ export class WarehouseZonesService { private readonly yardsService: WarehouseYardsService, ) {} + findAll(): Promise { + return this.zonesRepository.findAll({ + relations: { yard: { warehouse: true } }, + order: { code: 'ASC' }, + }); + } + findByYard(yardId: string): Promise { return this.zonesRepository.findAll({ where: { yardId }, 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 4a08d7f28..02e3bbb4c 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { ContractPdfService } from '../../contracts/contract-pdf.service'; import { FilesModule } from '../files/files.module'; import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity'; import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity'; @@ -100,6 +101,7 @@ import { WarehousesService } from './warehouses.service'; WarehouseInvoiceService, WarehouseSchedulingAdapterService, SchedulingReadFacade, + ContractPdfService, ], exports: [ WarehousesService, diff --git a/apps/edr-freight-api/src/seed/batch5-test-data.seeder.ts b/apps/edr-freight-api/src/seed/batch5-test-data.seeder.ts index f9e4e2af7..c5a49e629 100644 --- a/apps/edr-freight-api/src/seed/batch5-test-data.seeder.ts +++ b/apps/edr-freight-api/src/seed/batch5-test-data.seeder.ts @@ -102,6 +102,12 @@ export class Batch5TestDataSeeder { serviceTypeId: serviceType.id, status: 'PAID', paymentStatus: 'PAID', + scheduledDate: now, + contractType: 'SPOT', + equipmentReturn: 'TERMINAL', + paymentCurrency: 'ETB', + totalAmount: 0, + isGovernment: false, tradeDirection: 'EXPORT', freightType: 'BULK', cargoTotalWeightVgm: seed.weight, diff --git a/apps/edr-freight-api/src/seed/warehouse-demo.seeder.ts b/apps/edr-freight-api/src/seed/warehouse-demo.seeder.ts index e00544dc0..fbc19100a 100644 --- a/apps/edr-freight-api/src/seed/warehouse-demo.seeder.ts +++ b/apps/edr-freight-api/src/seed/warehouse-demo.seeder.ts @@ -89,6 +89,7 @@ export class WarehouseDemoSeeder { ): Promise => bookingRepo.save( bookingRepo.create({ + ...this.demoBookingDefaults(), reference, originYardId: direction === 'EXPORT' ? ethYard.id : djibYard.id, destinationYardId: direction === 'EXPORT' ? djibYard.id : ethYard.id, @@ -237,6 +238,7 @@ export class WarehouseDemoSeeder { for (let i = 1; i <= 3; i++) { const b = await bookingRepo.save( bookingRepo.create({ + ...this.demoBookingDefaults(), reference: `WH-DEMO-ARR-${i}`, originYardId: djibYard.id, destinationYardId: ethYard.id, @@ -255,4 +257,15 @@ export class WarehouseDemoSeeder { ); } } + + private demoBookingDefaults(): Partial { + return { + scheduledDate: new Date(), + contractType: 'SPOT', + equipmentReturn: 'TERMINAL', + paymentCurrency: 'ETB', + totalAmount: 0, + isGovernment: false, + }; + } } diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 462c97f9a..ba465b962 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -1,705 +1,689 @@ -import { - Boxes, - Building2, - Container, - FileText, - LayoutDashboard, - LayoutGrid, - Network, - Package, - PackageCheck, - PackageOpen, - Paperclip, - Send, - Settings, - ShieldCheck, - SlidersHorizontal, - Train, - Truck, - Users, - Wallet, -} from "lucide-react"; -import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom"; - -import { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@/components/layout"; -import { useAuth } from "./auth/useAuth"; -import LoadingScreen from "./components/LoadingScreen"; -import LoginPage from "./pages/auth/LoginPage"; -import BookingContractPage from "./pages/bookings/BookingContractPage"; -import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage"; -import BookingRequestsPage from "./pages/bookings/BookingRequestsPage"; -import GlClearancePage from "./pages/bookings/GlClearancePage"; -import NewBookingPage from "./pages/bookings/NewBookingPage"; -import CustomerDetailPage from "./pages/customers/CustomerDetailPage"; -import CustomersPage from "./pages/customers/CustomersPage"; -import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page"; -import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page"; -import MyProfilePage from "./pages/dashboard/MyProfilePage"; -import OverviewPage from "./pages/dashboard/OverviewPage"; -import UserManagementHostPage from "./pages/dashboard/user-management/UserManagementHostPage"; -import PaymentsPage from "./pages/payments/PaymentsPage"; -//import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage"; -import { RequirePermission } from "./components/auth/RequirePermission"; -import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "./lib/permissions"; -import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage"; -import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage"; -import RolesPage from "./pages/dashboard/user-management/RolesPage"; -import UserManagementPage from "./pages/dashboard/user-management/UserManagementPage"; -import UsersPage from "./pages/dashboard/user-management/UsersPage"; -import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage"; -import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage"; -import FleetResourcePage from "./pages/fleet/FleetResourcePage"; -import RoutesPage from "./pages/fleet/RoutesPage"; -import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"; -import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect"; -import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage"; -import CargoTypesPage from "./pages/ruleEngine/CargoTypesPage"; -import TrainScheduleV2ListPage from "./pages/trainScheduling/TrainScheduleV2ListPage"; -import BatchBoardPage from "./pages/trainScheduling/BatchBoardPage"; -import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetailPage"; -import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage"; -import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage"; -import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage"; -import FirstMilePage from "./pages/operations/FirstMilePage"; -import LastMilePage from "./pages/operations/LastMilePage"; -import TrainDetailPage from "./pages/trains/TrainDetailPage"; -import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage"; -import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage"; -import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage"; -import LoadedInventoryPage from "./pages/warehouses/LoadedInventoryPage"; -import LoadingQueuePage from "./pages/warehouses/LoadingQueuePage"; -import WarehouseDashboardPage from "./pages/warehouses/WarehouseDashboardPage"; -import WarehouseDetailPage from "./pages/warehouses/WarehouseDetailPage"; -import WarehouseInventoryPage from "./pages/warehouses/WarehouseInventoryPage"; -import WarehouseInvoicesPage from "./pages/warehouses/WarehouseInvoicesPage"; -import WarehouseListPage from "./pages/warehouses/WarehouseListPage"; -import WarehouseRulesPage from "./pages/warehouses/WarehouseRulesPage"; - -const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ - { - title: "Main menu", - items: [ - { - label: "Overview", - href: "/dashboard/overview", - icon: , - }, - { - label: "UM", - href: "/um", - icon: , - }, - { - label: "Booking requests", - href: "/dashboard/booking-requests", - icon: , - }, - { - label: "Customers", - href: "/dashboard/customers", - icon: , - }, - { - label: "Payments", - href: "/dashboard/payments", - icon: , - permission: FREIGHT_PERMS.bookings.view, - }, - ...demoItems, - ], - }, - { - title: "Operations", - items: [ - { - label: "Document Clearance", - href: "/dashboard/clearance", - icon: , - permission: FREIGHT_PERMS.bookings.reviewDocuments, - }, - { - label: "Train Schedules", - href: "/dashboard/operations/train-scheduling-v2", - icon: , - permission: FREIGHT_PERMS.trainScheduling.view, - }, - { - label: "Batch Board", - href: "/dashboard/operations/batch-board", - icon: , - permission: FREIGHT_PERMS.trainScheduling.view, - }, - { - label: "First Mile", - href: "/dashboard/operations/first-mile", - icon: , - permission: FREIGHT_PERMS.trainScheduling.view, - }, - { - label: "Last Mile", - href: "/dashboard/operations/last-mile", - icon: , - permission: FREIGHT_PERMS.trainScheduling.view, - }, - ], - }, - { - title: "Fleet Management", - items: [ - { - label: "Routes", - href: "/dashboard/routes", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, - { - label: "Locomotives", - href: "/dashboard/locomotives", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, - - // { - // label: "Wagon types", - // href: "/dashboard/wagon-types", - // icon: , - // }, - { - label: "Wagons", - href: "/dashboard/wagons", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, - { - label: "Vehicles", - href: "/dashboard/vehicles", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, - { - label: "Drivers", - href: "/dashboard/drivers", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, - // { - // label: "Containers", - // href: "/dashboard/containers", - // icon: , - // }, - // { - // label: "Cargoes", - // href: "/dashboard/cargoes", - // icon: , - // }, - ], - }, - { - title: "Warehouse Management", - items: [ - { - label: "Warehouse Dashboard", - href: "/dashboard/warehouse-dashboard", - icon: , - }, - { - label: "Warehouses", - href: "/dashboard/warehouses", - icon: , - }, - { - label: "Inventory", - href: "/dashboard/warehouse-inventory", - icon: , - }, - { - label: "Arrival Queue", - href: "/dashboard/arrival-queue", - 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", - icon: , - }, - { - label: "Allocation & Fees", - href: "/dashboard/warehouse-rules", - icon: , - }, - { - label: "Fee Invoices", - href: "/dashboard/warehouse-fee-invoices", - icon: , - }, - ], - }, - { - title: "Administration", - items: [ - { - label: "File settings", - href: "/dashboard/file-settings", - icon: , - permission: FREIGHT_PERMS.admin, - }, - { - label: "Dropdown settings", - href: "/dashboard/dropdown-settings", - icon: , - permission: FREIGHT_PERMS.admin, - }, - ], - }, - { - title: "Freight configuration", - mutedTitle: true, - items: [ - { - label: "Configuration", - href: "/dashboard/configuration", - icon: , - children: [ - ...getCategorySidebarChildren("configuration"), - // { - // label: "Train scheduling rules", - // href: "/dashboard/configuration/train-scheduling-rules", - // }, - ], - }, - { - label: "Rules", - href: "/dashboard/rules", - icon: , - children: getCategorySidebarChildren("rules"), - }, - ], - }, -]; - -/** Keep only items the user is permitted to see; drop now-empty sections. */ -const filterSidebarByPermission = ( - sections: SidebarSection[], - user: ReturnType["user"], -): SidebarSection[] => { - const itemAllowed = (item: SidebarItem): boolean => { - if (!item.permission) return true; - const keys = Array.isArray(item.permission) - ? item.permission - : [item.permission]; - return keys.some((key) => hasFreightPermission(user, key)); - }; - - return sections - .map((section) => ({ - ...section, - items: section.items.filter(itemAllowed), - })) - .filter((section) => section.items.length > 0); -}; - -const DashboardShell = () => { - const navigate = useNavigate(); - const location = useLocation(); - const { user, logout } = useAuth(); - - const demoItems: SidebarItem[] = []; - - const sidebarSections = filterSidebarByPermission( - buildSidebarSections(demoItems), - user, - ); - const displayName = user?.name?.en || user?.username || user?.email || "User"; - - return ( - - - - ); -}; - -const App = () => { - const { user, loading } = useAuth(); - - if (loading) { - return ; - } - - if (!user) { - return ( - - } /> - } /> - } /> - - ); - } - - return ( - - } /> - } /> - } /> - }> - } /> - } /> - - } /> - - - - } - /> - } /> - } /> - } /> - } /> - } - /> - - - - } - /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - {/* Legacy embedded user management routes */} - } /> - } /> - } /> - {/* } /> */} - } /> - } /> - - - - - } - /> - - - - } - /> - - } - /> - - - - } - /> - } /> - } /> - } /> - - } - /> - } /> - - } - /> - } /> - - } /> - } /> - - } /> - } /> - - - } /> - - ); -}; - -export default App; +import { + Boxes, + Building2, + Container, + FileText, + LayoutDashboard, + LayoutGrid, + Network, + Package, + PackageCheck, + PackageOpen, + Paperclip, + Send, + Settings, + SlidersHorizontal, + Train, + Truck, + Users, + Wallet, +} from "lucide-react"; +import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom"; + +import { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@/components/layout"; +import { useAuth } from "./auth/useAuth"; +import LoadingScreen from "./components/LoadingScreen"; +import LoginPage from "./pages/auth/LoginPage"; +import BookingContractPage from "./pages/bookings/BookingContractPage"; +import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage"; +import BookingRequestsPage from "./pages/bookings/BookingRequestsPage"; +import NewBookingPage from "./pages/bookings/NewBookingPage"; +import CustomerDetailPage from "./pages/customers/CustomerDetailPage"; +import CustomersPage from "./pages/customers/CustomersPage"; +import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page"; +import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page"; +import MyProfilePage from "./pages/dashboard/MyProfilePage"; +import OverviewPage from "./pages/dashboard/OverviewPage"; +import UserManagementHostPage from "./pages/dashboard/user-management/UserManagementHostPage"; +import PaymentsPage from "./pages/payments/PaymentsPage"; +//import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage"; +import { RequirePermission } from "./components/auth/RequirePermission"; +import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "./lib/permissions"; +import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage"; +import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage"; +import RolesPage from "./pages/dashboard/user-management/RolesPage"; +import UserManagementPage from "./pages/dashboard/user-management/UserManagementPage"; +import UsersPage from "./pages/dashboard/user-management/UsersPage"; +import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage"; +import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage"; +import FleetResourcePage from "./pages/fleet/FleetResourcePage"; +import RoutesPage from "./pages/fleet/RoutesPage"; +import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"; +import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect"; +import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage"; +import CargoTypesPage from "./pages/ruleEngine/CargoTypesPage"; +import TrainScheduleV2ListPage from "./pages/trainScheduling/TrainScheduleV2ListPage"; +import BatchBoardPage from "./pages/trainScheduling/BatchBoardPage"; +import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetailPage"; +import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage"; +import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage"; +import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage"; +import FirstMilePage from "./pages/operations/FirstMilePage"; +import LastMilePage from "./pages/operations/LastMilePage"; +import TrainDetailPage from "./pages/trains/TrainDetailPage"; +import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage"; +import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage"; +import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage"; +import LoadedInventoryPage from "./pages/warehouses/LoadedInventoryPage"; +import LoadingQueuePage from "./pages/warehouses/LoadingQueuePage"; +import WarehouseDashboardPage from "./pages/warehouses/WarehouseDashboardPage"; +import WarehouseDetailPage from "./pages/warehouses/WarehouseDetailPage"; +import WarehouseInventoryPage from "./pages/warehouses/WarehouseInventoryPage"; +import WarehouseInvoicesPage from "./pages/warehouses/WarehouseInvoicesPage"; +import WarehouseListPage from "./pages/warehouses/WarehouseListPage"; +import WarehouseRulesPage from "./pages/warehouses/WarehouseRulesPage"; + +const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ + { + title: "Main menu", + items: [ + { + label: "Overview", + href: "/dashboard/overview", + icon: , + }, + { + label: "UM", + href: "/um", + icon: , + }, + { + label: "Booking requests", + href: "/dashboard/booking-requests", + icon: , + }, + { + label: "Customers", + href: "/dashboard/customers", + icon: , + }, + { + label: "Payments", + href: "/dashboard/payments", + icon: , + permission: FREIGHT_PERMS.bookings.view, + }, + ...demoItems, + ], + }, + { + title: "Operations", + items: [ + { + label: "Train Schedules", + href: "/dashboard/operations/train-scheduling-v2", + icon: , + permission: FREIGHT_PERMS.trainScheduling.view, + }, + { + label: "Batch Board", + href: "/dashboard/operations/batch-board", + icon: , + permission: FREIGHT_PERMS.trainScheduling.view, + }, + { + label: "First Mile", + href: "/dashboard/operations/first-mile", + icon: , + permission: FREIGHT_PERMS.trainScheduling.view, + }, + { + label: "Last Mile", + href: "/dashboard/operations/last-mile", + icon: , + permission: FREIGHT_PERMS.trainScheduling.view, + }, + ], + }, + { + title: "Fleet Management", + items: [ + { + label: "Routes", + href: "/dashboard/routes", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Locomotives", + href: "/dashboard/locomotives", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + + // { + // label: "Wagon types", + // href: "/dashboard/wagon-types", + // icon: , + // }, + { + label: "Wagons", + href: "/dashboard/wagons", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Vehicles", + href: "/dashboard/vehicles", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Drivers", + href: "/dashboard/drivers", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + // { + // label: "Containers", + // href: "/dashboard/containers", + // icon: , + // }, + // { + // label: "Cargoes", + // href: "/dashboard/cargoes", + // icon: , + // }, + ], + }, + { + title: "Warehouse Management", + items: [ + { + label: "Warehouse Dashboard", + href: "/dashboard/warehouse-dashboard", + icon: , + }, + { + label: "Warehouses", + href: "/dashboard/warehouses", + icon: , + }, + { + label: "Inventory", + href: "/dashboard/warehouse-inventory", + icon: , + }, + { + label: "Arrival Queue", + href: "/dashboard/arrival-queue", + 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", + icon: , + }, + { + label: "Allocation & Fees", + href: "/dashboard/warehouse-rules", + icon: , + }, + { + label: "Fee Invoices", + href: "/dashboard/warehouse-fee-invoices", + icon: , + }, + ], + }, + { + title: "Administration", + items: [ + { + label: "File settings", + href: "/dashboard/file-settings", + icon: , + permission: FREIGHT_PERMS.admin, + }, + { + label: "Dropdown settings", + href: "/dashboard/dropdown-settings", + icon: , + permission: FREIGHT_PERMS.admin, + }, + ], + }, + { + title: "Freight configuration", + mutedTitle: true, + items: [ + { + label: "Configuration", + href: "/dashboard/configuration", + icon: , + children: [ + ...getCategorySidebarChildren("configuration"), + // { + // label: "Train scheduling rules", + // href: "/dashboard/configuration/train-scheduling-rules", + // }, + ], + }, + { + label: "Rules", + href: "/dashboard/rules", + icon: , + children: getCategorySidebarChildren("rules"), + }, + ], + }, +]; + +/** Keep only items the user is permitted to see; drop now-empty sections. */ +const filterSidebarByPermission = ( + sections: SidebarSection[], + user: ReturnType["user"], +): SidebarSection[] => { + const itemAllowed = (item: SidebarItem): boolean => { + if (!item.permission) return true; + const keys = Array.isArray(item.permission) + ? item.permission + : [item.permission]; + return keys.some((key) => hasFreightPermission(user, key)); + }; + + return sections + .map((section) => ({ + ...section, + items: section.items.filter(itemAllowed), + })) + .filter((section) => section.items.length > 0); +}; + +const DashboardShell = () => { + const navigate = useNavigate(); + const location = useLocation(); + const { user, logout } = useAuth(); + + const demoItems: SidebarItem[] = []; + + const sidebarSections = filterSidebarByPermission( + buildSidebarSections(demoItems), + user, + ); + const displayName = user?.name?.en || user?.username || user?.email || "User"; + + return ( + + + + ); +}; + +const App = () => { + const { user, loading } = useAuth(); + + if (loading) { + return ; + } + + if (!user) { + return ( + + } /> + } /> + } /> + + ); + } + + return ( + + } /> + } /> + } /> + }> + } /> + } /> + + } /> + + + + } + /> + } /> + } /> + } /> + } /> + } + /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + {/* Legacy embedded user management routes */} + } /> + } /> + } /> + {/* } /> */} + } /> + } /> + + + + + } + /> + + + + } + /> + + } + /> + + + + } + /> + } /> + } /> + } /> + + } + /> + } /> + + } + /> + } /> + + } /> + } /> + + } /> + } /> + + + } /> + + ); +}; + +export default App; diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/OperationsBookingQueue.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/OperationsBookingQueue.tsx index ae360c609..bb40823d8 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/OperationsBookingQueue.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/OperationsBookingQueue.tsx @@ -1,77 +1,16 @@ -import { useMemo, useState } from "react"; -import { ArrowRight, Building2, Package } from "lucide-react"; -import { - Accordion, - Badge, - Button, - Checkbox, - Group, - Paper, - Stack, - Text, - Title, -} from "@mantine/core"; +import { useCallback, useRef } from "react"; +import { useNavigate } from "react-router-dom"; +import { ArrowRight, Calendar, Package, User } from "lucide-react"; +import { Group } from "@mantine/core"; +import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu"; import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge"; -import { canAllocateBooking } from "@/features/bookings/booking-actions.config"; +import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; +import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge"; +import { bookingTable } from "@/components/bookings/booking-ui.styles"; import type { BookingListRow } from "@/types/booking"; -import { groupBookingsForOperationsQueue } from "@/utils/groupBookingsForOperationsQueue"; - -function BookingQueueRow({ - booking, - selected, - disabled, - onToggle, -}: { - booking: BookingListRow; - selected: boolean; - disabled: boolean; - onToggle: () => void; -}) { - return ( - - - - - - {booking.reference} - {booking.isGovernment ? ( - }> - Government - - ) : null} - {booking.freightType} - {booking.schedulingStatus ? ( - {booking.schedulingStatus} - ) : null} - - {booking.customerLabel} - - {booking.originLabel} - - {booking.destinationLabel} - - - - {booking.serviceTypeLabel ? ( - - {booking.serviceTypeLabel} - {booking.serviceTypeBonus ? ` (+${booking.serviceTypeBonus} bonus)` : ""} - - ) : null} - - - - ); -} +import { cn } from "@/lib/utils"; +import { Badge, DataTable, type ColumnDef } from "@edr/ui-common"; export function OperationsBookingQueue({ bookings, @@ -82,144 +21,144 @@ export function OperationsBookingQueue({ isLoading?: boolean; onAllocate: (bookingIds: string[]) => void; }) { - const { government, commercial } = useMemo( - () => groupBookingsForOperationsQueue(bookings), - [bookings], + const navigate = useNavigate(); + const suppressRowClickRef = useRef(false); + + const suppressRowClick = useCallback(() => { + suppressRowClickRef.current = true; + window.setTimeout(() => { + suppressRowClickRef.current = false; + }, 400); + }, []); + + const handleRowClick = useCallback( + (row: BookingListRow) => { + if (suppressRowClickRef.current) return; + navigate(`/dashboard/booking-requests/${row.id}`); + }, + [navigate], ); - const [govSelected, setGovSelected] = useState([]); - const [selectedByBucket, setSelectedByBucket] = useState>({}); - const allocatable = (row: BookingListRow) => - row.status === "PAID" && - canAllocateBooking({ status: row.status, schedulingStatus: row.schedulingStatus }); - - const govSelection = govSelected.length - ? govSelected - : government.filter(allocatable).map((b) => b.id); - - const bucketSelection = (bucketKey: string, bucketBookings: BookingListRow[]) => { - const existing = selectedByBucket[bucketKey]; - if (existing) return existing; - return bucketBookings.filter(allocatable).map((b) => b.id); - }; - - const toggleGov = (bookingId: string) => { - setGovSelected((prev) => { - const base = prev.length ? prev : government.filter(allocatable).map((b) => b.id); - return base.includes(bookingId) - ? base.filter((id) => id !== bookingId) - : [...base, bookingId]; - }); - }; - - const toggleBucket = (bucketKey: string, bookingId: string) => { - setSelectedByBucket((prev) => { - const current = prev[bucketKey] ?? []; - const next = current.includes(bookingId) - ? current.filter((id) => id !== bookingId) - : [...current, bookingId]; - return { ...prev, [bucketKey]: next }; - }); - }; - - if (isLoading) { - return Loading operations queue…; - } - - if (!government.length && !commercial.length) { - return ( - - No PAID bookings ready to allocate. - - ); - } + const columns: ColumnDef[] = [ + { + id: "booking", + header: () => Booking, + cell: ({ row }) => { + const booking = row.original; + return ( +
+
+ +
+
+ +

{booking.reference}

+ {booking.isGovernment ? ( + + Government + + ) : null} +
+

+ + {booking.customerLabel} +

+
+
+ ); + }, + }, + { + id: "route", + header: () => Route, + cell: ({ row }) => { + const booking = row.original; + return ( +
+
+ {booking.originLabel} + + {booking.destinationLabel} +
+
+ + {booking.tradeDirection} + + + {booking.freightType} + +
+
+ ); + }, + }, + { + id: "status", + header: () => Status, + cell: ({ row }) => ( +
+ + {row.original.schedulingStatus ? ( + + ) : null} +
+ ), + }, + { + id: "scheduled", + header: () => Scheduled, + cell: ({ row }) => ( + + + {row.original.scheduledDate} + + ), + }, + { + id: "priority", + header: () => Priority, + cell: ({ row }) => , + }, + { + id: "amount", + header: () => Amount, + cell: ({ row }) => ( + + {row.original.paymentCurrency}{" "} + {row.original.totalAmount.toLocaleString(undefined, { + minimumFractionDigits: 2, + })} + + ), + }, + { + id: "actions", + header: () => Actions, + cell: ({ row }) => ( + onAllocate([row.original.id])} + /> + ), + }, + ]; return ( - - {government.length > 0 ? ( - - - - Government priority - - Served first — not grouped by 3-hour window - - - - {govSelection.length} selected - - - - - {government.map((booking) => ( - toggleGov(booking.id)} - /> - ))} - - - ) : null} - - {commercial.length > 0 ? ( - - {commercial.map((bucket) => { - const selected = bucketSelection(bucket.key, bucket.bookings); - return ( - - - - - {bucket.label} - - {bucket.bookings.length} commercial booking - {bucket.bookings.length === 1 ? "" : "s"} - - - - {selected.length} selected - - - - - - - {bucket.bookings.map((booking) => ( - toggleBucket(bucket.key, booking.id)} - /> - ))} - - - - ); - })} - - ) : null} - + ); } diff --git a/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts b/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts index cb1172def..b400ddba4 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts +++ b/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts @@ -106,6 +106,34 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [ subtitle: "Manage route definitions built from freight yards", }, }, + { + prefix: "/dashboard/warehouses/list", + meta: { + title: "Warehouses", + subtitle: "Manage warehouses, yards, and zones", + }, + }, + { + prefix: "/dashboard/warehouse-inventory", + meta: { + title: "Warehouse inventory", + subtitle: "Track received items through inspection and loading", + }, + }, + { + prefix: "/dashboard/inventory-inquiry", + meta: { + title: "Inventory inquiry", + subtitle: "Locate cargo, containers, and goods inside the warehouse network", + }, + }, + { + prefix: "/dashboard/warehouses", + meta: { + title: "Warehouse dashboard", + subtitle: "Live overview of warehouse capacity and inventory lifecycle", + }, + }, ...getFleetRouteMeta(), { prefix: "/dashboard/trains/", diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx index 73ea669be..56a0d950c 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx @@ -5,8 +5,10 @@ import { useMutation, useQuery } from '@tanstack/react-query'; import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; +import { warehouseService } from '@/services/warehouse.service'; import { extractErrorMessage } from './options'; -import type { FeePreview, WarehouseInvoiceStatus } from '@/types/warehouse'; +import type { FeePreview, WarehouseInventoryItem, WarehouseInvoiceStatus } from '@/types/warehouse'; +import { openPdfBlob } from './pdf'; const INVOICE_STATUS_COLOR: Record = { DRAFT: 'gray', @@ -32,6 +34,9 @@ function fmtDate(iso: string | null) { return new Date(iso).toLocaleDateString(); } +const money = (amount: number, currency: string) => + `${Number(amount).toLocaleString()} ${currency === 'ETB' ? 'Birr (ETB)' : currency}`; + function FeeCard({ fee }: { fee: FeePreview }) { const meta = LABELS[fee.ruleType] ?? { label: fee.ruleType, color: 'gray' }; const configured = Boolean(fee.ruleId); @@ -48,7 +53,7 @@ function FeeCard({ fee }: { fee: FeePreview }) { )} - {fee.amount.toLocaleString()} {fee.currency} + {money(fee.amount, fee.currency)} @@ -60,10 +65,12 @@ function FeeCard({ fee }: { fee: FeePreview }) { - + + + )} @@ -106,7 +113,7 @@ export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModa if (!inventoryId) return; try { const inv = await generate.mutateAsync({ inventoryId, confirmZero }); - toast({ title: 'Invoice generated', description: `${inv.invoiceNumber} — ${inv.totalAmount} ${inv.currency}` }); + toast({ title: 'Invoice generated', description: `${inv.invoiceNumber} - ${money(inv.totalAmount, inv.currency)}` }); } catch (error) { const msg = extractErrorMessage(error); if (/no payable warehouse fee/i.test(msg)) { @@ -121,11 +128,21 @@ export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModa const handleGateClearance = async () => { if (!inventoryId) return; + const pdfWindow = window.open('', '_blank'); try { - await gateClear.mutateAsync(inventoryId); - toast({ title: 'Gate clearance recorded', description: 'Item released from terminal.' }); + const releasedItem = await gateClear.mutateAsync(inventoryId) as WarehouseInventoryItem; + const documentResponse = await warehouseService.downloadReleaseDocument(inventoryId); + const filename = `release-${releasedItem?.booking?.reference ?? releasedItem?.bookingId ?? inventoryId}.pdf`; + const opened = openPdfBlob(documentResponse.data, filename, pdfWindow); + toast({ + title: 'Gate clearance recorded', + description: opened + ? 'The release PDF opened in a browser tab.' + : 'The browser blocked the preview tab, so the PDF was downloaded.', + }); onClose(); } catch (error) { + pdfWindow?.close(); toast({ variant: 'destructive', title: 'Release blocked', description: extractErrorMessage(error) }); } }; @@ -165,7 +182,7 @@ export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModa - {Number(activeInvoice.balanceAmount).toLocaleString()} {activeInvoice.currency} due + {money(Number(activeInvoice.balanceAmount), activeInvoice.currency)} due ) : ( diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/InspectionReportModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/InspectionReportModal.tsx index d56547455..2c9098ad3 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/InspectionReportModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/InspectionReportModal.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { Button, Divider, @@ -12,10 +12,8 @@ import { } from '@mantine/core'; import { Upload } from 'lucide-react'; -import { useMutation } from '@tanstack/react-query'; - -import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; +import { useCreateInspectionReport, useInspectionReports, useUploadInspectionAttachments } from '@/hooks/useWarehouses'; import { INSPECTION_REPORT_TYPES, INSPECTION_STATUSES, @@ -47,12 +45,9 @@ const STATUS_LABELS: Record = { /** Batch 4.5 — record an inspection / damage report with optional image upload. */ export function InspectionReportModal({ opened, onClose, inventoryId }: InspectionReportModalProps) { const { toast } = useToast(); - const createReport = useMutation( - api.warehouses.createInspectionReport.mutationOptions(), - ); - const uploadAttachments = useMutation( - api.warehouses.uploadInspectionAttachments.mutationOptions(), - ); + const createReport = useCreateInspectionReport(); + const uploadAttachments = useUploadInspectionAttachments(); + const reportsQuery = useInspectionReports(opened ? inventoryId ?? undefined : undefined); const [reportType, setReportType] = useState('INSPECTION'); const [inspectionStatus, setInspectionStatus] = useState('PASSED'); @@ -82,6 +77,27 @@ export function InspectionReportModal({ opened, onClose, inventoryId }: Inspecti setFiles([]); }; + useEffect(() => { + if (!opened) return; + const report = reportsQuery.data?.[0]; + if (!report) { + reset(); + return; + } + + setReportType(report.reportType); + setInspectionStatus(report.inspectionStatus); + setHasDamage(report.hasDamage ?? false); + setDamageDescription(report.damageDescription ?? ''); + setHasWeightLoss(report.hasWeightLoss ?? false); + setExpectedWeight(report.expectedWeight == null ? '' : Number(report.expectedWeight)); + setActualWeight(report.actualWeight == null ? '' : Number(report.actualWeight)); + setHasMissingItems(report.hasMissingItems ?? false); + setMissingItemsDescription(report.missingItemsDescription ?? ''); + setRemarks(report.remarks ?? ''); + setFiles([]); + }, [opened, reportsQuery.data]); + const handleSubmit = async () => { if (!inventoryId) return; try { diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryDetailModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryDetailModal.tsx new file mode 100644 index 000000000..ad079577b --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryDetailModal.tsx @@ -0,0 +1,91 @@ +import { Badge, Divider, Group, Modal, SimpleGrid, Stack, Text } from '@mantine/core'; + +import type { WarehouseInventoryItem } from '@/types/warehouse'; +import { InventoryStatusBadge } from './badges'; +import { formatDate, formatNumber } from './options'; + +interface InventoryDetailModalProps { + opened: boolean; + onClose: () => void; + item: WarehouseInventoryItem | null; +} + +function DetailRow({ label, value }: { label: string; value: React.ReactNode }) { + return ( + + + {label} + + + {value || '-'} + + + ); +} + +export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailModalProps) { + return ( + + {!item ? ( + No inventory item selected. + ) : ( + + + + + {item.booking?.reference ?? item.bookingId ?? item.id} + + + Inventory ID: {item.id} + + + + + + + + + + + + + + + + + + + + + + + + + + + + {item.inspectionStatus ?? 'Not inspected'}} /> + + + + + + + + + + + + + + {item.notes && ( + <> + + {item.notes} + + )} + + )} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryInquiryDetailModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryInquiryDetailModal.tsx new file mode 100644 index 000000000..479249894 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryInquiryDetailModal.tsx @@ -0,0 +1,92 @@ +import { Badge, Divider, Group, Modal, SimpleGrid, Stack, Text } from '@mantine/core'; + +import type { InventoryInquiryResult } from '@/types/warehouse'; +import { InventoryStatusBadge } from './badges'; +import { formatDate, formatNumber } from './options'; + +interface InventoryInquiryDetailModalProps { + opened: boolean; + onClose: () => void; + result: InventoryInquiryResult | null; +} + +function DetailRow({ label, value }: { label: string; value: React.ReactNode }) { + return ( + + + {label} + + + {value || '-'} + + + ); +} + +function itemLabel(result: InventoryInquiryResult) { + if (result.containerNumber) return `Container ${result.containerNumber}`; + if (result.cargoType) return result.cargoType; + if (result.cargoDescription) return result.cargoDescription; + if (result.goodsId) return `Goods ${result.goodsId}`; + return '-'; +} + +export function InventoryInquiryDetailModal({ opened, onClose, result }: InventoryInquiryDetailModalProps) { + return ( + + {!result ? ( + No inquiry result selected. + ) : ( + + + + + {result.bookingReference ?? result.bookingNumber ?? result.bookingId ?? result.id} + + + Inventory ID: {result.inventoryId ?? 'Not yet in warehouse inventory'} + + + {result.status ? ( + + ) : ( + + {result.trainStatus ?? result.bookingStatus ?? 'Not in warehouse'} + + )} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + )} + + ); +} 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 b076510ab..c69696bec 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx @@ -6,10 +6,12 @@ import { useMutation } from '@tanstack/react-query'; import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; +import { warehouseService } from '@/services/warehouse.service'; import type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse'; import { DeliverInventoryModal } from './DeliverInventoryModal'; import { FeePreviewModal } from './FeePreviewModal'; import { InspectionReportModal } from './InspectionReportModal'; +import { InventoryDetailModal } from './InventoryDetailModal'; import { InventoryHistoryModal } from './InventoryHistoryModal'; import { LoadInventoryModal } from './LoadInventoryModal'; import { MoveInventoryModal } from './MoveInventoryModal'; @@ -17,6 +19,7 @@ import { ReleaseOrderModal } from './ReleaseOrderModal'; import { ReserveInventoryModal } from './ReserveInventoryModal'; import { WarehouseInventoryTable } from './WarehouseInventoryTable'; import { extractErrorMessage } from './options'; +import { openPdfBlob } from './pdf'; interface InventoryWorkbenchProps { items: WarehouseInventoryItem[]; @@ -33,6 +36,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo const [reserveItem, setReserveItem] = useState(null); const [loadItem, setLoadItem] = useState(null); const [historyItem, setHistoryItem] = useState(null); + const [viewItem, setViewItem] = useState(null); const [inspectItem, setInspectItem] = useState(null); const [feeItem, setFeeItem] = useState(null); const [releaseItem, setReleaseItem] = useState(null); @@ -91,10 +95,46 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo } }; + const downloadReleaseDocument = async (item: WarehouseInventoryItem) => { + setBusyId(item.id); + const pdfWindow = window.open('', '_blank'); + try { + const response = await warehouseService.downloadReleaseDocument(item.id); + const blob = response.data; + const filename = `release-${item.booking?.reference ?? item.bookingId ?? item.id}.pdf`; + const opened = openPdfBlob(blob, filename, pdfWindow); + toast({ title: opened ? 'Release exit paper opened' : 'Release exit paper downloaded' }); + } catch (error) { + pdfWindow?.close(); + toast({ + variant: 'destructive', + title: 'Release paper preview failed', + description: extractErrorMessage(error), + }); + } finally { + setBusyId(null); + } + }; + + const storeInventory = async (item: WarehouseInventoryItem) => { + setBusyId(item.id); + try { + const stored = await storeMutation.mutateAsync(item.id); + toast({ + title: 'Inventory stored', + description: [stored.warehouse?.code, stored.yard?.code, stored.zone?.code].filter(Boolean).join(' / '), + }); + } catch (error) { + toast({ variant: 'destructive', title: 'Store failed', description: extractErrorMessage(error) }); + } finally { + setBusyId(null); + } + }; + const advance = (item: WarehouseInventoryItem, action: InventoryAction) => { switch (action) { case 'store': - return runDirect(item, () => storeMutation.mutateAsync(item.id), 'Inventory stored'); + return storeInventory(item); case 'reserve': setReserveItem(item); return; @@ -151,8 +191,10 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo onAdvance={advance} onMove={setMoveItem} onHistory={setHistoryItem} + onView={setViewItem} onInspect={setInspectItem} onFeePreview={setFeeItem} + onReleaseDocument={downloadReleaseDocument} onLastMile={onLastMile} selectedIds={selected} onToggleSelect={toggleSelect} @@ -174,6 +216,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo onClose={() => setHistoryItem(null)} item={historyItem} /> + setViewItem(null)} item={viewItem} /> setInspectItem(null)} diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx index f2edd620b..7cbac0e6d 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx @@ -6,8 +6,10 @@ import { useMutation } from '@tanstack/react-query'; import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; +import { warehouseService } from '@/services/warehouse.service'; import type { WarehouseInventoryItem } from '@/types/warehouse'; import { extractErrorMessage } from './options'; +import { openPdfBlob } from './pdf'; interface ReleaseOrderModalProps { opened: boolean; @@ -19,6 +21,7 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr const { toast } = useToast(); const releaseMutation = useMutation(api.warehouses.release.mutationOptions()); const [reference, setReference] = useState(''); + const [downloading, setDownloading] = useState(false); useEffect(() => { if (opened) setReference(item?.releaseOrderReference ?? ''); @@ -26,36 +29,53 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr const handleSubmit = async () => { if (!item) return; + const pdfWindow = window.open('', '_blank'); try { - await releaseMutation.mutateAsync({ id: item.id, payload: { reference: reference.trim() || undefined } }); - toast({ title: 'Release order issued' }); + const released = await releaseMutation.mutateAsync({ + id: item.id, + payload: { reference: reference.trim() || undefined }, + }); + setDownloading(true); + const response = await warehouseService.downloadReleaseDocument(item.id); + const blob = response.data; + const filename = `release-${released.booking?.reference ?? released.bookingId ?? item.id}.pdf`; + const opened = openPdfBlob(blob, filename, pdfWindow); + toast({ + title: 'Release exit paper issued', + description: opened + ? 'The PDF opened in a browser tab for printing or saving.' + : 'The browser blocked the preview tab, so the PDF was downloaded.', + }); onClose(); } catch (error) { + pdfWindow?.close(); toast({ variant: 'destructive', title: 'Release failed', description: extractErrorMessage(error) }); + } finally { + setDownloading(false); } }; return ( - + } color="orange" variant="light"> - Records the delivery order / release order sent to the customer. Once issued, the goods can be - picked up and delivered. + Creates the warehouse release document with booking, customer, cargo and location details. The + printed paper authorizes the goods to leave the warehouse gate. setReference(e.currentTarget.value)} /> - - diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseCardView.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseCardView.tsx index bd76b5e10..c6baed7ca 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseCardView.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseCardView.tsx @@ -1,6 +1,6 @@ import { useMemo } from 'react'; -import { ActionIcon, Card, Group, SimpleGrid, Stack, Text } from '@mantine/core'; -import { Building2, Eye, MapPin, Pencil } from 'lucide-react'; +import { ActionIcon, Box, Card, Divider, Group, Progress, SimpleGrid, Stack, Text, Tooltip } from '@mantine/core'; +import { Building2, Eye, MapPin, Package, Pencil, Weight } from 'lucide-react'; import { useQuery } from '@tanstack/react-query'; @@ -35,56 +35,101 @@ export function WarehouseCardView({ warehouses, onView, onEdit }: WarehouseCardV return ( {warehouses.map((warehouse) => ( - - + + + -
- {warehouse.name} - - {warehouse.code} - -
- -
- - - - - - {warehouse.stationId && stationNameById.get(warehouse.stationId) && ( - - - {stationNameById.get(warehouse.stationId)} + + + + + + + {warehouse.name} + + + {warehouse.code} + + - )} - {warehouse.locationName && ( - - - {warehouse.locationName} - - )} - - - - Weight - - {formatCapacity(warehouse.currentWeight, warehouse.capacityWeight)} - - - - Containers - - {formatCapacity(warehouse.currentContainers, warehouse.capacityContainers)} + + + + - - onView(warehouse)} title="View"> - - - onEdit(warehouse)} title="Edit"> - - + + {warehouse.stationId && stationNameById.get(warehouse.stationId) && ( + + + + {stationNameById.get(warehouse.stationId)} + + + )} + + {warehouse.locationName && ( + + + + {warehouse.locationName} + + + )} + + + + + + + + + + + + onView(warehouse)} aria-label="View warehouse"> + + + + + onEdit(warehouse)} aria-label="Edit warehouse"> + + +
@@ -92,3 +137,40 @@ export function WarehouseCardView({ warehouses, onView, onEdit }: WarehouseCardV
); } + +const capacityPercent = (current?: number | null, capacity?: number | null) => { + if (!capacity || capacity <= 0) return 0; + return Math.min(100, Math.max(0, ((current ?? 0) / capacity) * 100)); +}; + +function CapacityRow({ + icon: Icon, + label, + current, + capacity, +}: { + icon: typeof Weight; + label: string; + current?: number | null; + capacity?: number | null; +}) { + const percent = capacityPercent(current, capacity); + const color = percent >= 90 ? 'red' : percent >= 70 ? 'orange' : 'green'; + + return ( + + + + + + {label} + + + + {formatCapacity(Number(current) || 0, capacity)} + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInquiryTable.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInquiryTable.tsx index 366a3950c..ecae5c2e8 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInquiryTable.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInquiryTable.tsx @@ -1,5 +1,5 @@ -import { Stack, Text } from '@mantine/core'; -import { DataTable, type ColumnDef } from '@edr/ui-common'; +import { ActionIcon, Badge, Stack, Table, Text, Tooltip } from '@mantine/core'; +import { Eye } from 'lucide-react'; import type { InventoryInquiryResult } from '@/types/warehouse'; import { InventoryStatusBadge } from './badges'; @@ -7,71 +7,111 @@ import { formatDate, formatNumber } from './options'; interface WarehouseInquiryTableProps { results: InventoryInquiryResult[]; + onView?: (result: InventoryInquiryResult) => void; } +const dash = '-'; + const itemDescriptor = (result: InventoryInquiryResult) => { if (result.containerNumber) return `Container ${result.containerNumber}`; - if (result.cargoType) return `Cargo · ${result.cargoType}`; - if (result.cargoDescription) return `Cargo · ${result.cargoDescription}`; + if (result.cargoType) return `Cargo - ${result.cargoType}`; + if (result.cargoDescription) return `Cargo - ${result.cargoDescription}`; if (result.goodsId) return 'Goods'; - return '—'; + return dash; }; -const columns: ColumnDef[] = [ - { - id: 'booking', - header: 'Booking', - cell: ({ row }) => ( - - {row.original.bookingNumber ?? row.original.bookingId.slice(0, 8)} +export function WarehouseInquiryTable({ results, onView }: WarehouseInquiryTableProps) { + if (results.length === 0) { + return ( + + No matching items. Adjust your search to locate cargo, containers or goods. - ), - }, - { id: 'customer', header: 'Customer', cell: ({ row }) => row.original.customerName ?? '—' }, - { id: 'item', header: 'Item', cell: ({ row }) => itemDescriptor(row.original) }, - { - id: 'warehouse', - header: 'Warehouse', - cell: ({ row }) => ( - - {row.original.warehouse?.name ?? '—'} - {row.original.warehouse?.code && ( - - {row.original.warehouse.code} - - )} - - ), - }, - { id: 'yard', header: 'Yard', cell: ({ row }) => row.original.yard?.name ?? '—' }, - { id: 'zone', header: 'Zone', cell: ({ row }) => row.original.zone?.name ?? '—' }, - { - id: 'status', - header: 'Status', - cell: ({ row }) => , - }, - { id: 'qty', header: 'Qty', cell: ({ row }) => formatNumber(row.original.quantity) }, - { id: 'weight', header: 'Weight', cell: ({ row }) => formatNumber(row.original.weight) }, - { - id: 'arrived', - header: 'Arrived', - cell: ({ row }) => {formatDate(row.original.arrivedAt)}, - }, - { - id: 'ready', - header: 'Ready', - cell: ({ row }) => {formatDate(row.original.readyForLoadingAt)}, - }, -]; + ); + } -export function WarehouseInquiryTable({ results }: WarehouseInquiryTableProps) { return ( - + + + + + Booking + Customer + Item + Warehouse + Yard + Zone + Location + Status + Qty + Weight + Arrived + Ready + {onView ? Actions : null} + + + + {results.map((result) => ( + + + + {result.bookingReference ?? result.bookingNumber ?? result.bookingId?.slice(0, 8) ?? dash} + + + {result.customerName ?? dash} + {itemDescriptor(result)} + + + {result.warehouse?.name ?? dash} + {result.warehouse?.code ? ( + + {result.warehouse.code} + + ) : null} + + + {result.yard?.name ?? dash} + {result.zone?.name ?? dash} + + + {result.locationSummary ?? dash} + {result.trainNumber ? ( + + {result.trainNumber} + {result.route ? ` - ${result.route}` : ''} + + ) : null} + + + + {result.status ? ( + + ) : ( + + {result.trainStatus ?? result.bookingStatus ?? 'Not in warehouse'} + + )} + + {formatNumber(result.quantity)} + {formatNumber(result.weight)} + + {formatDate(result.arrivedAt)} + + + {formatDate(result.readyForLoadingAt)} + + {onView ? ( + + + onView(result)} ml="auto"> + + + + + ) : null} + + ))} + +
+
); } diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx index 5598870c4..272d9bc62 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx @@ -1,15 +1,13 @@ -import { DataTable, type ColumnDef } from "@edr/ui-common"; -import { ActionIcon, Badge, Button, Group, Text, Tooltip } from "@mantine/core"; -import { ArrowRightLeft, ClipboardList, Coins, History } from "lucide-react"; -import { useMemo } from "react"; +import { ActionIcon, Badge, Button, Checkbox, Group, Table, Text, Tooltip } from '@mantine/core'; +import { ArrowRightLeft, ClipboardList, Coins, Eye, FileText, History, MapPin } from 'lucide-react'; import { - INVENTORY_NEXT_ACTION, - type InventoryAction, - type WarehouseInventoryItem, -} from "@/types/warehouse"; -import { InventoryStatusBadge } from "./badges"; -import { formatDate, formatNumber, humanizeEnum } from "./options"; + getNextInventoryAction, + type InventoryAction, + type WarehouseInventoryItem, +} from '@/types/warehouse'; +import { InventoryStatusBadge } from './badges'; +import { formatDate, formatNumber, humanizeEnum } from './options'; interface WarehouseInventoryTableProps { items: WarehouseInventoryItem[]; @@ -17,11 +15,11 @@ interface WarehouseInventoryTableProps { onAdvance: (item: WarehouseInventoryItem, action: InventoryAction) => void; onMove: (item: WarehouseInventoryItem) => void; onHistory: (item: WarehouseInventoryItem) => void; + onView?: (item: WarehouseInventoryItem) => void; onInspect?: (item: WarehouseInventoryItem) => void; onFeePreview?: (item: WarehouseInventoryItem) => void; - // Optional Last Mile action — only rendered for items whose booking requested door delivery. + onReleaseDocument?: (item: WarehouseInventoryItem) => void; onLastMile?: (item: WarehouseInventoryItem) => void; - // Optional row selection (used for bulk Mark-as-Inspected). selectedIds?: Set; onToggleSelect?: (id: string) => void; onToggleSelectAll?: () => void; @@ -30,21 +28,21 @@ interface WarehouseInventoryTableProps { } const itemKind = (item: WarehouseInventoryItem) => { - if (item.containerId) return { label: "Container", color: "blue" }; - if (item.cargoId) return { label: "Cargo", color: "grape" }; - if (item.goodsId) return { label: "Goods", color: "orange" }; - return { label: "—", color: "gray" }; + if (item.containerId) return { label: 'Container', color: 'blue' }; + if (item.cargoId) return { label: 'Cargo', color: 'grape' }; + if (item.goodsId) return { label: 'Goods', color: 'orange' }; + return { label: '-', color: 'gray' }; }; const actionColor: Record = { - store: "blue", - reserve: "grape", - "ready-for-loading": "cyan", - load: "teal", - dispatch: "edr-green", - "ready-for-pickup": "orange", - release: "yellow", - deliver: "green", + store: 'blue', + reserve: 'grape', + 'ready-for-loading': 'cyan', + load: 'teal', + dispatch: 'edr-green', + 'ready-for-pickup': 'orange', + release: 'yellow', + deliver: 'green', }; export function WarehouseInventoryTable({ @@ -53,165 +51,195 @@ export function WarehouseInventoryTable({ onAdvance, onMove, onHistory, + onView, onInspect, onFeePreview, + onReleaseDocument, + onLastMile, + selectedIds, + onToggleSelect, + onToggleSelectAll, + allSelected, + someSelected, }: WarehouseInventoryTableProps) { - const columns = useMemo[]>( - () => [ - { - id: "booking", - header: "Booking", - cell: ({ row }) => - row.original.bookingId ? ( - - - {row.original.bookingId.slice(0, 8)}… - - - ) : ( - - — - - ), - }, - { - id: "facility", - header: "Facility", - cell: ({ row }) => row.original.warehouse?.facility?.name ?? "—", - }, - { - id: "warehouse", - header: "Warehouse", - cell: ({ row }) => row.original.warehouse?.code ?? "—", - }, - { - id: "yard", - header: "Yard", - cell: ({ row }) => row.original.yard?.code ?? "—", - }, - { - id: "zone", - header: "Zone", - cell: ({ row }) => row.original.zone?.code ?? "—", - }, - { - id: "item", - header: "Item", - cell: ({ row }) => { - const kind = itemKind(row.original); - return ( - - {kind.label} - - ); - }, - }, - { - id: "qty", - header: "Qty", - cell: ({ row }) => formatNumber(row.original.quantity), - }, - { - id: "weight", - header: "Weight", - cell: ({ row }) => formatNumber(row.original.weight), - }, - { - id: "status", - header: "Status", - cell: ({ row }) => ( - - ), - }, - { - id: "arrived", - header: "Arrived", - cell: ({ row }) => ( - {formatDate(row.original.arrivedAt)} - ), - }, - { - id: "actions", - header: "", - cell: ({ row }) => { - const item = row.original; - const busy = busyId === item.id; - const nextAction = INVENTORY_NEXT_ACTION[item.status]; - return ( - e.stopPropagation()} - > - {nextAction && ( - - )} - {item.status !== "DISPATCHED" && ( - - onMove(item)} - > - - - - )} - {onInspect && ( - - onInspect(item)} - > - - - - )} - {onFeePreview && ( - - onFeePreview(item)} - > - - - - )} - - onHistory(item)} - > - - - - - ); - }, - }, - ], - [busyId, onAdvance, onMove, onHistory, onInspect, onFeePreview], - ); + const selectable = Boolean(onToggleSelect); + + if (items.length === 0) { + return ( + + No inventory items found. + + ); + } return ( - + + + + + {selectable && ( + + + + )} + Booking + Facility + Warehouse + Yard + Zone + Item + Qty + Weight + Status + Arrived + Actions + + + + {items.map((item) => { + const kind = itemKind(item); + const busy = busyId === item.id; + const nextAction = getNextInventoryAction(item); + + return ( + + {selectable && ( + + onToggleSelect?.(item.id)} + /> + + )} + + {item.bookingId ? ( + + + {item.bookingId.slice(0, 8)}... + + + ) : ( + + - + + )} + + {item.warehouse?.facility?.name ?? '-'} + {item.warehouse?.code ?? '-'} + {item.yard?.code ?? '-'} + {item.zone?.code ?? '-'} + + + {kind.label} + + + {formatNumber(item.quantity)} + {formatNumber(item.weight)} + + + + + {formatDate(item.arrivedAt)} + + + + {onView && ( + + onView(item)}> + + + + )} + {nextAction && ( + + )} + {item.status === 'READY_FOR_PICKUP' && ( + <> + + + + )} + {item.status !== 'DISPATCHED' && ( + + onMove(item)}> + + + + )} + {onInspect && ( + + onInspect(item)}> + + + + )} + {onFeePreview && ( + + onFeePreview(item)}> + + + + )} + {onReleaseDocument && item.releaseDate && ( + + onReleaseDocument(item)} + > + + + + )} + {onLastMile && item.booking?.lastMileDeliveryAddress && ( + + onLastMile(item)}> + + + + )} + + onHistory(item)}> + + + + + + + ); + })} + +
+
); } diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseTable.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseTable.tsx index 7f8dea0e5..7028a63a4 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseTable.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseTable.tsx @@ -1,10 +1,20 @@ import { useMemo } from 'react'; +import type { ReactNode } from 'react'; import { ActionIcon, Group, Text } from '@mantine/core'; -import { Eye, Pencil } from 'lucide-react'; +import { + Building2, + Eye, + MapPin, + Package, + Pencil, + Scale, + Warehouse as WarehouseIcon, +} from 'lucide-react'; import { DataTable, type ColumnDef } from '@edr/ui-common'; import { useQuery } from '@tanstack/react-query'; +import { bookingTable } from '@/components/bookings/booking-ui.styles'; import { api } from '@/services/api'; import type { Warehouse } from '@/types/warehouse'; import { WarehouseStatusBadge, WarehouseTypeBadge } from './badges'; @@ -16,6 +26,43 @@ interface WarehouseTableProps { onEdit: (warehouse: Warehouse) => void; } +const HEADER = bookingTable.headerCell; + +function CapacityCell({ + current, + capacity, + icon, +}: { + current?: number | null; + capacity?: number | null; + icon: ReactNode; +}) { + const numericCurrent = Number(current) || 0; + const numericCapacity = Number(capacity) || 0; + const hasCapacity = numericCapacity > 0; + const ratio = hasCapacity ? Math.min(100, Math.max(0, (numericCurrent / numericCapacity) * 100)) : 0; + const isOverCapacity = hasCapacity && numericCurrent > numericCapacity; + + return ( +
+
+ + {icon} + + {formatCapacity(numericCurrent, capacity)} +
+ {hasCapacity ? ( +
+
+
+ ) : null} +
+ ); +} + export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTableProps) { const { data: stations } = useQuery( api.stations.list.queryOptions({ staleTime: 5 * 60 * 1000 }), @@ -28,63 +75,98 @@ export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTablePro const columns: ColumnDef[] = [ { id: 'code', - header: 'Code', + header: () => Warehouse, cell: ({ row }) => ( - onView(row.original)} - > - {row.original.code} - +
+
+ +
+
+ +

+ {row.original.name} +

+
+
), }, - { id: 'name', header: 'Name', cell: ({ row }) => row.original.name }, { id: 'facility', - header: 'Facility', + header: () => Facility, cell: ({ row }) => { const name = row.original.stationId ? stationNameById.get(row.original.stationId) : undefined; return name ? ( - - {name} - +
+ + {name} +
) : ( - — + - ); }, }, { id: 'type', - header: 'Type', - cell: ({ row }) => , + header: () => Type, + cell: ({ row }) => ( +
+ +
+ ), }, { id: 'location', - header: 'Location', - cell: ({ row }) => row.original.locationName ?? '—', + header: () => Location, + cell: ({ row }) => ( +
+ + {row.original.locationName ?? '-'} +
+ ), }, { id: 'weight', - header: 'Weight (cur / cap)', - cell: ({ row }) => formatCapacity(row.original.currentWeight, row.original.capacityWeight), + header: () => Weight, + cell: ({ row }) => ( + } + /> + ), }, { id: 'containers', - header: 'Containers (cur / cap)', - cell: ({ row }) => - formatCapacity(row.original.currentContainers, row.original.capacityContainers), + header: () => Containers, + cell: ({ row }) => ( + } + /> + ), }, { id: 'status', - header: 'Status', - cell: ({ row }) => , + header: () => Status, + cell: ({ row }) => ( +
+ +
+ ), }, { id: 'actions', @@ -109,7 +191,7 @@ export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTablePro status="success" onRowClick={(warehouse) => onView(warehouse)} emptyMessage="No warehouses found." - containerClassName="border-0 shadow-none" + containerClassName="border-0 bg-transparent shadow-none" /> ); } diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/badges.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/badges.tsx index aebbd4de4..8f34de7e7 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/badges.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/badges.tsx @@ -57,6 +57,8 @@ const inventoryStatusColor: Record = { RECEIVED: "yellow", STORED: "blue", RESERVED: "grape", + ARRIVED_AT_WAREHOUSE: "orange", + UNDER_INSPECTION: "yellow", READY_FOR_LOADING: "cyan", LOADED: "teal", READY_FOR_PICKUP: "teal", diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/index.ts b/apps/edr-freight-web/backoffice/src/components/warehouses/index.ts index c0111fbef..570f1d090 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/index.ts @@ -16,6 +16,8 @@ export { ReserveInventoryModal } from './ReserveInventoryModal'; export { InventoryMovementHistoryTable } from './InventoryMovementHistoryTable'; export { ActivityTimeline } from './ActivityTimeline'; export { InventoryHistoryModal } from './InventoryHistoryModal'; +export { InventoryDetailModal } from './InventoryDetailModal'; +export { InventoryInquiryDetailModal } from './InventoryInquiryDetailModal'; export { InventoryWorkbench } from './InventoryWorkbench'; export { BookingSelect } from './BookingSelect'; export { WagonSelect } from './WagonSelect'; diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/pdf.ts b/apps/edr-freight-web/backoffice/src/components/warehouses/pdf.ts new file mode 100644 index 000000000..7a467b9db --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/pdf.ts @@ -0,0 +1,24 @@ +export function openPdfBlob(blob: Blob, filename: string, targetWindow?: Window | null) { + const url = URL.createObjectURL(blob); + + if (targetWindow && !targetWindow.closed) { + targetWindow.location.href = url; + setTimeout(() => URL.revokeObjectURL(url), 60_000); + return true; + } + + const opened = window.open(url, '_blank'); + if (opened) { + setTimeout(() => URL.revokeObjectURL(url), 60_000); + return true; + } + + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + return false; +} diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index fc3897fa6..ede28be37 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -276,38 +276,43 @@ export const URL_CONSTANTS = { }, WAREHOUSE_YARDS: { + BASE: '/warehouse-yards', BY_ID: (id: string) => `/warehouse-yards/${id}`, ZONES: (yardId: string) => `/warehouse-yards/${yardId}/zones`, }, WAREHOUSE_ZONES: { + BASE: '/warehouse-zones', BY_ID: (id: string) => `/warehouse-zones/${id}`, }, WAREHOUSE_INVENTORY: { BASE: '/warehouse-inventory', RECEIVE: '/warehouse-inventory/receive', + DASHBOARD_SUMMARY: '/warehouse-inventory/dashboard/summary', + READY_FOR_LOADING: '/warehouse-inventory/ready-for-loading', + INQUIRY: '/warehouse-inventory/inquiry', + STORE: (id: string) => `/warehouse-inventory/${id}/store`, + INSPECT: (id: string) => `/warehouse-inventory/${id}/inspect`, + MARK_READY: (id: string) => `/warehouse-inventory/${id}/ready-for-loading`, + LOAD: (id: string) => `/warehouse-inventory/${id}/load`, + DISPATCH: (id: string) => `/warehouse-inventory/${id}/dispatch`, + MOVE: (id: string) => `/warehouse-inventory/${id}/move`, RESERVE: '/warehouse-inventory/reserve', ARRIVAL_QUEUE: '/warehouse-inventory/arrival-queue', AUTO_UNLOAD_ARRIVED: '/warehouse-inventory/auto-unload-arrived', AUTO_LOAD_READY: '/warehouse-inventory/auto-load-ready', UNLOAD_BOOKING: (bookingId: string) => `/warehouse-inventory/bookings/${bookingId}/unload`, INSPECTION_REPORTS: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/inspection-reports`, - 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`, // Import branch MARK_READY_PICKUP: (id: string) => `/warehouse-inventory/${id}/ready-for-pickup`, RELEASE: (id: string) => `/warehouse-inventory/${id}/release`, + RELEASE_DOCUMENT: (id: string) => `/warehouse-inventory/${id}/release-document`, DELIVER: (id: string) => `/warehouse-inventory/${id}/deliver`, // Receive (Import/Export bulk) ELIGIBLE_BOOKINGS: (direction?: string) => diff --git a/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts b/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts new file mode 100644 index 000000000..2bd7b552b --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts @@ -0,0 +1,538 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import { warehouseService } from '@/services/warehouse.service'; +import type { + InspectionReportPayload, + SaveAllocationRulePayload, + SaveFeeRulePayload, + WarehouseInvoiceFilter, + PayInvoicePayload, + InventoryFilter, + InventoryInquiryFilter, + LoadInventoryPayload, + MoveInventoryPayload, + ReceiveInventoryPayload, + ReleaseOrderPayload, + DeliverInventoryPayload, + BulkReceivePayload, + BulkInspectPayload, + ReserveInventoryPayload, + SaveWarehousePayload, + SaveYardPayload, + SaveZonePayload, + WarehouseFilter, +} from '@/types/warehouse'; + +export const warehouseKeys = { + all: ['warehouses'] as const, + list: (filter?: WarehouseFilter) => ['warehouses', 'list', filter ?? {}] as const, + facilities: () => ['warehouses', 'facilities'] as const, + detail: (id: string) => ['warehouses', 'detail', id] as const, + yards: (warehouseId: string) => ['warehouses', warehouseId, 'yards'] as const, + allYards: () => ['warehouse-yards', 'all'] as const, + zones: (yardId: string) => ['warehouse-yards', yardId, 'zones'] as const, + allZones: () => ['warehouse-zones', 'all'] as const, + inventory: (filter?: InventoryFilter) => ['warehouse-inventory', 'list', filter ?? {}] as const, + dashboardSummary: (filter?: InventoryFilter) => ['warehouse-dashboard', 'summary', filter ?? {}] as const, + inquiry: (filter: InventoryInquiryFilter) => ['warehouse-inventory', 'inquiry', filter] as const, +}; + +// ── Warehouses ───────────────────────────────────────────────────────────── + +export function useWarehouses(filter?: WarehouseFilter) { + return useQuery({ + queryKey: warehouseKeys.list(filter), + queryFn: () => warehouseService.list(filter).then((r) => r.data), + }); +} + +export function useWarehouse(id?: string) { + return useQuery({ + queryKey: warehouseKeys.detail(id ?? ''), + queryFn: () => warehouseService.getById(id as string).then((r) => r.data), + enabled: Boolean(id), + }); +} + +export function useWarehouseFacilities() { + return useQuery({ + queryKey: warehouseKeys.facilities(), + queryFn: () => warehouseService.listFacilities().then((r) => r.data), + }); +} + +export function useCreateWarehouse() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (payload: SaveWarehousePayload) => warehouseService.create(payload), + onSuccess: () => qc.invalidateQueries({ queryKey: warehouseKeys.all }), + }); +} + +export function useUpdateWarehouse() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, payload }: { id: string; payload: Partial }) => + warehouseService.update(id, payload), + onSuccess: (_, { id }) => { + qc.invalidateQueries({ queryKey: warehouseKeys.all }); + qc.invalidateQueries({ queryKey: warehouseKeys.detail(id) }); + }, + }); +} + +// ── Yards ──────────────────────────────────────────────────────────────── + +export function useWarehouseYards(warehouseId?: string) { + return useQuery({ + queryKey: warehouseKeys.yards(warehouseId ?? ''), + queryFn: () => warehouseService.listYards(warehouseId as string).then((r) => r.data), + enabled: Boolean(warehouseId), + }); +} + +export function useAllWarehouseYards() { + return useQuery({ + queryKey: warehouseKeys.allYards(), + queryFn: () => warehouseService.listAllYards().then((r) => r.data), + }); +} + +export function useCreateYard() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ warehouseId, payload }: { warehouseId: string; payload: SaveYardPayload }) => + warehouseService.createYard(warehouseId, payload), + onSuccess: (_, { warehouseId }) => { + qc.invalidateQueries({ queryKey: warehouseKeys.yards(warehouseId) }); + qc.invalidateQueries({ queryKey: warehouseKeys.detail(warehouseId) }); + }, + }); +} + +export function useUpdateYard() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, payload }: { id: string; payload: Partial }) => + warehouseService.updateYard(id, payload), + onSuccess: () => qc.invalidateQueries({ queryKey: warehouseKeys.all }), + }); +} + +// ── Zones ────────────────────────────────────────────────────────────────── + +export function useWarehouseZones(yardId?: string) { + return useQuery({ + queryKey: warehouseKeys.zones(yardId ?? ''), + queryFn: () => warehouseService.listZones(yardId as string).then((r) => r.data), + enabled: Boolean(yardId), + }); +} + +export function useAllWarehouseZones() { + return useQuery({ + queryKey: warehouseKeys.allZones(), + queryFn: () => warehouseService.listAllZones().then((r) => r.data), + }); +} + +export function useCreateZone() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ yardId, payload }: { yardId: string; payload: SaveZonePayload }) => + warehouseService.createZone(yardId, payload), + onSuccess: (_, { yardId }) => qc.invalidateQueries({ queryKey: warehouseKeys.zones(yardId) }), + }); +} + +export function useUpdateZone() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, payload }: { id: string; payload: Partial }) => + warehouseService.updateZone(id, payload), + onSuccess: () => qc.invalidateQueries({ queryKey: ['warehouse-yards'] }), + }); +} + +// ── Inventory ────────────────────────────────────────────────────────────── + +export function useWarehouseInventory(filter?: InventoryFilter) { + return useQuery({ + queryKey: warehouseKeys.inventory(filter), + queryFn: () => warehouseService.listInventory(filter).then((r) => r.data), + }); +} + +export function useWarehouseDashboardSummary(filter?: InventoryFilter) { + return useQuery({ + queryKey: warehouseKeys.dashboardSummary(filter), + queryFn: () => warehouseService.getDashboardSummary(filter).then((r) => r.data), + }); +} + +export function useReceiveInventory() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (payload: ReceiveInventoryPayload) => warehouseService.receiveInventory(payload), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['warehouse-inventory'] }); + qc.invalidateQueries({ queryKey: warehouseKeys.all }); + qc.invalidateQueries({ queryKey: ['warehouse-dashboard'] }); + }, + }); +} + +function useInventoryMutation(fn: (args: TArgs) => Promise) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: fn, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['warehouse-inventory'] }); + qc.invalidateQueries({ queryKey: ['warehouse-loadings'] }); + qc.invalidateQueries({ queryKey: warehouseKeys.all }); + }, + }); +} + +export const useStoreInventory = () => useInventoryMutation((id: string) => warehouseService.store(id)); +export const useReserveInventory = () => + useInventoryMutation((payload: ReserveInventoryPayload) => warehouseService.reserve(payload)); +export const useMarkReadyForLoading = () => + useInventoryMutation((id: string) => warehouseService.markReadyForLoading(id)); +export const useLoadInventory = () => + useInventoryMutation((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), + ); + +// ── Import branch (READY_FOR_PICKUP → DELIVERED) ─────────────────────────── +export const useMarkReadyForPickup = () => + useInventoryMutation((id: string) => warehouseService.markReadyForPickup(id)); +export const useReleaseInventory = () => + useInventoryMutation((args: { id: string; payload: ReleaseOrderPayload }) => + warehouseService.release(args.id, args.payload), + ); +export const useDeliverInventory = () => + useInventoryMutation((args: { id: string; payload: DeliverInventoryPayload }) => + warehouseService.deliver(args.id, args.payload), + ); + +// ── Receive (Import/Export bulk) ─────────────────────────────────────────── +/** + * All not-yet-received PAID bookings, classified IMPORT/EXPORT by route, in one call. + * Both Receive tabs share this single query (same key) — only one HTTP request fires — + * then filter client-side by direction. + */ +export function useEligibleBookings(enabled = true) { + return useQuery({ + queryKey: ['warehouse-inventory', 'eligible-bookings'], + queryFn: () => warehouseService.eligibleBookings().then((r) => r.data), + enabled, + }); +} +export const useBulkReceive = () => + useInventoryMutation((payload: BulkReceivePayload) => warehouseService.receiveBulk(payload)); +export const useLoadPassedExport = () => + useInventoryMutation(() => warehouseService.loadPassedExport()); +export const useBulkMarkInspected = () => + useInventoryMutation((payload: BulkInspectPayload) => warehouseService.bulkMarkInspected(payload)); + +export function useReadyToLoadExport(enabled = true) { + return useQuery({ + queryKey: ['warehouse-inventory', 'ready-to-load-export'], + queryFn: () => warehouseService.readyToLoadExport().then((r) => r.data), + enabled, + }); +} + +export function useLoadedExport(enabled = true) { + return useQuery({ + queryKey: ['warehouse-inventory', 'loaded-export'], + queryFn: () => warehouseService.loadedExport().then((r) => r.data), + enabled, + }); +} + +export const useBulkDispatchExport = () => + useInventoryMutation((inventoryIds: string[]) => warehouseService.bulkDispatchExport(inventoryIds)); + +/** Arrived IMPORT trains (route-derived). Read-only. */ +export function useImportArriveQueue(enabled = true) { + return useQuery({ + queryKey: ['warehouse-inventory', 'import-arrive-queue'], + queryFn: () => warehouseService.importArriveQueue().then((r) => r.data), + enabled, + }); +} + +/** Assigned bookings/items for an arrived import train. Read-only. */ +export function useImportTrainItems(scheduleId?: string) { + return useQuery({ + queryKey: ['warehouse-inventory', 'import-train-items', scheduleId], + queryFn: () => warehouseService.importTrainItems(scheduleId as string).then((r) => r.data), + enabled: Boolean(scheduleId), + }); +} + +/** Unload all eligible assigned bookings of an ARRIVED import train (→ UNLOADED). */ +export const useAutoUnloadArrivedBookings = () => + useInventoryMutation((scheduleId: string) => warehouseService.autoUnloadArrivedBookings(scheduleId)); + +/** IMPORT inventory in the Unloaded Queue (UNLOADED / destination inspection). Read-only. */ +export function useImportUnloadedQueue(enabled = true) { + return useQuery({ + queryKey: ['warehouse-inventory', 'import-unloaded-queue'], + queryFn: () => warehouseService.importUnloadedQueue().then((r) => r.data), + enabled, + }); +} + +/** IMPORT inventory that is PICKUP_READY (READY_FOR_PICKUP) awaiting pickup/dispatch. Read-only. */ +export function useImportPickupReadyQueue(enabled = true) { + return useQuery({ + queryKey: ['warehouse-inventory', 'import-pickup-ready-queue'], + queryFn: () => warehouseService.importPickupReadyQueue().then((r) => r.data), + enabled, + }); +} + +// ── 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'], + queryFn: () => warehouseService.movements(id as string).then((r) => r.data), + enabled: Boolean(id), + }); +} + +export function useInventoryActivity(id?: string) { + return useQuery({ + queryKey: ['warehouse-inventory', id, 'activity'], + queryFn: () => warehouseService.activity(id as string).then((r) => r.data), + enabled: Boolean(id), + }); +} + +export function useWarehouseDashboard() { + return useQuery({ + queryKey: ['warehouses', 'dashboard'], + queryFn: () => warehouseService.dashboard().then((r) => r.data), + }); +} + +export function useInventoryInquiry(filter: InventoryInquiryFilter, enabled = true) { + return useQuery({ + queryKey: warehouseKeys.inquiry(filter), + queryFn: () => warehouseService.inquiry(filter).then((r) => r.data), + enabled, + }); +} + +// ── Batch 4.5: Arrival / Unload / Inspection ──────────────────────────────── + +export function useArrivalQueue() { + return useQuery({ + queryKey: ['warehouse-inventory', 'arrival-queue'], + queryFn: () => warehouseService.arrivalQueue().then((r) => r.data), + }); +} + +function useArrivalInvalidation() { + const qc = useQueryClient(); + return () => { + qc.invalidateQueries({ queryKey: ['warehouse-inventory'] }); + qc.invalidateQueries({ queryKey: warehouseKeys.all }); + }; +} + +export function useAutoUnloadArrived() { + const onSuccess = useArrivalInvalidation(); + return useMutation({ mutationFn: () => warehouseService.autoUnloadArrived(), onSuccess }); +} + +export function useAutoLoadReady() { + const onSuccess = useArrivalInvalidation(); + return useMutation({ mutationFn: () => warehouseService.autoLoadReady(), onSuccess }); +} + +export function useUnloadBooking() { + const onSuccess = useArrivalInvalidation(); + return useMutation({ + mutationFn: (args: { bookingId: string; payload?: Record }) => + warehouseService.unloadBooking(args.bookingId, args.payload), + onSuccess, + }); +} + +export function useInspectionReports(inventoryId?: string) { + return useQuery({ + queryKey: ['warehouse-inventory', inventoryId, 'inspection-reports'], + queryFn: () => warehouseService.listInspectionReports(inventoryId as string).then((r) => r.data), + enabled: Boolean(inventoryId), + }); +} + +export function useCreateInspectionReport() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ inventoryId, payload }: { inventoryId: string; payload: InspectionReportPayload }) => + warehouseService.createInspectionReport(inventoryId, payload).then((r) => r.data), + onSuccess: (_, { inventoryId }) => { + qc.invalidateQueries({ queryKey: ['warehouse-inventory', inventoryId, 'inspection-reports'] }); + qc.invalidateQueries({ queryKey: ['warehouse-inventory'] }); + }, + }); +} + +export function useUploadInspectionAttachments() { + return useMutation({ + mutationFn: ({ reportId, files }: { reportId: string; files: File[] }) => + warehouseService.uploadInspectionAttachments(reportId, files), + }); +} + +// ── Batch 5: Allocation + Fee rules / preview ─────────────────────────────── + +export function useAllocationRules() { + return useQuery({ + queryKey: ['warehouse-allocation-rules'], + queryFn: () => warehouseService.listAllocationRules().then((r) => r.data), + }); +} + +export function useFeeRules() { + return useQuery({ + queryKey: ['warehouse-fee-rules'], + queryFn: () => warehouseService.listFeeRules().then((r) => r.data), + }); +} + +function useRuleMutation(fn: (args: TArgs) => Promise, keys: string[]) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: fn, + onSuccess: () => keys.forEach((k) => qc.invalidateQueries({ queryKey: [k] })), + }); +} + +export const useCreateAllocationRule = () => + useRuleMutation( + (payload: SaveAllocationRulePayload) => warehouseService.createAllocationRule(payload), + ['warehouse-allocation-rules'], + ); +export const useUpdateAllocationRule = () => + useRuleMutation( + (args: { id: string; payload: Partial }) => + warehouseService.updateAllocationRule(args.id, args.payload), + ['warehouse-allocation-rules'], + ); +export const useDeleteAllocationRule = () => + useRuleMutation((id: string) => warehouseService.deleteAllocationRule(id), ['warehouse-allocation-rules']); + +export const useCreateFeeRule = () => + useRuleMutation((payload: SaveFeeRulePayload) => warehouseService.createFeeRule(payload), ['warehouse-fee-rules']); +export const useUpdateFeeRule = () => + useRuleMutation( + (args: { id: string; payload: Partial }) => + warehouseService.updateFeeRule(args.id, args.payload), + ['warehouse-fee-rules'], + ); +export const useDeleteFeeRule = () => + useRuleMutation((id: string) => warehouseService.deleteFeeRule(id), ['warehouse-fee-rules']); + +export function useFeePreview(inventoryId?: string) { + return useQuery({ + queryKey: ['warehouse-inventory', inventoryId, 'fee-preview'], + queryFn: () => warehouseService.feePreview(inventoryId as string).then((r) => r.data), + enabled: Boolean(inventoryId), + }); +} + +// ── Batch 6: Warehouse fee invoices ───────────────────────────────────────── + +export function useWarehouseInvoices(filter?: WarehouseInvoiceFilter) { + return useQuery({ + queryKey: ['warehouse-fee-invoices', filter ?? {}], + queryFn: () => warehouseService.listInvoices(filter).then((r) => r.data), + }); +} + +export function useWarehouseInvoice(id?: string) { + return useQuery({ + queryKey: ['warehouse-fee-invoices', 'detail', id], + queryFn: () => warehouseService.getInvoice(id as string).then((r) => r.data), + enabled: Boolean(id), + }); +} + +export function useInvoicesForInventory(inventoryId?: string) { + return useQuery({ + queryKey: ['warehouse-inventory', inventoryId, 'fee-invoices'], + queryFn: () => warehouseService.invoicesForInventory(inventoryId as string).then((r) => r.data), + enabled: Boolean(inventoryId), + }); +} + +function useInvoiceInvalidation() { + const qc = useQueryClient(); + return () => { + qc.invalidateQueries({ queryKey: ['warehouse-fee-invoices'] }); + qc.invalidateQueries({ queryKey: ['warehouse-inventory'] }); + }; +} + +export function useGenerateInvoice() { + const onSuccess = useInvoiceInvalidation(); + return useMutation({ + mutationFn: ({ inventoryId, confirmZero }: { inventoryId: string; confirmZero?: boolean }) => + warehouseService.generateInvoice(inventoryId, confirmZero).then((r) => r.data), + onSuccess, + }); +} + +export function useCancelInvoice() { + const onSuccess = useInvoiceInvalidation(); + return useMutation({ mutationFn: (id: string) => warehouseService.cancelInvoice(id), onSuccess }); +} + +export function usePayInvoice() { + const onSuccess = useInvoiceInvalidation(); + return useMutation({ + mutationFn: ({ id, payload }: { id: string; payload: PayInvoicePayload }) => + warehouseService.payInvoice(id, payload), + onSuccess, + }); +} + +export function useGateClearance() { + const onSuccess = useInvoiceInvalidation(); + return useMutation({ mutationFn: (inventoryId: string) => warehouseService.gateClearance(inventoryId), onSuccess }); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx index ecf08bdfc..d61a47139 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx @@ -103,9 +103,8 @@ export default function BookingRequestsPage() { page: 1, pageSize: 100, statuses: "PAID", - schedulingStatuses: "NOT_SCHEDULED,HOLDING,ELIGIBLE", assignedToSchedule: "false", - sortBy: "isGovernment", + sortBy: "createdAt", sortOrder: "DESC", tab: activeTab, }; diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx index 317520f48..cf0a75fa8 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx @@ -1,197 +1,271 @@ -import { useState } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { Fragment, useState } from 'react'; import { Badge, Button, Card, + Container, Group, + Loader, + Stack, + Table, Text, } from '@mantine/core'; -import { ClipboardList, Eye, PackageOpen, Truck } from 'lucide-react'; -import { DataTable, type ColumnDef } from '@edr/ui-common'; +import { ChevronDown, ChevronRight, PackageOpen, Truck } from 'lucide-react'; -import { PageContainer, PageHeader } from '@/components/page'; +import { PageHeader } from '@/components/page'; +import Breadcrumbs from '@/components/ui/Breadcrumbs'; import { - InspectionReportModal, VisualEmptyState, + WarehouseHero, formatDate, + formatNumber, } from '@/components/warehouses'; -import { useMutation, useQuery } from '@tanstack/react-query'; - -import { api } from '@/services/api'; +import { + useAutoUnloadArrivedBookings, + useImportArriveQueue, + useImportTrainItems, +} from '@/hooks/useWarehouses'; import { useToast } from '@/hooks/use-toast'; -import type { ArrivalQueueItem } from '@/types/warehouse'; +import type { AutoUnloadArrivedResult, ImportTrain, ImportTrainItem } from '@/types/warehouse'; -function inspectionBadge(status: string | null) { - if (!status) return Not inspected; - const color = status === 'PASSED' ? 'edr-green' : status === 'FAILED' ? 'red' : 'orange'; - return {status.replace(/_/g, ' ')}; -} +const getErrorMessage = (error: unknown) => { + if (error && typeof error === 'object' && 'response' in error) { + const response = (error as { response?: { data?: { message?: unknown } } }).response; + const message = response?.data?.message; + if (Array.isArray(message)) return message.join(', '); + if (typeof message === 'string') return message; + } + return error instanceof Error ? error.message : undefined; +}; -/** Batch 4.5 — arrived bookings awaiting unload / inspection. */ -export default function ArrivalQueuePage() { - const navigate = useNavigate(); - const { toast } = useToast(); - const { data, isLoading } = useQuery(api.warehouses.arrivalQueue.queryOptions()); - const autoUnload = useMutation(api.warehouses.autoUnloadArrived.mutationOptions()); - const unloadOne = useMutation(api.warehouses.unloadBooking.mutationOptions()); - const [inspectInventoryId, setInspectInventoryId] = useState(null); +function ImportTrainDetailRows({ scheduleId }: { scheduleId: string }) { + const { data: items = [], isLoading } = useImportTrainItems(scheduleId); - const items = data ?? []; + if (isLoading) { + return ( + + + + ); + } - const handleAutoUnload = async () => { - try { - const r = await autoUnload.mutateAsync(); - toast({ - title: 'Auto-unload complete', - description: `Processed ${r.processedCount}, skipped ${r.skippedCount}, failed ${r.failedCount}.`, - }); - } catch { - toast({ variant: 'destructive', title: 'Auto-unload failed' }); - } - }; - - const handleUnloadOne = async (item: ArrivalQueueItem) => { - try { - await unloadOne.mutateAsync({ bookingId: item.bookingId }); - toast({ title: 'Booking unloaded', description: `${item.bookingReference} stored as RECEIVED.` }); - } catch { - toast({ variant: 'destructive', title: 'Unload failed' }); - } - }; - - const columns: ColumnDef[] = [ - { - id: 'booking', - header: 'Booking', - cell: ({ row }) => ( - - {row.original.bookingReference} - - ), - }, - { id: 'customer', header: 'Customer', cell: ({ row }) => row.original.customer ?? '—' }, - { - id: 'cargo', - header: 'Cargo / Container', - cell: ({ row }) => row.original.container ?? row.original.cargo ?? '—', - }, - { - id: 'arrival', - header: 'Arrival', - cell: ({ row }) => {formatDate(row.original.arrivalDate)}, - }, - { id: 'facility', header: 'Facility', cell: ({ row }) => row.original.facility ?? '—' }, - { id: 'warehouse', header: 'Warehouse', cell: ({ row }) => row.original.warehouse ?? '—' }, - { id: 'yard', header: 'Yard', cell: ({ row }) => row.original.yard ?? '—' }, - { id: 'zone', header: 'Zone', cell: ({ row }) => row.original.zone ?? '—' }, - { - id: 'status', - header: 'Status', - cell: ({ row }) => - row.original.unloaded ? ( - - {row.original.currentStatus ?? 'RECEIVED'} - - ) : ( - - Not unloaded - - ), - }, - { - id: 'inspection', - header: 'Inspection', - cell: ({ row }) => inspectionBadge(row.original.inspectionStatus), - }, - { - id: 'actions', - header: '', - cell: ({ row }) => { - const item = row.original; - return ( - e.stopPropagation()}> - {!item.unloaded && ( - - )} - {item.inventoryId && ( - - )} - {item.inventoryId && ( - - )} - - ); - }, - }, - ]; + if (items.length === 0) { + return ( + + No assigned bookings found for this train. + + ); + } return ( - - } - loading={autoUnload.isPending} - onClick={handleAutoUnload} - > - Auto Unload Arrived Bookings - - } - /> - - - - {items.length} arrived booking(s) - - - {!isLoading && items.length === 0 ? ( - - ) : ( - - )} - - - setInspectInventoryId(null)} - inventoryId={inspectInventoryId} - /> - + + + + Booking + Customer + Container + Cargo + Weight + Arrival + Status + Pickup + + + + {items.map((item: ImportTrainItem) => ( + + + + {item.bookingReference ?? item.bookingId.slice(0, 8)} + + + {item.customerName ?? '-'} + {item.containerNumber ?? '-'} + {item.cargoType ?? '-'} + {formatNumber(item.weight)} + {formatDate(item.arrivalTime)} + + + {item.currentStatus ?? 'PENDING'} + + + {item.pickupOption.replace(/_/g, ' ')} + + ))} + +
+ ); +} + +/** Arrived import trains awaiting unload into warehouse inventory. */ +export default function ArrivalQueuePage() { + const { toast } = useToast(); + const { data: trains = [], isLoading } = useImportArriveQueue(); + const autoUnload = useAutoUnloadArrivedBookings(); + const [openScheduleId, setOpenScheduleId] = useState(null); + const [busyScheduleId, setBusyScheduleId] = useState(null); + + const unloadTrain = async (train: ImportTrain) => { + setBusyScheduleId(train.scheduleId); + try { + const res = (await autoUnload.mutateAsync(train.scheduleId)) as { + data: AutoUnloadArrivedResult; + }; + const result = res.data; + const details = [ + result.skippedCount ? `${result.skippedCount} skipped` : '', + result.failedCount ? `${result.failedCount} failed` : '', + ] + .filter(Boolean) + .join(', '); + + toast({ + title: `${result.unloadedCount} booking(s) unloaded`, + description: details || `${train.trainNumber ?? 'Train'} moved into warehouse inventory.`, + }); + } catch (error) { + toast({ + variant: 'destructive', + title: 'Auto unload failed', + description: getErrorMessage(error), + }); + } finally { + setBusyScheduleId(null); + } + }; + + return ( + + + + + + + + + + + {trains.length} arrived import train(s) + + Open a train to review assigned bookings, then auto unload it. + + + + {isLoading ? ( + + + + ) : trains.length === 0 ? ( + + ) : ( + + + + + Train + Route + Origin + Destination + Arrival + Bookings + Containers + Cargoes + Status + Actions + + + + {trains.map((train: ImportTrain) => { + const isOpen = openScheduleId === train.scheduleId; + return ( + + + + + + {train.trainNumber ?? '-'} + + + {train.scheduleId.slice(0, 8)} + + + + {train.route ?? '-'} + {train.origin ?? '-'} + {train.destination ?? '-'} + + {formatDate(train.arrivalTime)} + + {train.totalBookings} + {train.totalContainers} + {train.totalCargoes} + + + {train.status} + + + + + + + + + + {isOpen && ( + + + + + + )} + + ); + })} + +
+
+ )} +
+
+
); } diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/InventoryInquiryPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/InventoryInquiryPage.tsx index 582a9cedd..d85cb815b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/InventoryInquiryPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/InventoryInquiryPage.tsx @@ -3,35 +3,30 @@ import { Button, Card, Center, Group, Loader, Select, Stack, TextInput } from '@ import { Search } from 'lucide-react'; import { PageContainer, PageHeader } from '@/components/page'; -import { useQuery } from '@tanstack/react-query'; - -import { VisualEmptyState, WarehouseInquiryTable, inventoryStatusOptions } from '@/components/warehouses'; -import { api } from '@/services/api'; -import type { InventoryInquiryFilter, InventoryStatus } from '@/types/warehouse'; +import { + InventoryInquiryDetailModal, + VisualEmptyState, + WarehouseInquiryTable, + inventoryStatusOptions, +} from '@/components/warehouses'; +import { + useAllWarehouseYards, + useAllWarehouseZones, + useInventoryInquiry, + useWarehouses, +} from '@/hooks/useWarehouses'; +import type { InventoryInquiryFilter, InventoryInquiryResult, InventoryStatus } from '@/types/warehouse'; export default function InventoryInquiryPage() { const [draft, setDraft] = useState({}); const [applied, setApplied] = useState({}); + const [viewResult, setViewResult] = useState(null); - const warehousesQuery = useQuery( - api.warehouses.list.queryOptions({ input: {} }), - ); - const yardsQuery = useQuery( - api.warehouses.listYards.queryOptions({ - input: { warehouseId: draft.warehouseId ?? '' }, - enabled: Boolean(draft.warehouseId), - }), - ); - const zonesQuery = useQuery( - api.warehouses.listZones.queryOptions({ - input: { yardId: draft.yardId ?? '' }, - enabled: Boolean(draft.yardId), - }), - ); + const warehousesQuery = useWarehouses(); + const yardsQuery = useAllWarehouseYards(); + const zonesQuery = useAllWarehouseZones(); - const { data, isFetching } = useQuery( - api.warehouses.inquiry.queryOptions({ input: { filter: applied } }), - ); + const { data, isFetching } = useInventoryInquiry(applied); const results = data ?? []; const warehouseOptions = useMemo( @@ -39,15 +34,39 @@ export default function InventoryInquiryPage() { [warehousesQuery.data], ); const yardOptions = useMemo( - () => (yardsQuery.data ?? []).map((y) => ({ value: y.id, label: `${y.name} (${y.code})` })), - [yardsQuery.data], + () => + (yardsQuery.data ?? []) + .filter((y) => !draft.warehouseId || y.warehouseId === draft.warehouseId) + .map((y) => ({ value: y.id, label: `${y.name} (${y.code})` })), + [draft.warehouseId, yardsQuery.data], ); const zoneOptions = useMemo( - () => (zonesQuery.data ?? []).map((z) => ({ value: z.id, label: `${z.name} (${z.code})` })), - [zonesQuery.data], + () => { + const visibleYardIds = new Set( + (yardsQuery.data ?? []) + .filter((y) => !draft.warehouseId || y.warehouseId === draft.warehouseId) + .map((y) => y.id), + ); + return (zonesQuery.data ?? []) + .filter((z) => { + if (draft.yardId) return z.yardId === draft.yardId; + if (draft.warehouseId) return visibleYardIds.has(z.yardId); + return true; + }) + .map((z) => ({ value: z.id, label: `${z.name} (${z.code})` })); + }, + [draft.warehouseId, draft.yardId, yardsQuery.data, zonesQuery.data], ); - const runSearch = () => setApplied(draft); + const normalizeDraft = (filter: InventoryInquiryFilter): InventoryInquiryFilter => ({ + ...filter, + bookingReference: filter.bookingReference?.trim() || undefined, + containerNumber: filter.containerNumber?.trim() || undefined, + cargoType: filter.cargoType?.trim() || undefined, + goodsName: filter.goodsName?.trim() || undefined, + }); + + const runSearch = () => setApplied(normalizeDraft(draft)); const reset = () => { setDraft({}); setApplied({}); @@ -60,101 +79,109 @@ export default function InventoryInquiryPage() { subtitle="Locate any cargo, container or goods inside the warehouse network." /> - - - - { const v = e.currentTarget.value; setDraft((f) => ({ ...f, bookingNumber: v || undefined })); }} - w={200} - /> - { const v = e.currentTarget.value; setDraft((f) => ({ ...f, containerNumber: v || undefined })); }} - w={200} - /> - { const v = e.currentTarget.value; setDraft((f) => ({ ...f, goodsName: v || undefined })); }} - w={180} - /> - setDraft((f) => ({ ...f, yardId: value ?? undefined, zoneId: undefined }))} - w={180} - /> - setDraft((f) => ({ ...f, status: (value as InventoryStatus) ?? undefined }))} - w={180} - /> - + + + + + { const v = e.currentTarget.value; setDraft((f) => ({ ...f, bookingReference: v || undefined })); }} + onKeyDown={(e) => { + if (e.key === 'Enter') runSearch(); + }} + w={200} + /> + { const v = e.currentTarget.value; setDraft((f) => ({ ...f, containerNumber: v || undefined })); }} + w={200} + /> + { const v = e.currentTarget.value; setDraft((f) => ({ ...f, goodsName: v || undefined })); }} + w={180} + /> + setDraft((f) => ({ ...f, yardId: value ?? undefined, zoneId: undefined }))} + w={180} + /> + setDraft((f) => ({ ...f, status: (value as InventoryStatus) ?? undefined }))} + w={180} + /> + - - - - - - + + + + + + - - {isFetching ? ( -
- -
- ) : results.length === 0 ? ( - - ) : ( - - )} -
+ + {isFetching ? ( +
+ +
+ ) : results.length === 0 ? ( + + ) : ( + + )} +
+ + setViewResult(null)} + result={viewResult} + /> ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDashboardPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDashboardPage.tsx index 60dbb13c4..5df5018ec 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDashboardPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDashboardPage.tsx @@ -1,5 +1,5 @@ import { useNavigate } from 'react-router-dom'; -import { Card, Center, Group, Loader, SimpleGrid, Text, ThemeIcon } from '@mantine/core'; +import { Card, Center, Group, Loader, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core'; import { ClipboardCheck, ClipboardList, @@ -16,10 +16,8 @@ import { } from 'lucide-react'; import { PageContainer, PageHeader } from '@/components/page'; -import { useQuery } from '@tanstack/react-query'; - -import { WarehouseDashboardCharts } from '@/components/warehouses'; -import { api } from '@/services/api'; +import { WarehouseDashboardCharts, WarehouseHero } from '@/components/warehouses'; +import { useWarehouseDashboard } from '@/hooks/useWarehouses'; import type { WarehouseDashboard } from '@/types/warehouse'; interface Metric { @@ -31,8 +29,8 @@ interface Metric { theme: string; } -const ORANGE = '#f08c00'; -const GREEN = '#22c55e'; +const ORANGE = 'rgb(241, 147, 23)'; +const GREEN = '#084b21'; const METRICS: Metric[] = [ { key: 'totalWarehouses', label: 'Total Warehouses', icon: , to: '/dashboard/warehouses', theme: ORANGE }, @@ -51,7 +49,7 @@ const METRICS: Metric[] = [ export default function WarehouseDashboardPage() { const navigate = useNavigate(); - const { data, isLoading } = useQuery(api.warehouses.dashboard.queryOptions()); + const { data, isError, isLoading } = useWarehouseDashboard(); return ( @@ -60,45 +58,58 @@ export default function WarehouseDashboardPage() { subtitle="Live overview of warehouse capacity and inventory lifecycle." /> - {isLoading ? ( -
- -
- ) : ( - <> - - {METRICS.map((metric) => ( - navigate(metric.to)} - className="cursor-pointer transition-[transform,border-color] duration-150 hover:-translate-y-0.5 hover:border-edr-primary!" - > - -
- - {metric.label} - - - {data ? data[metric.key] : 0} - -
- - {metric.icon} - -
-
- ))} -
+ + - - - )} + {isLoading ? ( +
+ +
+ ) : isError ? ( +
+ Failed to load warehouse dashboard. +
+ ) : ( + <> + + {METRICS.map((metric) => ( + navigate(metric.to)} + className="cursor-pointer transition-[transform,border-color] duration-150 hover:-translate-y-0.5 hover:border-edr-primary!" + > + +
+ + {metric.label} + + + {data ? data[metric.key] : 0} + +
+ + {metric.icon} + +
+
+ ))} +
+ + + + )} +
); } diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDetailPage.tsx index 31509766d..be597bb77 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDetailPage.tsx @@ -5,16 +5,15 @@ import { Button, Card, Center, - Container, Group, Loader, - Stack, Select, + Stack, Tabs, Text, } from '@mantine/core'; import { ArrowLeft, Boxes, LayoutGrid, Package, Pencil, Plus } from 'lucide-react'; - +import { useQuery } from '@tanstack/react-query'; import { DataTable, type ColumnDef } from '@edr/ui-common'; import { KpiStrip, PageContainer, PageHeader } from '@/components/page'; @@ -27,8 +26,6 @@ import { formatCapacity, humanizeEnum, } from '@/components/warehouses'; -import { useQuery } from '@tanstack/react-query'; - import { api } from '@/services/api'; import type { WarehouseYard, WarehouseZone } from '@/types/warehouse'; @@ -51,7 +48,6 @@ export default function WarehouseDetailPage() { const [yardModalOpen, setYardModalOpen] = useState(false); const [editingYard, setEditingYard] = useState(null); - const [zoneModalOpen, setZoneModalOpen] = useState(false); const [editingZone, setEditingZone] = useState(null); const [selectedYardId, setSelectedYardId] = useState(null); @@ -169,14 +165,18 @@ export default function WarehouseDetailPage() { if (!warehouse) { return ( - - + + Warehouse not found - - + ); } @@ -189,9 +189,7 @@ export default function WarehouseDetailPage() { ]} backTo="/dashboard/warehouses" title={warehouse.name} - subtitle={`${warehouse.code}${ - warehouse.locationName ? ` · ${warehouse.locationName}` : '' - }`} + subtitle={`${warehouse.code}${warehouse.locationName ? ` - ${warehouse.locationName}` : ''}`} meta={ @@ -216,7 +214,6 @@ export default function WarehouseDetailPage() { - {/* OVERVIEW */} - {/* YARDS */} - - - - - Yards - - + + + + + Yards + + + void yardsQuery.refetch(), + } + : undefined + } + /> + + + + + + + + + - - - - {!selectedYardId ? ( - - Select a yard to view its zones. - - ) : ( - void zonesQuery.refetch(), - } - : undefined - } - /> - )} - - - - - {/* INVENTORY */} - - - - - + + + + + {id && ( diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInventoryPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInventoryPage.tsx index 2ae417db1..057dd0c21 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInventoryPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInventoryPage.tsx @@ -10,9 +10,12 @@ import { ReceiveInventoryModal, inventoryStatusOptions, } from '@/components/warehouses'; -import { useQuery } from '@tanstack/react-query'; - -import { api } from '@/services/api'; +import { + useWarehouseInventory, + useWarehouseYards, + useWarehouseZones, + useWarehouses, +} from '@/hooks/useWarehouses'; import type { InventoryFilter, InventoryStatus } from '@/types/warehouse'; export default function WarehouseInventoryPage() { @@ -30,24 +33,10 @@ export default function WarehouseInventoryPage() { [filter, debouncedSearch], ); - const warehousesQuery = useQuery( - api.warehouses.list.queryOptions({ input: {} }), - ); - const yardsQuery = useQuery( - api.warehouses.listYards.queryOptions({ - input: { warehouseId: filter.warehouseId ?? '' }, - enabled: Boolean(filter.warehouseId), - }), - ); - const zonesQuery = useQuery( - api.warehouses.listZones.queryOptions({ - input: { yardId: filter.yardId ?? '' }, - enabled: Boolean(filter.yardId), - }), - ); - const inventoryQuery = useQuery( - api.warehouses.listInventory.queryOptions({ input: { filter: queryFilter } }), - ); + const warehousesQuery = useWarehouses(); + const yardsQuery = useWarehouseYards(filter.warehouseId); + const zonesQuery = useWarehouseZones(filter.yardId); + const inventoryQuery = useWarehouseInventory(queryFilter); const warehouseOptions = useMemo( () => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })), @@ -91,7 +80,12 @@ export default function WarehouseInventoryPage() { data={warehouseOptions} value={filter.warehouseId ?? null} onChange={(value) => - setFilter((f) => ({ ...f, warehouseId: value ?? undefined, yardId: undefined, zoneId: undefined })) + setFilter((f) => ({ + ...f, + warehouseId: value ?? undefined, + yardId: undefined, + zoneId: undefined, + })) } w={220} /> @@ -102,7 +96,9 @@ export default function WarehouseInventoryPage() { disabled={!filter.warehouseId} data={yardOptions} value={filter.yardId ?? null} - onChange={(value) => setFilter((f) => ({ ...f, yardId: value ?? undefined, zoneId: undefined }))} + onChange={(value) => + setFilter((f) => ({ ...f, yardId: value ?? undefined, zoneId: undefined })) + } w={200} /> setForm((f) => ({ ...f, freightType: v ?? '' }))} clearable /> - setForm((f) => ({ ...f, freightType: selectValue(v) }))} + clearable + /> + setForm((f) => ({ ...f, targetYardCode: selectValue(value) }))} + /> + { + const value = e.currentTarget.value; + setForm((f) => ({ ...f, storageType: value })); + }} + /> - - + + @@ -178,9 +318,9 @@ function AllocationRules() { function FeeRules() { const { toast } = useToast(); - const { data, isLoading } = useQuery(api.warehouses.feeRules.queryOptions()); - const create = useMutation(api.warehouses.createFeeRule.mutationOptions()); - const remove = useMutation(api.warehouses.deleteFeeRule.mutationOptions()); + const { data, isLoading } = useFeeRules(); + const create = useCreateFeeRule(); + const remove = useDeleteFeeRule(); const [open, setOpen] = useState(false); const [form, setForm] = useState({ name: '', @@ -199,6 +339,7 @@ function FeeRules() { toast({ variant: 'destructive', title: 'Name is required' }); return; } + await create.mutateAsync({ name: form.name.trim(), ruleType: form.ruleType, @@ -208,86 +349,158 @@ function FeeRules() { freeDays: form.freeDays, ratePerDay: form.ratePerDay, currency: form.currency || 'USD', - isActive: true, } as never); toast({ title: 'Fee rule created' }); setOpen(false); }; - const columns: ColumnDef<(typeof rules)[number]>[] = [ - { - id: 'type', - header: 'Type', - cell: ({ row }) => ( - - {row.original.ruleType === 'DEMURRAGE_FEE' ? 'Demurrage' : 'Storage'} - - ), - }, - { id: 'name', header: 'Name', cell: ({ row }) => row.original.name }, - { id: 'freight', header: 'Freight', cell: ({ row }) => row.original.freightType ?? '—' }, - { id: 'trade', header: 'Trade', cell: ({ row }) => row.original.tradeDirection ?? '—' }, - { id: 'freeDays', header: 'Free days', cell: ({ row }) => row.original.freeDays }, - { - id: 'rate', - header: 'Rate / day', - cell: ({ row }) => `${Number(row.original.ratePerDay).toLocaleString()} ${row.original.currency}`, - }, - { - id: 'active', - header: 'Active', - cell: ({ row }) => ( - - {row.original.isActive ? 'Yes' : 'No'} - - ), - }, - { - id: 'actions', - header: '', - cell: ({ row }) => ( - e.stopPropagation()}> - remove.mutate(row.original.id)} title="Delete"> - - - - ), - }, - ]; - return ( <> - {rules.length} rule(s) — most specific match applies - + + {rules.length} rule(s) - most specific match applies + + - + + {isLoading ? ( + + + + ) : ( + + + + + Type + Name + Freight + Trade + Free days + Rate / day + Active + Actions + + + + {rules.map((rule) => ( + + + + {rule.ruleType === 'DEMURRAGE_FEE' ? 'Demurrage' : 'Storage'} + + + {rule.name} + {rule.freightType ?? dash} + {rule.tradeDirection ?? dash} + {rule.freeDays} + + {Number(rule.ratePerDay).toLocaleString()} {rule.currency} + + + + {rule.isActive ? 'Yes' : 'No'} + + + + remove.mutate(rule.id)} + title="Delete" + > + + + + + ))} + +
+
+ )} setOpen(false)} title="New fee rule" centered size="lg"> - setForm((f) => ({ ...f, name: e.currentTarget.value }))} /> - ({ + value: type, + label: type === 'DEMURRAGE_FEE' ? 'Demurrage' : 'Storage', + }))} + value={form.ruleType} + onChange={(value) => + setForm((f) => ({ + ...f, + ruleType: selectValue(value, 'DEMURRAGE_FEE') as FeeRuleType, + })) + } + allowDeselect={false} + /> - setForm((f) => ({ ...f, tradeDirection: v ?? '' }))} clearable /> - setForm((f) => ({ ...f, cargoTypeCode: e.currentTarget.value }))} /> + setForm((f) => ({ ...f, tradeDirection: selectValue(value) }))} + clearable + /> + { + const value = e.currentTarget.value; + setForm((f) => ({ ...f, cargoTypeCode: value })); + }} + /> - setForm((f) => ({ ...f, freeDays: Number(v) || 0 }))} /> - setForm((f) => ({ ...f, ratePerDay: Number(v) || 0 }))} /> - setForm((f) => ({ ...f, currency: e.currentTarget.value }))} /> + setForm((f) => ({ ...f, freeDays: numberValue(value) }))} + /> + setForm((f) => ({ ...f, ratePerDay: numberValue(value) }))} + /> +