mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Marshaling document Receive Export and import handover to customer
This commit is contained in:
@@ -22,6 +22,7 @@
|
||||
"seed:import-djibouti-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-import-djibouti-demo.ts",
|
||||
"seed:approved-first-lastmile-demo-bookings": "ts-node -r tsconfig-paths/register src/scripts/seed-approved-first-lastmile-demo-bookings.ts",
|
||||
"seed:negad-indode-arrived-train": "ts-node -r tsconfig-paths/register src/scripts/seed-negad-indode-arrived-train.ts",
|
||||
"auto-unload:arrived-import-trains": "ts-node -r tsconfig-paths/register src/scripts/auto-unload-arrived-import-trains.ts",
|
||||
"seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts",
|
||||
"seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh",
|
||||
"iam:typeorm:cli": "cross-env MIGRATIONS_DIR=node_modules/@tria-plc/iamapi-common/dist/db/migrations/*.{ts,js} ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -d ./node_modules/@tria-plc/api-common/dist/modules/typeorm/typeorm.config.js",
|
||||
|
||||
@@ -53,6 +53,7 @@ import { Batch7TestDataSeeder } from "./seed/batch7-test-data.seeder";
|
||||
import { Batch8TestDataSeeder } from "./seed/batch8-test-data.seeder";
|
||||
import { WarehouseDemoSeeder } from "./seed/warehouse-demo.seeder";
|
||||
import { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-interchange-demo.seeder";
|
||||
import { MarshallingDemoTrainsSeeder } from "./seed/marshalling-demo-trains.seeder";
|
||||
import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
|
||||
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
|
||||
import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder";
|
||||
@@ -150,6 +151,7 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera
|
||||
Batch8TestDataSeeder,
|
||||
WarehouseDemoSeeder,
|
||||
ExportDjiboutiInterchangeDemoSeeder,
|
||||
MarshallingDemoTrainsSeeder,
|
||||
ApprovedFirstLastMileDemoBookingsSeeder,
|
||||
],
|
||||
})
|
||||
@@ -168,6 +170,7 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
private readonly batch8TestDataSeeder: Batch8TestDataSeeder,
|
||||
private readonly warehouseDemoSeeder: WarehouseDemoSeeder,
|
||||
private readonly exportDjiboutiInterchangeDemoSeeder: ExportDjiboutiInterchangeDemoSeeder,
|
||||
private readonly marshallingDemoTrainsSeeder: MarshallingDemoTrainsSeeder,
|
||||
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
|
||||
private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
|
||||
) { }
|
||||
@@ -187,6 +190,7 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
await this.batch8TestDataSeeder.run();
|
||||
await this.warehouseDemoSeeder.run();
|
||||
await this.exportDjiboutiInterchangeDemoSeeder.run();
|
||||
await this.marshallingDemoTrainsSeeder.run();
|
||||
// Idempotent demo data: ≥100 wagons/type, approval chains, 4 staff users.
|
||||
// Each block self-guards on an empty-table check, so this is safe every boot.
|
||||
// Demo data seeds (DemoBookingsSeeder, PricingDataSeeder,
|
||||
|
||||
@@ -32,6 +32,9 @@ export interface ImportTrainItemRow {
|
||||
bookingReference: string | null;
|
||||
customerId: string | null;
|
||||
customerName: string | null;
|
||||
wagonNumber: string | null;
|
||||
sequenceNo: number | null;
|
||||
allocatedWeightTons: number | null;
|
||||
containerNumber: string | null;
|
||||
cargoType: string | null;
|
||||
weight: number | null;
|
||||
@@ -59,6 +62,9 @@ export interface ExportTrainItemRow {
|
||||
bookingReference: string | null;
|
||||
customerId: string | null;
|
||||
customerName: string | null;
|
||||
wagonNumber: string | null;
|
||||
sequenceNo: number | null;
|
||||
allocatedWeightTons: number | null;
|
||||
itemType: 'CONTAINER' | 'CARGO';
|
||||
itemId: string | null;
|
||||
inventoryId: string | null;
|
||||
@@ -265,6 +271,9 @@ export class SchedulingReadFacade {
|
||||
b.reference AS "bookingReference",
|
||||
b.company_id AS "customerId",
|
||||
company.name AS "customerName",
|
||||
w.wagon_number AS "wagonNumber",
|
||||
tsw.sequence_no AS "sequenceNo",
|
||||
wba.allocated_weight_tons AS "allocatedWeightTons",
|
||||
(SELECT c.container_number FROM freight.containers c
|
||||
WHERE c.booking_id = b.id AND c.deleted_at IS NULL
|
||||
ORDER BY c.container_number LIMIT 1) AS "containerNumber",
|
||||
@@ -279,11 +288,24 @@ export class SchedulingReadFacade {
|
||||
FROM freight.train_schedule_bookings tsb
|
||||
JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id
|
||||
JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL
|
||||
LEFT JOIN freight.wagon_booking_allocations wba ON wba.booking_id = b.id
|
||||
AND wba.deleted_at IS NULL
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM freight.train_set_wagons tsw_match
|
||||
WHERE tsw_match.id = wba.train_set_wagon_id
|
||||
AND tsw_match.train_set_id = ts.train_set_id
|
||||
AND tsw_match.deleted_at IS NULL
|
||||
)
|
||||
LEFT JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id
|
||||
AND tsw.train_set_id = ts.train_set_id
|
||||
AND tsw.deleted_at IS NULL
|
||||
LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id AND w.deleted_at IS NULL
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
|
||||
LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
|
||||
WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL
|
||||
ORDER BY b.reference ASC NULLS LAST`,
|
||||
ORDER BY tsw.sequence_no ASC NULLS LAST, b.reference ASC NULLS LAST`,
|
||||
[scheduleId],
|
||||
);
|
||||
return rows;
|
||||
@@ -401,6 +423,9 @@ export class SchedulingReadFacade {
|
||||
b.reference,
|
||||
b.company_id,
|
||||
company.name AS customer_name,
|
||||
w.wagon_number,
|
||||
tsw.sequence_no,
|
||||
wba.allocated_weight_tons,
|
||||
COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS cargo_type,
|
||||
b.cargo_total_weight_vgm AS booking_weight,
|
||||
oy.code AS origin,
|
||||
@@ -412,6 +437,19 @@ export class SchedulingReadFacade {
|
||||
FROM freight.train_schedule_bookings tsb
|
||||
JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id
|
||||
JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL
|
||||
LEFT JOIN freight.wagon_booking_allocations wba ON wba.booking_id = b.id
|
||||
AND wba.deleted_at IS NULL
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM freight.train_set_wagons tsw_match
|
||||
WHERE tsw_match.id = wba.train_set_wagon_id
|
||||
AND tsw_match.train_set_id = ts.train_set_id
|
||||
AND tsw_match.deleted_at IS NULL
|
||||
)
|
||||
LEFT JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id
|
||||
AND tsw.train_set_id = ts.train_set_id
|
||||
AND tsw.deleted_at IS NULL
|
||||
LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id AND w.deleted_at IS NULL
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
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
|
||||
@@ -423,6 +461,9 @@ export class SchedulingReadFacade {
|
||||
a.reference AS "bookingReference",
|
||||
a.company_id AS "customerId",
|
||||
a.customer_name AS "customerName",
|
||||
a.wagon_number AS "wagonNumber",
|
||||
a.sequence_no AS "sequenceNo",
|
||||
a.allocated_weight_tons AS "allocatedWeightTons",
|
||||
'CONTAINER' AS "itemType",
|
||||
c.id AS "itemId",
|
||||
a.inventory_id AS "inventoryId",
|
||||
@@ -441,6 +482,9 @@ export class SchedulingReadFacade {
|
||||
a.reference AS "bookingReference",
|
||||
a.company_id AS "customerId",
|
||||
a.customer_name AS "customerName",
|
||||
a.wagon_number AS "wagonNumber",
|
||||
a.sequence_no AS "sequenceNo",
|
||||
a.allocated_weight_tons AS "allocatedWeightTons",
|
||||
'CARGO' AS "itemType",
|
||||
cg.id AS "itemId",
|
||||
a.inventory_id AS "inventoryId",
|
||||
@@ -460,6 +504,9 @@ export class SchedulingReadFacade {
|
||||
a.reference AS "bookingReference",
|
||||
a.company_id AS "customerId",
|
||||
a.customer_name AS "customerName",
|
||||
a.wagon_number AS "wagonNumber",
|
||||
a.sequence_no AS "sequenceNo",
|
||||
a.allocated_weight_tons AS "allocatedWeightTons",
|
||||
CASE WHEN a.cargo_type ILIKE '%container%' THEN 'CONTAINER' ELSE 'CARGO' END AS "itemType",
|
||||
a.inventory_id AS "itemId",
|
||||
a.inventory_id AS "inventoryId",
|
||||
@@ -474,7 +521,7 @@ export class SchedulingReadFacade {
|
||||
FROM assigned a
|
||||
WHERE NOT EXISTS (SELECT 1 FROM freight.containers c WHERE c.booking_id = a.booking_id AND c.deleted_at IS NULL)
|
||||
AND NOT EXISTS (SELECT 1 FROM freight.cargoes cg WHERE cg.booking_id = a.booking_id AND cg.deleted_at IS NULL)
|
||||
ORDER BY "bookingReference" ASC NULLS LAST, "itemType" ASC`,
|
||||
ORDER BY "sequenceNo" ASC NULLS LAST, "bookingReference" ASC NULLS LAST, "itemType" ASC`,
|
||||
[scheduleId],
|
||||
);
|
||||
return rows;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Request, Res } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import type { Response } from 'express';
|
||||
|
||||
@@ -273,6 +273,25 @@ export class WarehouseInventoryController {
|
||||
return res.send(buffer);
|
||||
}
|
||||
|
||||
@Get(':id/handover-document')
|
||||
@ApiOperation({ summary: 'View import goods handover document PDF' })
|
||||
async handoverDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
|
||||
const { filename, buffer } = await this.inventoryService.handoverDocument(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('bookings/:bookingId/approve-delivery')
|
||||
@ApiOperation({ summary: "Approve delivery using the current customer's saved signature" })
|
||||
approveDeliveryForBooking(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@Request() req: { user?: { id?: string; sub?: string } },
|
||||
) {
|
||||
return this.inventoryService.approveDeliveryForBooking(bookingId, req.user?.id ?? req.user?.sub);
|
||||
}
|
||||
|
||||
@Post(':id/deliver')
|
||||
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' })
|
||||
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { InterchangeDocumentsService } from '../interchange-documents/interchang
|
||||
import type { InterchangeDocument } from '../interchange-documents/entities/interchange-document.entity';
|
||||
import { LastMileService } from '../last-mile/last-mile.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { SignaturesService } from '../signatures/signatures.service';
|
||||
import { BulkInspectDto } from './dto/bulk-inspect.dto';
|
||||
import { BulkReceiveDto, TruckEntranceDto } from './dto/bulk-receive.dto';
|
||||
import { DeliverInventoryDto } from './dto/deliver-inventory.dto';
|
||||
@@ -50,6 +51,8 @@ const normalizeWagonStatus = (status: string | null | undefined) =>
|
||||
const isLoadableWagonStatus = (status: string | null | undefined) =>
|
||||
LOADABLE_WAGON_STATUSES.includes(normalizeWagonStatus(status));
|
||||
|
||||
const CUSTOMER_DELIVERY_APPROVAL_PREFIX = 'CUSTOMER_DELIVERY_APPROVAL:';
|
||||
|
||||
export interface InventoryInquiryResult {
|
||||
id: string;
|
||||
inventoryId: string | null;
|
||||
@@ -297,6 +300,9 @@ export interface ImportUnloadedRow {
|
||||
pickupOption: string;
|
||||
lastMileRequested: boolean;
|
||||
currentStatus: string;
|
||||
releaseDate: string | null;
|
||||
releaseOrderReference: string | null;
|
||||
deliveredAt: string | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -316,6 +322,7 @@ export class WarehouseInventoryService {
|
||||
private readonly interchangeDocuments: InterchangeDocumentsService,
|
||||
private readonly lastMileService: LastMileService,
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly signatures: SignaturesService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -1020,6 +1027,9 @@ export class WarehouseInventoryService {
|
||||
THEN 'DOOR_DELIVERY' ELSE 'TERMINAL_PICKUP' END AS "pickupOption",
|
||||
(b.last_mile_delivery_address IS NOT NULL) AS "lastMileRequested",
|
||||
inv.status AS "currentStatus",
|
||||
inv.release_date AS "releaseDate",
|
||||
inv.release_order_reference AS "releaseOrderReference",
|
||||
inv.delivered_at AS "deliveredAt",
|
||||
oy.country AS "originCountry",
|
||||
dy.country AS "destinationCountry"
|
||||
FROM freight.warehouse_inventory inv
|
||||
@@ -1049,7 +1059,16 @@ export class WarehouseInventoryService {
|
||||
* states), with the columns the inspection screen needs. Read-only.
|
||||
*/
|
||||
importUnloadedQueue(): Promise<ImportUnloadedRow[]> {
|
||||
return this.importQueueByStatuses(['UNLOADED', 'DESTINATION_INSPECTION', 'UNDER_INSPECTION']);
|
||||
return this.importQueueByStatuses([
|
||||
'UNLOADED',
|
||||
'DESTINATION_INSPECTION',
|
||||
'UNDER_INSPECTION',
|
||||
'ARRIVED_AT_WAREHOUSE',
|
||||
'STORED',
|
||||
'READY_FOR_PICKUP',
|
||||
'DISPATCHED',
|
||||
'DELIVERED',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2020,6 +2039,154 @@ export class WarehouseInventoryService {
|
||||
}
|
||||
|
||||
/** Hand import goods to the customer + capture proof of delivery (READY_FOR_PICKUP → DELIVERED). */
|
||||
async approveDeliveryForBooking(
|
||||
bookingId: string,
|
||||
userId?: string,
|
||||
): Promise<{ bookingId: string; inventoryId: string; approvedAt: string; signerDisplayName: string }> {
|
||||
if (!userId) {
|
||||
throw new BadRequestException('Authentication is required to approve delivery');
|
||||
}
|
||||
|
||||
const signature = await this.signatures.getForUser(userId);
|
||||
if (!signature?.signatureImageUrl) {
|
||||
throw new BadRequestException('Please save your signature before approving delivery');
|
||||
}
|
||||
|
||||
const [item]: Array<{ id: string; warehouseId: string | null; notes: string | null }> =
|
||||
await this.dataSource.query(
|
||||
`SELECT inv.id,
|
||||
inv.warehouse_id AS "warehouseId",
|
||||
inv.notes
|
||||
FROM freight.warehouse_inventory inv
|
||||
JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
|
||||
WHERE inv.booking_id = $1
|
||||
AND inv.deleted_at IS NULL
|
||||
AND inv.inspection_status = 'PASSED'
|
||||
ORDER BY inv.updated_at DESC NULLS LAST, inv.created_at DESC
|
||||
LIMIT 1`,
|
||||
[bookingId],
|
||||
);
|
||||
|
||||
if (!item) {
|
||||
throw new BadRequestException('Delivery can be approved after warehouse inspection has passed');
|
||||
}
|
||||
|
||||
const approvedAt = new Date();
|
||||
const approval = {
|
||||
approvedAt: approvedAt.toISOString(),
|
||||
signerDisplayName: signature.signerDisplayName,
|
||||
signatureImageUrl: signature.signatureImageUrl,
|
||||
userId,
|
||||
};
|
||||
const existingNotes = this.stripCustomerDeliveryApproval(item.notes);
|
||||
const approvalNote = `${CUSTOMER_DELIVERY_APPROVAL_PREFIX}${JSON.stringify(approval)}`;
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(WarehouseInventory).update(item.id, {
|
||||
notes: this.appendNote(existingNotes, approvalNote),
|
||||
});
|
||||
await this.activityLog.record(
|
||||
{
|
||||
activityType: 'INVENTORY_RELEASED',
|
||||
inventoryId: item.id,
|
||||
warehouseId: item.warehouseId,
|
||||
description: `Customer approved delivery as ${signature.signerDisplayName}`,
|
||||
performedBy: signature.signerDisplayName,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
bookingId,
|
||||
inventoryId: item.id,
|
||||
approvedAt: approval.approvedAt,
|
||||
signerDisplayName: signature.signerDisplayName,
|
||||
};
|
||||
}
|
||||
|
||||
async handoverDocument(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const [row] = await this.dataSource.query(
|
||||
`SELECT inv.id,
|
||||
inv.booking_id AS "bookingId",
|
||||
inv.quantity,
|
||||
inv.weight,
|
||||
inv.status,
|
||||
inv.notes,
|
||||
inv.inspection_status AS "inspectionStatus",
|
||||
inv.release_date AS "releaseDate",
|
||||
inv.release_order_reference AS "releaseOrderReference",
|
||||
COALESCE(inv.unloaded_at, inv.arrived_at, inv.created_at) AS "handoverDate",
|
||||
b.reference AS "bookingReference",
|
||||
b.status AS "bookingStatus",
|
||||
b.freight_type AS "freightType",
|
||||
b.trade_direction AS "tradeDirection",
|
||||
company.name AS "customerName",
|
||||
COALESCE(container.container_number, booking_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",
|
||||
ts.train_number AS "trainSchedule"
|
||||
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 container.id = inv.container_id AND container.deleted_at IS NULL
|
||||
LEFT JOIN freight.booking_container booking_container ON (
|
||||
booking_container.booking_id = b.id
|
||||
AND booking_container.deleted_at IS NULL
|
||||
)
|
||||
LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_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 freight.train_schedule_bookings tsb ON tsb.booking_id = b.id AND tsb.deleted_at IS NULL
|
||||
LEFT JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id AND ts.deleted_at IS NULL
|
||||
WHERE inv.id = $1 AND inv.deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[id],
|
||||
);
|
||||
if (!row) {
|
||||
throw new NotFoundException(`Inventory item ${id} not found`);
|
||||
}
|
||||
if (row.inspectionStatus !== 'PASSED') {
|
||||
throw new BadRequestException('Handover document is available after inspection has passed');
|
||||
}
|
||||
|
||||
const bookingReference = row.bookingReference || row.bookingId || 'N/A';
|
||||
const html = this.buildHandoverDocumentHtml({
|
||||
reference: `HND-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}`,
|
||||
handedOverAt: new Date(row.handoverDate ?? Date.now()),
|
||||
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 ?? 0),
|
||||
weight: Number(row.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 ?? null,
|
||||
inspectionStatus: row.inspectionStatus ?? null,
|
||||
releaseOrderReference: row.releaseOrderReference ?? null,
|
||||
releaseDate: row.releaseDate ? new Date(row.releaseDate) : null,
|
||||
trainSchedule: row.trainSchedule ?? null,
|
||||
customerApproval: this.extractCustomerDeliveryApproval(row.notes),
|
||||
});
|
||||
|
||||
return {
|
||||
filename: `handover-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
|
||||
buffer: await this.releaseDocuments.htmlToPdfBuffer(html),
|
||||
};
|
||||
}
|
||||
|
||||
async deliver(id: string, dto: DeliverInventoryDto): Promise<WarehouseInventory> {
|
||||
const item = await this.findById(id);
|
||||
this.assertTransition(item.status, 'DELIVERED');
|
||||
@@ -2619,6 +2786,183 @@ export class WarehouseInventoryService {
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private buildHandoverDocumentHtml(data: {
|
||||
reference: string;
|
||||
handedOverAt: 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;
|
||||
inspectionStatus: string | null;
|
||||
releaseOrderReference: string | null;
|
||||
releaseDate: Date | null;
|
||||
trainSchedule: string | null;
|
||||
customerApproval: {
|
||||
approvedAt: string;
|
||||
signerDisplayName: string;
|
||||
signatureImageUrl: string;
|
||||
} | null;
|
||||
}): string {
|
||||
const esc = (value: unknown) =>
|
||||
String(value ?? '-')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
const fmt = (date: Date | string | null) => {
|
||||
if (!date) return '-';
|
||||
const parsed = date instanceof Date ? date : new Date(date);
|
||||
if (Number.isNaN(parsed.getTime())) return '-';
|
||||
return parsed.toLocaleString('en-GB', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
const rows = [
|
||||
['Booking Reference', data.bookingReference],
|
||||
['Customer / Consignee', data.customerName],
|
||||
['Booking Status', data.bookingStatus],
|
||||
['Freight Type', data.freightType],
|
||||
['Trade Direction', data.tradeDirection],
|
||||
['Train Schedule', data.trainSchedule],
|
||||
['Container Number', data.containerNumber],
|
||||
['Cargo / Goods Description', data.cargoDescription],
|
||||
['Quantity', data.quantity],
|
||||
['Declared Weight', `${data.weight.toLocaleString()} kg`],
|
||||
['Warehouse', data.warehouse],
|
||||
['Yard', data.yard],
|
||||
['Zone', data.zone],
|
||||
['Inventory Status', data.inventoryStatus],
|
||||
['Inspection Status', data.inspectionStatus],
|
||||
['Release Order', data.releaseOrderReference],
|
||||
['Release Date', fmt(data.releaseDate)],
|
||||
];
|
||||
const approval = data.customerApproval;
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Import Goods Handover Document</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
@page { size: A4; margin: 12mm 15mm 14mm; }
|
||||
body { font-family: "Times New Roman", Georgia, serif; color: #061323; margin: 0; background: #fff; }
|
||||
.top { display: grid; grid-template-columns: 1fr 190px; gap: 26px; border-top: 5px solid #2a2a2a; padding-top: 18px; }
|
||||
.brand { font-size: 12px; color: #064c27; text-transform: uppercase; letter-spacing: .13em; font-weight: 800; }
|
||||
h1 { margin: 8px 0 0; max-width: 380px; font-size: 29px; line-height: .98; text-transform: uppercase; letter-spacing: .02em; }
|
||||
.subtitle { margin-top: 12px; font-size: 11px; color: #3d516a; text-transform: uppercase; letter-spacing: .14em; }
|
||||
.ref { text-align: right; font-size: 11px; color: #334155; padding-top: 8px; }
|
||||
.ref strong { display: block; color: #061323; font-size: 18px; margin: 5px 0 8px; letter-spacing: .02em; }
|
||||
.rule { height: 3px; background: #064c27; margin: 16px 0 22px; }
|
||||
.notice { width: 78%; margin: 0 0 18px; padding: 13px 18px; background: #f3fff6; border: 1px solid #61d98b; border-left: 5px solid #16743d; font-size: 13px; line-height: 1.45; }
|
||||
.section-title { margin: 18px 0 8px; font-size: 13px; font-weight: 800; color: #064c27; text-transform: uppercase; letter-spacing: .12em; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th { width: 31%; text-align: left; color: #0f2744; background: #f8fafc; font-weight: 800; }
|
||||
th, td { border: 1px solid #b9c7d8; padding: 8px 10px; font-size: 12.2px; vertical-align: top; }
|
||||
.clause { margin-top: 14px; border: 1px solid #b9c7d8; padding: 12px 15px; font-size: 12.2px; line-height: 1.45; }
|
||||
.signatures { display: grid; grid-template-columns: 1fr 96px 1.45fr; gap: 22px; align-items: start; margin-top: 42px; }
|
||||
.line { border-top: 1.4px solid #061323; padding-top: 7px; font-size: 10.8px; color: #24384f; min-height: 72px; }
|
||||
.signature-img { display: block; max-width: 210px; max-height: 58px; margin: 2px 0 6px; object-fit: contain; }
|
||||
.signature-meta { font-size: 11px; color: #061323; }
|
||||
.seal { position: relative; width: 96px; height: 96px; margin: -28px auto 0; border: 3px double #17633a; border-radius: 999px; color: #17633a; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 14px; line-height: 1.05; transform: rotate(-17deg); text-transform: uppercase; }
|
||||
.seal::before { content: ""; position: absolute; width: 78px; height: 78px; border: 1px solid #17633a; border-radius: 999px; }
|
||||
.seal span { position: relative; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="top">
|
||||
<div>
|
||||
<div class="brand">Ethio-Djibouti Railway S.C.</div>
|
||||
<h1>Import Goods Handover Document</h1>
|
||||
<div class="subtitle">EDR to customer warehouse handover</div>
|
||||
</div>
|
||||
<div class="ref">
|
||||
Document No.
|
||||
<strong>${esc(data.reference)}</strong>
|
||||
Handover: ${esc(fmt(data.handedOverAt))}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rule"></div>
|
||||
<div class="notice">
|
||||
This document confirms EDR handed over the listed import goods to the customer after warehouse inspection passed.
|
||||
</div>
|
||||
<div class="section-title">Handover Particulars</div>
|
||||
<table>
|
||||
<tbody>
|
||||
${rows.map(([label, value]) => `<tr><th>${esc(label)}</th><td>${esc(value)}</td></tr>`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="section-title">Goods List</div>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr><th>1. Goods</th><td>${esc(data.cargoDescription || data.containerNumber || data.bookingReference)}</td></tr>
|
||||
<tr><th>Container</th><td>${esc(data.containerNumber)}</td></tr>
|
||||
<tr><th>Weight</th><td>${esc(`${data.weight.toLocaleString()} kg`)}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="section-title">Handover Clause</div>
|
||||
<div class="clause">
|
||||
The customer acknowledges receipt of the goods listed above. Warehouse staff shall verify identity, booking reference,
|
||||
inspection status, and release records before final physical handover.
|
||||
</div>
|
||||
<div class="signatures">
|
||||
<div class="line">Officer in charge name / signature / date</div>
|
||||
<div class="seal"><span>EDR<br />Warehouse<br />Handover</span></div>
|
||||
<div class="line">
|
||||
${approval?.signatureImageUrl ? `<img class="signature-img" src="${esc(approval.signatureImageUrl)}" />` : ''}
|
||||
<div class="signature-meta">${approval ? esc(approval.signerDisplayName) : 'Customer or driver name / signature / date'}</div>
|
||||
<div class="signature-meta">${approval ? `Approved: ${esc(fmt(approval.approvedAt))}` : ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private extractCustomerDeliveryApproval(notes?: string | null): {
|
||||
approvedAt: string;
|
||||
signerDisplayName: string;
|
||||
signatureImageUrl: string;
|
||||
} | null {
|
||||
if (!notes) return null;
|
||||
const line = notes
|
||||
.split(/\r?\n/)
|
||||
.find((entry) => entry.startsWith(CUSTOMER_DELIVERY_APPROVAL_PREFIX));
|
||||
if (!line) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(line.slice(CUSTOMER_DELIVERY_APPROVAL_PREFIX.length));
|
||||
if (!parsed?.approvedAt || !parsed?.signerDisplayName || !parsed?.signatureImageUrl) return null;
|
||||
return {
|
||||
approvedAt: String(parsed.approvedAt),
|
||||
signerDisplayName: String(parsed.signerDisplayName),
|
||||
signatureImageUrl: String(parsed.signatureImageUrl),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private stripCustomerDeliveryApproval(notes?: string | null): string | null {
|
||||
if (!notes?.trim()) return null;
|
||||
const lines = notes
|
||||
.split(/\r?\n/)
|
||||
.filter((entry) => !entry.startsWith(CUSTOMER_DELIVERY_APPROVAL_PREFIX));
|
||||
return lines.join('\n').trim() || null;
|
||||
}
|
||||
|
||||
private assertTransition(from: WarehouseInventoryStatus, to: WarehouseInventoryStatus): void {
|
||||
if (!WAREHOUSE_INVENTORY_TRANSITIONS[from]?.includes(to)) {
|
||||
throw new BadRequestException(`Invalid transition ${from} → ${to}`);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { FilesModule } from '../files/files.module';
|
||||
import { InterchangeDocumentsModule } from '../interchange-documents/interchange-documents.module';
|
||||
import { LastMileModule } from '../last-mile/last-mile.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { SignaturesModule } from '../signatures/signatures.module';
|
||||
import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
|
||||
import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity';
|
||||
import { WarehouseFeeInvoice } from './entities/warehouse-fee-invoice.entity';
|
||||
@@ -73,6 +74,7 @@ import { WarehousesService } from './warehouses.service';
|
||||
InterchangeDocumentsModule,
|
||||
forwardRef(() => LastMileModule),
|
||||
NotificationsModule,
|
||||
SignaturesModule,
|
||||
ExchangeModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService): ExchangeOptions =>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import 'reflect-metadata';
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
config({ path: resolve(__dirname, '../../.env') });
|
||||
process.env.TYPEORM_LOGGING = 'false';
|
||||
|
||||
import { AppModule } from '../app.module';
|
||||
import { deriveTradeDirection } from '../common/derive-trade-direction.util';
|
||||
import { WarehouseInventoryService } from '../modules/warehouses/warehouse-inventory.service';
|
||||
|
||||
async function main() {
|
||||
const app = await NestFactory.createApplicationContext(AppModule, {
|
||||
logger: ['error', 'warn'],
|
||||
});
|
||||
|
||||
try {
|
||||
const dataSource = app.get(DataSource);
|
||||
const inventory = app.get(WarehouseInventoryService);
|
||||
|
||||
const schedules: {
|
||||
id: string;
|
||||
trainNumber: string | null;
|
||||
originCountry: string | null;
|
||||
destinationCountry: string | null;
|
||||
}[] = await dataSource.query(
|
||||
`SELECT ts.id,
|
||||
ts.train_number AS "trainNumber",
|
||||
oy.country AS "originCountry",
|
||||
dy.country AS "destinationCountry"
|
||||
FROM freight.train_schedules ts
|
||||
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 ts.status = 'ARRIVED'
|
||||
AND ts.deleted_at IS NULL
|
||||
ORDER BY COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) DESC NULLS LAST,
|
||||
ts.created_at DESC`,
|
||||
);
|
||||
|
||||
const importSchedules = schedules.filter(
|
||||
(schedule) =>
|
||||
deriveTradeDirection(
|
||||
{ country: schedule.originCountry },
|
||||
{ country: schedule.destinationCountry },
|
||||
) === 'IMPORT',
|
||||
);
|
||||
|
||||
if (importSchedules.length === 0) {
|
||||
console.log('No ARRIVED import trains found.');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const schedule of importSchedules) {
|
||||
const result = await inventory.autoUnloadArrivedBookings(
|
||||
schedule.id,
|
||||
'Demo Auto Unload',
|
||||
);
|
||||
console.log(
|
||||
`${schedule.trainNumber ?? schedule.id}: ${result.unloadedCount} unloaded, ${result.skippedCount} skipped, ${result.failedCount} failed`,
|
||||
);
|
||||
for (const item of result.results) {
|
||||
console.log(` - ${item.bookingId}: ${item.status}${item.reason ? ` (${item.reason})` : ''}`);
|
||||
}
|
||||
}
|
||||
|
||||
const queueRows = await inventory.importUnloadedQueue();
|
||||
console.log(`Import Unloaded Queue rows now visible: ${queueRows.length}`);
|
||||
const byStatus = queueRows.reduce<Record<string, number>>((acc, row) => {
|
||||
acc[row.currentStatus] = (acc[row.currentStatus] ?? 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
for (const [status, count] of Object.entries(byStatus)) {
|
||||
console.log(` ${status}: ${count}`);
|
||||
}
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -1,16 +1,30 @@
|
||||
import 'reflect-metadata';
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
import { WagonStatus } from '@edr/types';
|
||||
|
||||
config({ path: resolve(__dirname, '../../.env') });
|
||||
|
||||
import { AppDataSource } from '../data-source';
|
||||
import { BookingContainer } from '../modules/bookings/entities/booking-container.entity';
|
||||
import { Booking } from '../modules/bookings/entities/booking.entity';
|
||||
import { Company, CompanyStatus, CompanyType } from '../modules/companies/entities/company.entity';
|
||||
import { Locomotive } from '../modules/locomotives/entities/locomotive.entity';
|
||||
import { ImportDjiboutiOperation } from '../modules/train-scheduling/entities/import-djibouti-operation.entity';
|
||||
import { ContainerType } from '../modules/rule-engine/entities/container-type.entity';
|
||||
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
|
||||
import { Yard } from '../modules/rule-engine/entities/yard.entity';
|
||||
import { TrainScheduleBooking } from '../modules/train-schedules/entities/train-schedule-booking.entity';
|
||||
import { WagonAllocationContainerItem } from '../modules/train-schedules/entities/wagon-allocation-container-item.entity';
|
||||
import { WagonBookingAllocation } from '../modules/train-schedules/entities/wagon-booking-allocation.entity';
|
||||
import { TrainSchedule } from '../modules/train-schedules/entities/train-schedule.entity';
|
||||
import { TrainSetWagon } from '../modules/train-sets/entities/train-set-wagon.entity';
|
||||
import { TrainSet } from '../modules/train-sets/entities/train-set.entity';
|
||||
import { WagonType } from '../modules/wagon-types/entities/wagon-type.entity';
|
||||
import { Wagon } from '../modules/wagons/entities/wagon.entity';
|
||||
|
||||
const TRAIN_NUMBER = 'NEGAD-INDODE-ARR-01';
|
||||
const BOOKING_REFS = ['NEGAD-INDODE-BKG-001', 'NEGAD-INDODE-BKG-002', 'NEGAD-INDODE-BKG-003'] as const;
|
||||
|
||||
function addHours(date: Date, hours: number): Date {
|
||||
return new Date(date.getTime() + hours * 60 * 60 * 1000);
|
||||
@@ -25,18 +39,34 @@ async function main() {
|
||||
const locomotiveRepo = manager.getRepository(Locomotive);
|
||||
const trainSetRepo = manager.getRepository(TrainSet);
|
||||
const scheduleRepo = manager.getRepository(TrainSchedule);
|
||||
const wagonTypeRepo = manager.getRepository(WagonType);
|
||||
const wagonRepo = manager.getRepository(Wagon);
|
||||
const trainSetWagonRepo = manager.getRepository(TrainSetWagon);
|
||||
const serviceTypeRepo = manager.getRepository(ServiceType);
|
||||
const containerTypeRepo = manager.getRepository(ContainerType);
|
||||
const companyRepo = manager.getRepository(Company);
|
||||
const bookingRepo = manager.getRepository(Booking);
|
||||
const bookingContainerRepo = manager.getRepository(BookingContainer);
|
||||
const scheduleBookingRepo = manager.getRepository(TrainScheduleBooking);
|
||||
const allocationRepo = manager.getRepository(WagonBookingAllocation);
|
||||
const containerItemRepo = manager.getRepository(WagonAllocationContainerItem);
|
||||
const importOperationRepo = manager.getRepository(ImportDjiboutiOperation);
|
||||
|
||||
const negad =
|
||||
(await yardRepo.findOne({ where: { code: 'NEGAD' } })) ??
|
||||
(await yardRepo.save(
|
||||
yardRepo.create({
|
||||
code: 'NEGAD',
|
||||
label: 'Negad',
|
||||
label: 'Negad / Nagad',
|
||||
country: 'Djibouti',
|
||||
isActive: true,
|
||||
displayOrder: 5,
|
||||
}),
|
||||
));
|
||||
if (negad.label !== 'Negad / Nagad') {
|
||||
negad.label = 'Negad / Nagad';
|
||||
await yardRepo.save(negad);
|
||||
}
|
||||
|
||||
const indode =
|
||||
(await yardRepo.findOne({ where: { code: 'INDODE' } })) ??
|
||||
@@ -64,6 +94,78 @@ async function main() {
|
||||
}),
|
||||
));
|
||||
|
||||
const wagonType =
|
||||
(await wagonTypeRepo.findOne({ where: { code: 'NEGAD-FLAT' } })) ??
|
||||
(await wagonTypeRepo.save(
|
||||
wagonTypeRepo.create({
|
||||
code: 'NEGAD-FLAT',
|
||||
name: 'Negad Demo Flat Wagon',
|
||||
capacityTons: 70,
|
||||
lengthMeters: 14,
|
||||
maxWagonsPerTrain: 53,
|
||||
supportedLoadTypes: ['CONTAINER'],
|
||||
isActive: true,
|
||||
equatedLengthM: 14,
|
||||
tareWeightTons: 20,
|
||||
supportsContainer: true,
|
||||
maxContainerGrossT: 70,
|
||||
}),
|
||||
));
|
||||
|
||||
const containerType =
|
||||
(await containerTypeRepo.findOne({ where: { code: '40FT' } })) ??
|
||||
(await containerTypeRepo.save(
|
||||
containerTypeRepo.create({
|
||||
code: '40FT',
|
||||
label: '40FT',
|
||||
sizeFt: 40,
|
||||
wagonsPerUnit: 1,
|
||||
isReefer: false,
|
||||
isOpenTop: false,
|
||||
isActive: true,
|
||||
displayOrder: 2,
|
||||
}),
|
||||
));
|
||||
|
||||
const serviceType =
|
||||
(await serviceTypeRepo.findOne({ where: { code: 'RAIL_CONTAINER' } })) ??
|
||||
(await serviceTypeRepo.save(
|
||||
serviceTypeRepo.create({
|
||||
code: 'RAIL_CONTAINER',
|
||||
serviceName: 'Rail Container Service',
|
||||
description: 'Rail container service for demo marshalling',
|
||||
canBeBookedAlone: true,
|
||||
includesFirstMile: false,
|
||||
includesLastMile: false,
|
||||
includesCustoms: false,
|
||||
priorityBonusPoints: 0,
|
||||
isActive: true,
|
||||
displayOrder: 1,
|
||||
}),
|
||||
));
|
||||
|
||||
const company =
|
||||
(await companyRepo.findOne({ where: { tin: 'NEGADIND01' } })) ??
|
||||
(await companyRepo.save(
|
||||
companyRepo.create({
|
||||
name: 'Negad Indode Marshalling Demo Customer',
|
||||
type: CompanyType.Customer,
|
||||
status: CompanyStatus.Active,
|
||||
tin: 'NEGADIND01',
|
||||
vatNumber: 'NEGADIND01',
|
||||
fanNumber: 'NEGADINDODE00001',
|
||||
country: 'Ethiopia',
|
||||
address: 'Indode Dry Port',
|
||||
phone: '251900000202',
|
||||
email: 'negad-indode-demo@edr.local',
|
||||
contactPersonName: 'Marshalling Demo',
|
||||
contactPersonPhone: '251900000202',
|
||||
generalManagerName: 'Demo Manager',
|
||||
generalManagerEmail: 'negad-indode-demo@edr.local',
|
||||
generalManagerPhone: '251900000202',
|
||||
}),
|
||||
));
|
||||
|
||||
const now = new Date();
|
||||
const departure = addHours(now, -12);
|
||||
const arrival = now;
|
||||
@@ -75,7 +177,7 @@ async function main() {
|
||||
locomotiveId: locomotive.id,
|
||||
totalWeightTons: 960,
|
||||
totalLengthMeters: 420,
|
||||
wagonCount: 18,
|
||||
wagonCount: BOOKING_REFS.length,
|
||||
status: 'COMPLETED',
|
||||
}),
|
||||
);
|
||||
@@ -111,9 +213,169 @@ async function main() {
|
||||
}
|
||||
|
||||
const saved = await scheduleRepo.save(schedule);
|
||||
await trainSetRepo.update(saved.trainSetId, {
|
||||
totalWeightTons: BOOKING_REFS.length * 28,
|
||||
totalLengthMeters: BOOKING_REFS.length * 14,
|
||||
wagonCount: BOOKING_REFS.length,
|
||||
status: 'COMPLETED',
|
||||
});
|
||||
|
||||
const existingSlots = await trainSetWagonRepo.find({ where: { trainSetId: saved.trainSetId } });
|
||||
const existingAllocations = existingSlots.length
|
||||
? await allocationRepo.find({
|
||||
where: existingSlots.map((slot) => ({ trainSetWagonId: slot.id })),
|
||||
})
|
||||
: [];
|
||||
if (existingAllocations.length) {
|
||||
await containerItemRepo.delete(
|
||||
existingAllocations.map((allocation) => ({ wagonBookingAllocationId: allocation.id })),
|
||||
);
|
||||
}
|
||||
if (existingSlots.length) {
|
||||
await allocationRepo.delete(existingSlots.map((slot) => ({ trainSetWagonId: slot.id })));
|
||||
await wagonRepo.update(
|
||||
existingSlots.map((slot) => ({ trainSetWagonId: slot.id })),
|
||||
{
|
||||
trainSetWagonId: null,
|
||||
currentTrainScheduleId: null,
|
||||
sequenceNumber: null,
|
||||
status: WagonStatus.Available,
|
||||
},
|
||||
);
|
||||
await trainSetWagonRepo.delete({ trainSetId: saved.trainSetId });
|
||||
}
|
||||
|
||||
for (const [index, reference] of BOOKING_REFS.entries()) {
|
||||
const sequenceNo = index + 1;
|
||||
const containerNumber = `NEGADIND${String(sequenceNo).padStart(4, '0')}`;
|
||||
const weightTons = 26 + sequenceNo;
|
||||
|
||||
let booking = await bookingRepo.findOne({ where: { reference } });
|
||||
if (!booking) {
|
||||
booking = bookingRepo.create({ reference });
|
||||
}
|
||||
Object.assign(booking, {
|
||||
companyId: company.id,
|
||||
originYardId: negad.id,
|
||||
destinationYardId: indode.id,
|
||||
serviceTypeId: serviceType.id,
|
||||
status: 'IN_TRANSIT',
|
||||
paymentStatus: 'PAID',
|
||||
scheduledDate: departure,
|
||||
estimatedShipmentDate: departure,
|
||||
contractType: 'SPOT',
|
||||
equipmentReturn: 'TERMINAL',
|
||||
paymentCurrency: 'ETB',
|
||||
totalAmount: 0,
|
||||
isGovernment: false,
|
||||
tradeDirection: 'IMPORT',
|
||||
freightType: 'CONTAINER',
|
||||
cargoTypeId: null,
|
||||
cargoFreeText: `Negad to Indode demo container ${sequenceNo}`,
|
||||
cargoTotalWeightVgm: weightTons,
|
||||
priorityScore: 75 - index,
|
||||
trainScheduleId: saved.id,
|
||||
schedulingStatus: 'SCHEDULED',
|
||||
scheduledAt: now,
|
||||
wagonsRequired: 1,
|
||||
});
|
||||
booking = await bookingRepo.save(booking);
|
||||
|
||||
await bookingContainerRepo.delete({ bookingId: booking.id });
|
||||
const bookingContainer = await bookingContainerRepo.save(
|
||||
bookingContainerRepo.create({
|
||||
bookingId: booking.id,
|
||||
containerTypeId: containerType.id,
|
||||
containerNumber,
|
||||
quantity: 1,
|
||||
vgmPerUnitTons: weightTons,
|
||||
totalVgmTons: weightTons,
|
||||
wagonsRequired: 1,
|
||||
weightLimitRuleId: null,
|
||||
isOverweight: false,
|
||||
overweightExcessTons: null,
|
||||
}),
|
||||
);
|
||||
|
||||
await scheduleBookingRepo.upsert(
|
||||
{ trainScheduleId: saved.id, bookingId: booking.id },
|
||||
{ conflictPaths: { trainScheduleId: true, bookingId: true } },
|
||||
);
|
||||
|
||||
const wagon = await wagonRepo.save(
|
||||
wagonRepo.create({
|
||||
wagonNumber: `NEGAD-INDODE-WGN-${String(sequenceNo).padStart(2, '0')}`,
|
||||
wagonTypeId: wagonType.id,
|
||||
trainId: null,
|
||||
sequenceNumber: sequenceNo,
|
||||
tareWeight: 20,
|
||||
maxPayloadWeight: 70,
|
||||
status: WagonStatus.Assigned,
|
||||
currentYardId: indode.id,
|
||||
notes: 'Demo wagon for Negad to Indode marshalling',
|
||||
trainSetWagonId: null,
|
||||
currentTrainScheduleId: saved.id,
|
||||
}),
|
||||
);
|
||||
|
||||
const trainSetWagon = await trainSetWagonRepo.save(
|
||||
trainSetWagonRepo.create({
|
||||
trainSetId: saved.trainSetId,
|
||||
wagonTypeId: wagonType.id,
|
||||
physicalWagonId: wagon.id,
|
||||
sequenceNo,
|
||||
capacityTons: 70,
|
||||
lengthMeters: 14,
|
||||
assignedWeightTons: weightTons,
|
||||
status: 'LOADED',
|
||||
}),
|
||||
);
|
||||
await wagonRepo.update(wagon.id, { trainSetWagonId: trainSetWagon.id });
|
||||
|
||||
const allocation = await allocationRepo.save(
|
||||
allocationRepo.create({
|
||||
trainSetWagonId: trainSetWagon.id,
|
||||
bookingId: booking.id,
|
||||
allocatedWeightTons: weightTons,
|
||||
loadType: 'CONTAINER',
|
||||
status: 'LOADED',
|
||||
confirmedAt: now,
|
||||
}),
|
||||
);
|
||||
|
||||
await containerItemRepo.save(
|
||||
containerItemRepo.create({
|
||||
wagonBookingAllocationId: allocation.id,
|
||||
bookingContainerId: bookingContainer.id,
|
||||
containerId: null,
|
||||
containerNumber,
|
||||
containerTypeId: containerType.id,
|
||||
positionOnWagon: 1,
|
||||
sealNumber: `SEAL-${containerNumber}`,
|
||||
grossWeightTons: weightTons,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await importOperationRepo.upsert(
|
||||
{
|
||||
trainScheduleId: saved.id,
|
||||
documents: {},
|
||||
gatepassGrantedAt: departure,
|
||||
readyForLoadingAt: departure,
|
||||
loadedOnTrainAt: departure,
|
||||
departedFromDjiboutiAt: departure,
|
||||
loadListGeneratedAt: now,
|
||||
performedBy: 'Seed Demo',
|
||||
notes: 'Seeded marshalling data for Negad to Indode arrived train',
|
||||
},
|
||||
{ conflictPaths: { trainScheduleId: true } },
|
||||
);
|
||||
|
||||
console.log(`Seeded ARRIVED train ${TRAIN_NUMBER}`);
|
||||
console.log(`Schedule ID: ${saved.id}`);
|
||||
console.log(`Route: ${negad.code} -> ${indode.code}`);
|
||||
console.log(`Marshalling data: ${BOOKING_REFS.length} bookings, wagons and allocations`);
|
||||
});
|
||||
} finally {
|
||||
await dataSource.destroy();
|
||||
|
||||
474
apps/edr-freight-api/src/seed/marshalling-demo-trains.seeder.ts
Normal file
474
apps/edr-freight-api/src/seed/marshalling-demo-trains.seeder.ts
Normal file
@@ -0,0 +1,474 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { WagonStatus } from '@edr/types';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { Booking } from '../modules/bookings/entities/booking.entity';
|
||||
import { Locomotive } from '../modules/locomotives/entities/locomotive.entity';
|
||||
import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity';
|
||||
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
|
||||
import { Yard } from '../modules/rule-engine/entities/yard.entity';
|
||||
import { ImportDjiboutiOperation } from '../modules/train-scheduling/entities/import-djibouti-operation.entity';
|
||||
import { WagonAllocationContainerItem } from '../modules/train-schedules/entities/wagon-allocation-container-item.entity';
|
||||
import { WagonBookingAllocation } from '../modules/train-schedules/entities/wagon-booking-allocation.entity';
|
||||
import { TrainSchedule } from '../modules/train-schedules/entities/train-schedule.entity';
|
||||
import { TrainScheduleBooking } from '../modules/train-schedules/entities/train-schedule-booking.entity';
|
||||
import { TrainSetWagon } from '../modules/train-sets/entities/train-set-wagon.entity';
|
||||
import { TrainSet } from '../modules/train-sets/entities/train-set.entity';
|
||||
import { WagonType } from '../modules/wagon-types/entities/wagon-type.entity';
|
||||
import { Wagon } from '../modules/wagons/entities/wagon.entity';
|
||||
import { WarehouseInventory } from '../modules/warehouses/entities/warehouse-inventory.entity';
|
||||
import { WarehouseYard } from '../modules/warehouses/entities/warehouse-yard.entity';
|
||||
import { WarehouseZone } from '../modules/warehouses/entities/warehouse-zone.entity';
|
||||
import { Warehouse } from '../modules/warehouses/entities/warehouse.entity';
|
||||
|
||||
type DemoDirection = 'IMPORT' | 'EXPORT';
|
||||
|
||||
interface DemoTrain {
|
||||
trainNumber: string;
|
||||
direction: DemoDirection;
|
||||
status: 'SCHEDULED' | 'DISPATCHED' | 'ARRIVED';
|
||||
bookingPrefix: string;
|
||||
departureOffsetHours: number;
|
||||
}
|
||||
|
||||
const DEMO_TRAINS: DemoTrain[] = [
|
||||
{
|
||||
trainNumber: 'MSH-DEMO-IMP-01',
|
||||
direction: 'IMPORT',
|
||||
status: 'SCHEDULED',
|
||||
bookingPrefix: 'MSH-IMP-01',
|
||||
departureOffsetHours: 6,
|
||||
},
|
||||
{
|
||||
trainNumber: 'MSH-DEMO-IMP-02',
|
||||
direction: 'IMPORT',
|
||||
status: 'DISPATCHED',
|
||||
bookingPrefix: 'MSH-IMP-02',
|
||||
departureOffsetHours: -3,
|
||||
},
|
||||
{
|
||||
trainNumber: 'MSH-DEMO-IMP-03',
|
||||
direction: 'IMPORT',
|
||||
status: 'ARRIVED',
|
||||
bookingPrefix: 'MSH-IMP-03',
|
||||
departureOffsetHours: -14,
|
||||
},
|
||||
{
|
||||
trainNumber: 'MSH-DEMO-EXP-01',
|
||||
direction: 'EXPORT',
|
||||
status: 'SCHEDULED',
|
||||
bookingPrefix: 'MSH-EXP-01',
|
||||
departureOffsetHours: 8,
|
||||
},
|
||||
{
|
||||
trainNumber: 'MSH-DEMO-EXP-02',
|
||||
direction: 'EXPORT',
|
||||
status: 'DISPATCHED',
|
||||
bookingPrefix: 'MSH-EXP-02',
|
||||
departureOffsetHours: -2,
|
||||
},
|
||||
{
|
||||
trainNumber: 'MSH-DEMO-EXP-03',
|
||||
direction: 'EXPORT',
|
||||
status: 'ARRIVED',
|
||||
bookingPrefix: 'MSH-EXP-03',
|
||||
departureOffsetHours: -12,
|
||||
},
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class MarshallingDemoTrainsSeeder {
|
||||
private readonly logger = new Logger(MarshallingDemoTrainsSeeder.name);
|
||||
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
async run(): Promise<void> {
|
||||
try {
|
||||
const yardRepo = this.dataSource.getRepository(Yard);
|
||||
const serviceTypeRepo = this.dataSource.getRepository(ServiceType);
|
||||
const cargoTypeRepo = this.dataSource.getRepository(CargoType);
|
||||
const wagonTypeRepo = this.dataSource.getRepository(WagonType);
|
||||
const warehouseRepo = this.dataSource.getRepository(Warehouse);
|
||||
const warehouseYardRepo = this.dataSource.getRepository(WarehouseYard);
|
||||
const warehouseZoneRepo = this.dataSource.getRepository(WarehouseZone);
|
||||
|
||||
const djiboutiYard =
|
||||
(await yardRepo.findOne({ where: { code: 'NAGAD' } })) ??
|
||||
(await yardRepo.findOne({ where: { code: 'DJIB_PORT' } })) ??
|
||||
(await yardRepo.findOne({ where: { country: 'Djibouti' } }));
|
||||
const ethiopiaYard =
|
||||
(await yardRepo.findOne({ where: { code: 'INDODE' } })) ??
|
||||
(await yardRepo.findOne({ where: { code: 'MOJO' } })) ??
|
||||
(await yardRepo.findOne({ where: { country: 'Ethiopia' } }));
|
||||
const serviceType =
|
||||
(await serviceTypeRepo.findOne({ where: { code: 'RAIL_CONTAINER' } })) ??
|
||||
(await serviceTypeRepo.findOne({ where: { isActive: true } }));
|
||||
const cargoType = await cargoTypeRepo.findOne({ where: { isActive: true } });
|
||||
const wagonType =
|
||||
(await wagonTypeRepo.findOne({ where: { code: 'NW5' } })) ??
|
||||
(await wagonTypeRepo.findOne({ where: { supportsContainer: true } })) ??
|
||||
(await wagonTypeRepo.findOne({ where: { isActive: true } }));
|
||||
const warehouse = await warehouseRepo.findOne({ where: { code: 'INDODE_OPEN' } });
|
||||
const warehouseYard = warehouse
|
||||
? await warehouseYardRepo.findOne({ where: { warehouseId: warehouse.id } })
|
||||
: null;
|
||||
const warehouseZone = warehouseYard
|
||||
? await warehouseZoneRepo.findOne({ where: { yardId: warehouseYard.id } })
|
||||
: null;
|
||||
|
||||
const missing = [
|
||||
!djiboutiYard ? 'Djibouti yard' : '',
|
||||
!ethiopiaYard ? 'Ethiopia yard' : '',
|
||||
!serviceType ? 'service type' : '',
|
||||
!wagonType ? 'wagon type' : '',
|
||||
!warehouse ? 'INDODE_OPEN warehouse' : '',
|
||||
!warehouseYard ? 'warehouse yard' : '',
|
||||
!warehouseZone ? 'warehouse zone' : '',
|
||||
].filter(Boolean);
|
||||
if (missing.length) {
|
||||
this.logger.warn(`Cannot seed marshalling demo trains, missing: ${missing.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let seeded = 0;
|
||||
for (const demo of DEMO_TRAINS) {
|
||||
const created = await this.seedTrain(demo, {
|
||||
djiboutiYard: djiboutiYard!,
|
||||
ethiopiaYard: ethiopiaYard!,
|
||||
serviceType: serviceType!,
|
||||
cargoType,
|
||||
wagonType: wagonType!,
|
||||
warehouse: warehouse!,
|
||||
warehouseYard: warehouseYard!,
|
||||
warehouseZone: warehouseZone!,
|
||||
});
|
||||
if (created) seeded += 1;
|
||||
}
|
||||
|
||||
this.logger.log(`Marshalling demo trains ready: ${seeded} new train(s) seeded, 6 total expected`);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`MarshallingDemoTrainsSeeder failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async seedTrain(
|
||||
demo: DemoTrain,
|
||||
refs: {
|
||||
djiboutiYard: Yard;
|
||||
ethiopiaYard: Yard;
|
||||
serviceType: ServiceType;
|
||||
cargoType: CargoType | null;
|
||||
wagonType: WagonType;
|
||||
warehouse: Warehouse;
|
||||
warehouseYard: WarehouseYard;
|
||||
warehouseZone: WarehouseZone;
|
||||
},
|
||||
): Promise<boolean> {
|
||||
const bookingRepo = this.dataSource.getRepository(Booking);
|
||||
const trainSetRepo = this.dataSource.getRepository(TrainSet);
|
||||
const trainSetWagonRepo = this.dataSource.getRepository(TrainSetWagon);
|
||||
const scheduleRepo = this.dataSource.getRepository(TrainSchedule);
|
||||
const scheduleBookingRepo = this.dataSource.getRepository(TrainScheduleBooking);
|
||||
const allocationRepo = this.dataSource.getRepository(WagonBookingAllocation);
|
||||
const containerItemRepo = this.dataSource.getRepository(WagonAllocationContainerItem);
|
||||
|
||||
const existing = await scheduleRepo.findOne({ where: { trainNumber: demo.trainNumber } });
|
||||
if (existing) {
|
||||
await this.backfillDispatchQueueInventory(demo, refs);
|
||||
return false;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const departure = this.addHours(now, demo.departureOffsetHours);
|
||||
const arrival = this.addHours(departure, demo.direction === 'IMPORT' ? 12 : 10);
|
||||
const isDispatched = demo.status === 'DISPATCHED';
|
||||
const isArrived = demo.status === 'ARRIVED';
|
||||
const hasDeparted = isDispatched || isArrived;
|
||||
const originYard = demo.direction === 'IMPORT' ? refs.djiboutiYard : refs.ethiopiaYard;
|
||||
const destinationYard = demo.direction === 'IMPORT' ? refs.ethiopiaYard : refs.djiboutiYard;
|
||||
|
||||
const locomotive = await this.ensureLocomotive(originYard.id);
|
||||
const bookingWeights = [22.4, 24.8, 18.6, 20.2];
|
||||
const totalWeight = bookingWeights.reduce((sum, weight) => sum + weight, 0);
|
||||
const wagonCapacity = Number(refs.wagonType.capacityTons) || 70;
|
||||
const wagonLength = Number(refs.wagonType.lengthMeters) || 14;
|
||||
const tareWeight = Number(refs.wagonType.tareWeightTons) || 14;
|
||||
|
||||
const trainSet = await trainSetRepo.save(
|
||||
trainSetRepo.create({
|
||||
locomotiveId: locomotive.id,
|
||||
totalWeightTons: totalWeight,
|
||||
totalLengthMeters: wagonLength * bookingWeights.length,
|
||||
wagonCount: bookingWeights.length,
|
||||
status: isArrived ? 'COMPLETED' : isDispatched ? 'DISPATCHED' : 'ASSIGNED',
|
||||
}),
|
||||
);
|
||||
|
||||
const schedule = await scheduleRepo.save(
|
||||
scheduleRepo.create({
|
||||
trainSetId: trainSet.id,
|
||||
originStationId: originYard.id,
|
||||
destinationStationId: destinationYard.id,
|
||||
scheduledDepartureDate: departure,
|
||||
scheduledArrivalDate: arrival,
|
||||
actualDepartureAt: hasDeparted ? departure : null,
|
||||
actualArrivalAt: isArrived ? arrival : null,
|
||||
status: demo.status as TrainSchedule['status'],
|
||||
trainNumber: demo.trainNumber,
|
||||
direction: demo.direction,
|
||||
maxWagons: 53,
|
||||
bookingWindowStatus: 'CLOSED',
|
||||
}),
|
||||
);
|
||||
|
||||
for (const [index, weightTons] of bookingWeights.entries()) {
|
||||
const sequence = index + 1;
|
||||
const bookingReference = `${demo.bookingPrefix}-${String(sequence).padStart(3, '0')}`;
|
||||
const containerNumber = `${demo.direction === 'IMPORT' ? 'IMDU' : 'EXPU'}${demo.trainNumber.slice(-2)}${String(sequence).padStart(3, '0')}`;
|
||||
|
||||
const booking = await bookingRepo.save(
|
||||
bookingRepo.create({
|
||||
reference: bookingReference,
|
||||
originYardId: originYard.id,
|
||||
destinationYardId: destinationYard.id,
|
||||
serviceTypeId: refs.serviceType.id,
|
||||
status: hasDeparted ? 'IN_TRANSIT' : 'PAID',
|
||||
paymentStatus: 'PAID',
|
||||
scheduledDate: departure,
|
||||
contractType: 'SPOT',
|
||||
equipmentReturn: 'TERMINAL',
|
||||
paymentCurrency: 'ETB',
|
||||
totalAmount: 0,
|
||||
isGovernment: false,
|
||||
tradeDirection: demo.direction,
|
||||
freightType: sequence % 2 === 0 ? 'BULK' : 'CONTAINER',
|
||||
cargoTypeId: refs.cargoType?.id ?? null,
|
||||
cargoFreeText: refs.cargoType ? null : `${demo.direction} marshalling demo goods ${sequence}`,
|
||||
cargoTotalWeightVgm: weightTons * 1000,
|
||||
trainScheduleId: schedule.id,
|
||||
schedulingStatus: isArrived ? 'ARRIVED' : isDispatched ? 'DISPATCHED' : 'SCHEDULED',
|
||||
scheduledAt: now,
|
||||
}),
|
||||
);
|
||||
await this.ensureDispatchQueueInventory({
|
||||
booking,
|
||||
demo,
|
||||
refs,
|
||||
weightKg: weightTons * 1000,
|
||||
now,
|
||||
});
|
||||
|
||||
const physicalWagon = await this.ensureWagon({
|
||||
wagonNumber: `${demo.trainNumber}-W${String(sequence).padStart(2, '0')}`,
|
||||
wagonTypeId: refs.wagonType.id,
|
||||
yardId: originYard.id,
|
||||
trainScheduleId: schedule.id,
|
||||
tareWeight,
|
||||
capacityTons: wagonCapacity,
|
||||
dispatched: hasDeparted,
|
||||
});
|
||||
|
||||
const trainSetWagon = await trainSetWagonRepo.save(
|
||||
trainSetWagonRepo.create({
|
||||
trainSetId: trainSet.id,
|
||||
wagonTypeId: refs.wagonType.id,
|
||||
physicalWagonId: physicalWagon.id,
|
||||
sequenceNo: sequence,
|
||||
capacityTons: wagonCapacity,
|
||||
lengthMeters: wagonLength,
|
||||
assignedWeightTons: weightTons,
|
||||
status: hasDeparted ? 'DEPARTED' : 'LOADED',
|
||||
}),
|
||||
);
|
||||
|
||||
await this.dataSource.getRepository(Wagon).update(physicalWagon.id, {
|
||||
trainSetWagonId: trainSetWagon.id,
|
||||
});
|
||||
|
||||
const allocation = await allocationRepo.save(
|
||||
allocationRepo.create({
|
||||
trainSetWagonId: trainSetWagon.id,
|
||||
bookingId: booking.id,
|
||||
allocatedWeightTons: weightTons,
|
||||
loadType: booking.freightType === 'CONTAINER' ? 'CONTAINER' : 'BULK',
|
||||
status: hasDeparted ? 'DEPARTED' : 'LOADED',
|
||||
confirmedAt: now,
|
||||
}),
|
||||
);
|
||||
|
||||
await containerItemRepo.save(
|
||||
containerItemRepo.create({
|
||||
wagonBookingAllocationId: allocation.id,
|
||||
containerNumber,
|
||||
positionOnWagon: 1,
|
||||
sealNumber: `SEAL-${demo.trainNumber.slice(-2)}-${sequence}`,
|
||||
chassisNumber: `CHS-${demo.trainNumber.slice(-2)}-${sequence}`,
|
||||
grossWeightTons: weightTons,
|
||||
}),
|
||||
);
|
||||
|
||||
await scheduleBookingRepo.save(
|
||||
scheduleBookingRepo.create({
|
||||
trainScheduleId: schedule.id,
|
||||
bookingId: booking.id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (demo.direction === 'IMPORT') {
|
||||
await this.seedImportOperation(schedule.id, demo.trainNumber, now, departure, hasDeparted);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private async backfillDispatchQueueInventory(
|
||||
demo: DemoTrain,
|
||||
refs: {
|
||||
warehouse: Warehouse;
|
||||
warehouseYard: WarehouseYard;
|
||||
warehouseZone: WarehouseZone;
|
||||
},
|
||||
): Promise<void> {
|
||||
const bookingRepo = this.dataSource.getRepository(Booking);
|
||||
for (let sequence = 1; sequence <= 4; sequence++) {
|
||||
const reference = `${demo.bookingPrefix}-${String(sequence).padStart(3, '0')}`;
|
||||
const booking = await bookingRepo.findOne({ where: { reference } });
|
||||
if (!booking) continue;
|
||||
await this.ensureDispatchQueueInventory({
|
||||
booking,
|
||||
demo,
|
||||
refs,
|
||||
weightKg: Number(booking.cargoTotalWeightVgm) || 0,
|
||||
now: new Date(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureDispatchQueueInventory(input: {
|
||||
booking: Booking;
|
||||
demo: DemoTrain;
|
||||
refs: {
|
||||
warehouse: Warehouse;
|
||||
warehouseYard: WarehouseYard;
|
||||
warehouseZone: WarehouseZone;
|
||||
};
|
||||
weightKg: number;
|
||||
now: Date;
|
||||
}): Promise<void> {
|
||||
const repo = this.dataSource.getRepository(WarehouseInventory);
|
||||
const existing = await repo.findOne({ where: { bookingId: input.booking.id } });
|
||||
if (existing) return;
|
||||
|
||||
const exportDispatch = input.demo.direction === 'EXPORT';
|
||||
const arrivedAt = this.addHours(input.now, -8);
|
||||
const inspectedAt = this.addHours(input.now, -6);
|
||||
const readyAt = this.addHours(input.now, -4);
|
||||
const loadedAt = this.addHours(input.now, -2);
|
||||
|
||||
await repo.save(
|
||||
repo.create({
|
||||
warehouseId: input.refs.warehouse.id,
|
||||
yardId: input.refs.warehouseYard.id,
|
||||
zoneId: input.refs.warehouseZone.id,
|
||||
bookingId: input.booking.id,
|
||||
quantity: 1,
|
||||
weight: input.weightKg,
|
||||
status: exportDispatch ? 'LOADED' : 'READY_FOR_PICKUP',
|
||||
inspectionStatus: 'PASSED',
|
||||
arrivedAt,
|
||||
unloadedAt: exportDispatch ? null : arrivedAt,
|
||||
inspectedAt,
|
||||
readyForLoadingAt: exportDispatch ? readyAt : null,
|
||||
loadedAt: exportDispatch ? loadedAt : null,
|
||||
readyForPickupAt: exportDispatch ? null : readyAt,
|
||||
notes: `[MSH-DEMO] ${input.demo.trainNumber} dispatch queue test item`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async ensureLocomotive(currentYardId: string): Promise<Locomotive> {
|
||||
const repo = this.dataSource.getRepository(Locomotive);
|
||||
const existing = await repo.findOne({ where: { code: 'MSH-DEMO-LOCO' } });
|
||||
if (existing) return existing;
|
||||
return repo.save(
|
||||
repo.create({
|
||||
code: 'MSH-DEMO-LOCO',
|
||||
name: 'Marshalling Demo Locomotive',
|
||||
locomotiveType: 'DIESEL',
|
||||
maxPullWeightTons: 4200,
|
||||
maxTrainLengthMeters: 760,
|
||||
status: 'AVAILABLE',
|
||||
currentYardId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async ensureWagon(input: {
|
||||
wagonNumber: string;
|
||||
wagonTypeId: string;
|
||||
yardId: string;
|
||||
trainScheduleId: string;
|
||||
tareWeight: number;
|
||||
capacityTons: number;
|
||||
dispatched: boolean;
|
||||
}): Promise<Wagon> {
|
||||
const repo = this.dataSource.getRepository(Wagon);
|
||||
const existing = await repo.findOne({ where: { wagonNumber: input.wagonNumber } });
|
||||
if (existing) return existing;
|
||||
return repo.save(
|
||||
repo.create({
|
||||
wagonNumber: input.wagonNumber,
|
||||
wagonTypeId: input.wagonTypeId,
|
||||
currentYardId: input.yardId,
|
||||
currentTrainScheduleId: input.trainScheduleId,
|
||||
tareWeight: input.tareWeight,
|
||||
maxPayloadWeight: input.capacityTons,
|
||||
status: WagonStatus.Assigned,
|
||||
notes: 'Marshalling demo seed wagon',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async seedImportOperation(
|
||||
trainScheduleId: string,
|
||||
trainNumber: string,
|
||||
now: Date,
|
||||
departure: Date,
|
||||
dispatched: boolean,
|
||||
): Promise<void> {
|
||||
const repo = this.dataSource.getRepository(ImportDjiboutiOperation);
|
||||
await repo.save(
|
||||
repo.create({
|
||||
trainScheduleId,
|
||||
documents: {
|
||||
DELIVERY_ORDER: this.documentRecord(trainNumber, 'DELIVERY_ORDER', now),
|
||||
PORT_INVOICE: this.documentRecord(trainNumber, 'PORT_INVOICE', now),
|
||||
DJIBOUTI_T1: this.documentRecord(trainNumber, 'DJIBOUTI_T1', now),
|
||||
ETHIOPIA_T1: this.documentRecord(trainNumber, 'ETHIOPIA_T1', now),
|
||||
RAILWAY_BILL: this.documentRecord(trainNumber, 'RAILWAY_BILL', now),
|
||||
},
|
||||
gatepassGrantedAt: now,
|
||||
readyForLoadingAt: now,
|
||||
loadedOnTrainAt: now,
|
||||
departedFromDjiboutiAt: dispatched ? departure : null,
|
||||
performedBy: 'Marshalling Demo Seeder',
|
||||
notes: '[MSH-DEMO] Import train ready for marshalling document and dispatch workflow',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private documentRecord(trainNumber: string, type: string, now: Date) {
|
||||
return {
|
||||
reference: `${type}-${trainNumber}`,
|
||||
uploadedAt: now.toISOString(),
|
||||
uploadedBy: 'Marshalling Demo Seeder',
|
||||
notes: 'Seeded document for import marshalling workflow',
|
||||
};
|
||||
}
|
||||
|
||||
private addHours(date: Date, hours: number): Date {
|
||||
return new Date(date.getTime() + hours * 60 * 60 * 1000);
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,8 @@ import TrainDetailPage from "./pages/trains/TrainDetailPage";
|
||||
import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage";
|
||||
import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage";
|
||||
import ExportDjiboutiUnloadingQueuePage from "./pages/warehouses/ExportDjiboutiUnloadingQueuePage";
|
||||
import ExportWarehouseFlowPage from "./pages/warehouses/ExportWarehouseFlowPage";
|
||||
import ImportWarehouseFlowPage from "./pages/warehouses/ImportWarehouseFlowPage";
|
||||
import InterchangeDocumentsPage from "./pages/warehouses/InterchangeDocumentsPage";
|
||||
import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage";
|
||||
import LoadedInventoryPage from "./pages/warehouses/LoadedInventoryPage";
|
||||
@@ -198,6 +200,85 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
// },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Port & Terminal",
|
||||
items: [
|
||||
{
|
||||
label: "Import Operations",
|
||||
href: "/dashboard/import-warehouse",
|
||||
icon: <PackageOpen />,
|
||||
children: [
|
||||
{
|
||||
label: "Import Overview",
|
||||
href: "/dashboard/import-warehouse",
|
||||
icon: <PackageOpen />,
|
||||
},
|
||||
{
|
||||
label: "Arrival Queue",
|
||||
href: "/dashboard/arrival-queue",
|
||||
icon: <PackageOpen />,
|
||||
},
|
||||
{
|
||||
label: "Dispatch Queue",
|
||||
href: "/dashboard/dispatch-queue",
|
||||
icon: <Send />,
|
||||
},
|
||||
{
|
||||
label: "Terminal Inventory",
|
||||
href: "/dashboard/warehouse-inventory",
|
||||
icon: <Package />,
|
||||
},
|
||||
{
|
||||
label: "Inventory Inquiry",
|
||||
href: "/dashboard/inventory-inquiry",
|
||||
icon: <Boxes />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Export Operations",
|
||||
href: "/dashboard/export-warehouse",
|
||||
icon: <Truck />,
|
||||
children: [
|
||||
{
|
||||
label: "Export Overview",
|
||||
href: "/dashboard/export-warehouse",
|
||||
icon: <Truck />,
|
||||
},
|
||||
{
|
||||
label: "Loading Queue",
|
||||
href: "/dashboard/loading-queue",
|
||||
icon: <Truck />,
|
||||
},
|
||||
{
|
||||
label: "Loaded Inventory",
|
||||
href: "/dashboard/loaded-inventory",
|
||||
icon: <PackageCheck />,
|
||||
},
|
||||
{
|
||||
label: "Dispatch Queue",
|
||||
href: "/dashboard/dispatch-queue",
|
||||
icon: <Send />,
|
||||
},
|
||||
{
|
||||
label: "Djibouti Unloading",
|
||||
href: "/dashboard/export-djibouti-unloading",
|
||||
icon: <PackageOpen />,
|
||||
},
|
||||
{
|
||||
label: "Interchange Documents",
|
||||
href: "/dashboard/interchange-documents",
|
||||
icon: <FileText />,
|
||||
},
|
||||
{
|
||||
label: "Terminal Inventory",
|
||||
href: "/dashboard/warehouse-inventory",
|
||||
icon: <Package />,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Warehouse Management",
|
||||
items: [
|
||||
@@ -211,46 +292,6 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
href: "/dashboard/warehouses",
|
||||
icon: <Container />,
|
||||
},
|
||||
{
|
||||
label: "Inventory",
|
||||
href: "/dashboard/warehouse-inventory",
|
||||
icon: <Package />,
|
||||
},
|
||||
{
|
||||
label: "Arrival Queue",
|
||||
href: "/dashboard/arrival-queue",
|
||||
icon: <PackageOpen />,
|
||||
},
|
||||
{
|
||||
label: "Loading Queue",
|
||||
href: "/dashboard/loading-queue",
|
||||
icon: <Truck />,
|
||||
},
|
||||
{
|
||||
label: "Loaded Inventory",
|
||||
href: "/dashboard/loaded-inventory",
|
||||
icon: <PackageCheck />,
|
||||
},
|
||||
{
|
||||
label: "Dispatch Queue",
|
||||
href: "/dashboard/dispatch-queue",
|
||||
icon: <Send />,
|
||||
},
|
||||
{
|
||||
label: "Djibouti Unloading",
|
||||
href: "/dashboard/export-djibouti-unloading",
|
||||
icon: <PackageOpen />,
|
||||
},
|
||||
{
|
||||
label: "Interchange Documents",
|
||||
href: "/dashboard/interchange-documents",
|
||||
icon: <FileText />,
|
||||
},
|
||||
{
|
||||
label: "Inventory Inquiry",
|
||||
href: "/dashboard/inventory-inquiry",
|
||||
icon: <Boxes />,
|
||||
},
|
||||
{
|
||||
label: "Allocation & Fees",
|
||||
href: "/dashboard/warehouse-rules",
|
||||
@@ -418,6 +459,8 @@ const App = () => {
|
||||
<Route path="warehouses" element={<WarehouseListPage />} />
|
||||
<Route path="warehouses/:id" element={<WarehouseDetailPage />} />
|
||||
<Route path="warehouse-inventory" element={<WarehouseInventoryPage />} />
|
||||
<Route path="import-warehouse" element={<ImportWarehouseFlowPage />} />
|
||||
<Route path="export-warehouse" element={<ExportWarehouseFlowPage />} />
|
||||
<Route path="arrival-queue" element={<ArrivalQueuePage />} />
|
||||
<Route path="loading-queue" element={<LoadingQueuePage />} />
|
||||
<Route path="loaded-inventory" element={<LoadedInventoryPage />} />
|
||||
|
||||
@@ -120,6 +120,26 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
||||
}
|
||||
};
|
||||
|
||||
const openHandoverDocument = async (item: WarehouseInventoryItem) => {
|
||||
setBusyId(item.id);
|
||||
const pdfWindow = window.open('', '_blank');
|
||||
try {
|
||||
const response = await warehouseService.downloadHandoverDocument(item.id);
|
||||
const filename = `handover-${item.booking?.reference ?? item.bookingId ?? item.id}.pdf`;
|
||||
const opened = openPdfBlob(response.data, filename, pdfWindow);
|
||||
toast({ title: opened ? 'Handover document opened' : 'Handover document downloaded' });
|
||||
} catch (error) {
|
||||
pdfWindow?.close();
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Handover document failed',
|
||||
description: extractErrorMessage(error),
|
||||
});
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const acceptLastMile = async (item: WarehouseInventoryItem) => {
|
||||
const reference = item.booking?.reference;
|
||||
if (!reference) {
|
||||
@@ -227,6 +247,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
||||
onInspect={setInspectItem}
|
||||
onFeePreview={setFeeItem}
|
||||
onReleaseDocument={downloadReleaseDocument}
|
||||
onHandoverDocument={openHandoverDocument}
|
||||
onLastMile={onLastMile ? acceptLastMile : undefined}
|
||||
selectedIds={selected}
|
||||
onToggleSelect={toggleSelect}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Fragment, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
Loader,
|
||||
Modal,
|
||||
NumberInput,
|
||||
ScrollArea,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
@@ -15,28 +17,58 @@ import {
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { ChevronDown, ChevronRight, ClipboardCheck, Info, PackageSearch, Train, Truck } from 'lucide-react';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ClipboardCheck,
|
||||
Eye,
|
||||
FileText,
|
||||
History,
|
||||
Info,
|
||||
PackageCheck,
|
||||
PackageOpen,
|
||||
PackageSearch,
|
||||
Send,
|
||||
Search,
|
||||
Train,
|
||||
Truck,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { api } from '@/services/api';
|
||||
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useInventoryInquiry } from '@/hooks/useWarehouses';
|
||||
import { firstMileService } from '@/services/first-mile.service';
|
||||
import { warehouseService } from '@/services/warehouse.service';
|
||||
import type {
|
||||
EligibleBooking,
|
||||
InventoryInquiryFilter,
|
||||
InventoryInquiryResult,
|
||||
ImportTrain,
|
||||
ImportTrainItem,
|
||||
ImportUnloadedItem,
|
||||
ReadyToLoadRow,
|
||||
ReceiveInventoryPayload,
|
||||
TruckEntrancePayload,
|
||||
WarehouseInventoryItem,
|
||||
} from '@/types/warehouse';
|
||||
import { BookingSelect } from './BookingSelect';
|
||||
import { DeliverInventoryModal } from './DeliverInventoryModal';
|
||||
import { FeePreviewModal } from './FeePreviewModal';
|
||||
import { InspectionReportModal } from './InspectionReportModal';
|
||||
import { InventoryDetailModal } from './InventoryDetailModal';
|
||||
import { InventoryHistoryModal } from './InventoryHistoryModal';
|
||||
import { InventoryWorkbench } from './InventoryWorkbench';
|
||||
import { extractErrorMessage, formatDate, formatNumber } from './options';
|
||||
import { InventoryInquiryDetailModal } from './InventoryInquiryDetailModal';
|
||||
import { ReleaseOrderModal } from './ReleaseOrderModal';
|
||||
import { WarehouseInquiryTable } from './WarehouseInquiryTable';
|
||||
import { extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions } from './options';
|
||||
import { openPdfBlob } from './pdf';
|
||||
import '@/components/overview/overview.css';
|
||||
|
||||
interface ReceiveInventoryModalProps {
|
||||
opened: boolean;
|
||||
@@ -608,6 +640,7 @@ function EligibleTab({
|
||||
const [statusTab, setStatusTab] = useState('ALL');
|
||||
const [truckOpen, setTruckOpen] = useState(false);
|
||||
const [pendingReceiveIds, setPendingReceiveIds] = useState<string[]>([]);
|
||||
const [receivedAt, setReceivedAt] = useState<string | null>(null);
|
||||
const [truckForm, setTruckForm] = useState<TruckEntranceFormState>(emptyTruckEntrance());
|
||||
const [lockedTruckFields, setLockedTruckFields] = useState<LockedTruckEntranceFields>({});
|
||||
const [packagingFreightType, setPackagingFreightType] = useState<PackagingFreightType>('MIXED');
|
||||
@@ -717,6 +750,7 @@ function EligibleTab({
|
||||
setSelected(new Set());
|
||||
setTruckOpen(false);
|
||||
setPendingReceiveIds([]);
|
||||
setReceivedAt(null);
|
||||
setLockedTruckFields({});
|
||||
setPackagingFreightType('MIXED');
|
||||
onChanged?.();
|
||||
@@ -749,6 +783,7 @@ function EligibleTab({
|
||||
}
|
||||
const { form, lockedFields, packagingFreightType: nextPackagingFreightType } = truckEntranceFromBookings(selectedRows);
|
||||
setPendingReceiveIds(filteredIds);
|
||||
setReceivedAt(new Date().toISOString());
|
||||
setTruckForm(form);
|
||||
setLockedTruckFields(lockedFields);
|
||||
setPackagingFreightType(nextPackagingFreightType);
|
||||
@@ -807,15 +842,18 @@ function EligibleTab({
|
||||
)}
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="default"
|
||||
color={direction === 'EXPORT' ? 'edr-green' : undefined}
|
||||
variant={direction === 'EXPORT' ? 'filled' : 'default'}
|
||||
leftSection={direction === 'EXPORT' ? <Truck size={14} /> : undefined}
|
||||
disabled={!locationReady || selectableRows.length === 0}
|
||||
loading={bulkReceive.isPending}
|
||||
onClick={() => openTruckReceive(selectableRows.map((r) => r.id))}
|
||||
onClick={() => openTruckReceive(selected.size > 0 ? [...selected] : selectableRows.map((r) => r.id))}
|
||||
>
|
||||
Receive All Eligible
|
||||
{direction === 'EXPORT' ? 'Receive to Warehouse' : 'Receive All to Warehouse'}
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="default"
|
||||
disabled={!locationReady || selected.size === 0}
|
||||
loading={bulkReceive.isPending}
|
||||
onClick={() => openTruckReceive([...selected])}
|
||||
@@ -859,7 +897,7 @@ function EligibleTab({
|
||||
<Table.Th>Origin</Table.Th>
|
||||
<Table.Th>Destination</Table.Th>
|
||||
<Table.Th>Route</Table.Th>
|
||||
<Table.Th>Container #</Table.Th>
|
||||
<Table.Th>Container / Cargo Items</Table.Th>
|
||||
<Table.Th>Cargo Type</Table.Th>
|
||||
<Table.Th>Weight</Table.Th>
|
||||
<Table.Th>Payment</Table.Th>
|
||||
@@ -899,7 +937,17 @@ function EligibleTab({
|
||||
<Table.Td>
|
||||
{r.origin || r.destination ? `${r.origin ?? '?'} → ${r.destination ?? '?'}` : '—'}
|
||||
</Table.Td>
|
||||
<Table.Td>—</Table.Td>
|
||||
<Table.Td>
|
||||
<Stack gap={2}>
|
||||
<Text size="sm">{r.containerNumber ?? r.cargoDescription ?? r.cargo ?? '—'}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{[
|
||||
r.containerQuantity != null ? `${r.containerQuantity} unit(s)` : null,
|
||||
r.containerPackagingType,
|
||||
].filter(Boolean).join(' / ') || 'Item details from booking'}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Table.Td>
|
||||
<Table.Td>{r.cargo ?? '—'}</Table.Td>
|
||||
<Table.Td>{formatNumber(Number(r.weight))}</Table.Td>
|
||||
<Table.Td>
|
||||
@@ -952,7 +1000,7 @@ function EligibleTab({
|
||||
loading={bulkReceive.isPending}
|
||||
onClick={() => openTruckReceive([r.id])}
|
||||
>
|
||||
{canReceive ? 'Receive' : 'Await First Mile'}
|
||||
{canReceive ? 'Receive to Warehouse' : 'Await First Mile'}
|
||||
</Button>
|
||||
)}
|
||||
</Table.Td>
|
||||
@@ -967,7 +1015,7 @@ function EligibleTab({
|
||||
<Modal
|
||||
opened={truckOpen}
|
||||
onClose={() => setTruckOpen(false)}
|
||||
title="Export Truck Arrival / First Mile Receive Form"
|
||||
title="Receive to Warehouse"
|
||||
centered
|
||||
size="lg"
|
||||
>
|
||||
@@ -979,6 +1027,52 @@ function EligibleTab({
|
||||
: 'Register the customer or third-party truck and driver before export receiving and GRN.'}
|
||||
</Text>
|
||||
</Alert>
|
||||
<Table.ScrollContainer minWidth={900}>
|
||||
<Table withTableBorder highlightOnHover verticalSpacing="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Booking</Table.Th>
|
||||
<Table.Th>Customer</Table.Th>
|
||||
<Table.Th>TIN / Phone</Table.Th>
|
||||
<Table.Th>Container / Cargo</Table.Th>
|
||||
<Table.Th>Qty / Package</Table.Th>
|
||||
<Table.Th>Weight</Table.Th>
|
||||
<Table.Th>Received at</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{pendingReceiveRows.map((booking) => (
|
||||
<Table.Tr key={booking.id}>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={600}>{booking.reference}</Text>
|
||||
<Text size="xs" c="dimmed">{booking.id.slice(0, 8)}...</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{booking.customer ?? '-'}</Table.Td>
|
||||
<Table.Td>
|
||||
<Stack gap={0}>
|
||||
<Text size="xs">{booking.customerTin ?? '-'}</Text>
|
||||
<Text size="xs" c="dimmed">{booking.customerPhone ?? '-'}</Text>
|
||||
</Stack>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Stack gap={0}>
|
||||
<Text size="xs">{booking.containerNumber ?? booking.cargoDescription ?? booking.cargo ?? '-'}</Text>
|
||||
<Text size="xs" c="dimmed">{booking.freightType ?? '-'}</Text>
|
||||
</Stack>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{[
|
||||
booking.containerQuantity != null ? `${booking.containerQuantity} unit(s)` : null,
|
||||
booking.containerPackagingType,
|
||||
].filter(Boolean).join(' / ') || '-'}
|
||||
</Table.Td>
|
||||
<Table.Td>{formatNumber(Number(booking.weight))}</Table.Td>
|
||||
<Table.Td>{formatDate(receivedAt)}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
<TruckEntranceFields
|
||||
value={truckForm}
|
||||
onChange={setTruckForm}
|
||||
@@ -1084,7 +1178,7 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
|
||||
<Table.Th>Booking ID</Table.Th>
|
||||
<Table.Th>Customer ID</Table.Th>
|
||||
<Table.Th>Customer Name</Table.Th>
|
||||
<Table.Th>Container #</Table.Th>
|
||||
<Table.Th>Container / Cargo Items</Table.Th>
|
||||
<Table.Th>Cargo Type</Table.Th>
|
||||
<Table.Th>Weight</Table.Th>
|
||||
<Table.Th>Route</Table.Th>
|
||||
@@ -1466,11 +1560,11 @@ function LoadedExportTab({
|
||||
}
|
||||
|
||||
/** Assigned bookings/items for an arrived import train (read-only detail view). */
|
||||
function ImportTrainDetailTable({ scheduleId }: { scheduleId: string }) {
|
||||
function ImportTrainDetailTable({ train }: { train: ImportTrain }) {
|
||||
const { data: items = [], isLoading } = useQuery(
|
||||
api.warehouses.importTrainItems.queryOptions({
|
||||
input: { scheduleId },
|
||||
enabled: Boolean(scheduleId),
|
||||
input: { scheduleId: train.scheduleId },
|
||||
enabled: Boolean(train.scheduleId),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1493,6 +1587,7 @@ function ImportTrainDetailTable({ scheduleId }: { scheduleId: string }) {
|
||||
<Table withTableBorder verticalSpacing="xs" fz="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Wagon</Table.Th>
|
||||
<Table.Th>Booking ID</Table.Th>
|
||||
<Table.Th>Booking Ref</Table.Th>
|
||||
<Table.Th>Customer ID</Table.Th>
|
||||
@@ -1509,7 +1604,12 @@ function ImportTrainDetailTable({ scheduleId }: { scheduleId: string }) {
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{items.map((it: ImportTrainItem) => (
|
||||
<Table.Tr key={it.bookingId}>
|
||||
<Table.Tr key={`${it.bookingId}-${it.sequenceNo ?? 'wagon'}`}>
|
||||
<Table.Td>
|
||||
<Text size="xs" fw={600}>
|
||||
{it.sequenceNo ? `#${it.sequenceNo}` : '-'} {it.wagonNumber ?? ''}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" c="dimmed">{it.bookingId.slice(0, 8)}…</Text>
|
||||
</Table.Td>
|
||||
@@ -1656,8 +1756,8 @@ function ImportArriveQueueTab({
|
||||
<Table.Td ta="center">{t.totalCargoes}</Table.Td>
|
||||
<Table.Td>
|
||||
<Stack gap={2}>
|
||||
<Badge color={fullyUnloaded ? 'green' : 'indigo'} variant="light" size="sm">
|
||||
{fullyUnloaded ? 'UNLOADED' : t.status}
|
||||
<Badge color="indigo" variant="light" size="sm">
|
||||
{t.status}
|
||||
</Badge>
|
||||
<Text size="xs" c="dimmed">
|
||||
{Math.max(unloadedBookings, 0)}/{t.totalBookings} unloaded
|
||||
@@ -1690,7 +1790,7 @@ function ImportArriveQueueTab({
|
||||
{isOpen && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={11} bg="var(--mantine-color-gray-0)">
|
||||
<ImportTrainDetailTable scheduleId={t.scheduleId} />
|
||||
<ImportTrainDetailTable train={t} />
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
@@ -1712,14 +1812,24 @@ function ImportArriveQueueTab({
|
||||
*/
|
||||
function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
const { data: rows = [], isLoading } = useQuery(
|
||||
api.warehouses.importUnloadedQueue.queryOptions({ enabled }),
|
||||
);
|
||||
const inspectMutation = useMutation(
|
||||
api.warehouses.bulkMarkInspected.mutationOptions(),
|
||||
);
|
||||
const storeMutation = useMutation(api.warehouses.store.mutationOptions());
|
||||
const readyMutation = useMutation(api.warehouses.markReadyForPickup.mutationOptions());
|
||||
const dispatchMutation = useMutation(api.warehouses.dispatch.mutationOptions());
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [inspectId, setInspectId] = useState<string | null>(null);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [viewItem, setViewItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [historyItem, setHistoryItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [feeItem, setFeeItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [releaseItem, setReleaseItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [deliverItem, setDeliverItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
|
||||
const allSelected = rows.length > 0 && selected.size === rows.length;
|
||||
const someSelected = selected.size > 0 && !allSelected;
|
||||
@@ -1744,11 +1854,62 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
description: r.skippedCount ? `${r.skippedCount} skipped` : undefined,
|
||||
});
|
||||
setSelected(new Set());
|
||||
void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
|
||||
} catch (error) {
|
||||
toast({ variant: 'destructive', title: 'Mark inspected failed', description: extractErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
const toInventoryItem = (row: ImportUnloadedItem): WarehouseInventoryItem =>
|
||||
({
|
||||
id: row.id,
|
||||
bookingId: row.bookingId,
|
||||
quantity: 1,
|
||||
weight: Number(row.weight) || 0,
|
||||
status: row.currentStatus,
|
||||
arrivedAt: row.arrivalTime,
|
||||
unloadedAt: row.arrivalTime,
|
||||
inspectionStatus: row.inspectionStatus,
|
||||
releaseDate: row.releaseDate,
|
||||
releaseOrderReference: row.releaseOrderReference,
|
||||
deliveredAt: row.deliveredAt,
|
||||
booking: row.bookingId
|
||||
? {
|
||||
id: row.bookingId,
|
||||
reference: row.bookingReference ?? row.bookingId,
|
||||
tradeDirection: 'IMPORT',
|
||||
lastMileDeliveryAddress: row.lastMileRequested ? row.pickupOption : null,
|
||||
}
|
||||
: null,
|
||||
}) as unknown as WarehouseInventoryItem;
|
||||
|
||||
const runRowAction = async (row: ImportUnloadedItem, label: string, fn: () => Promise<unknown>) => {
|
||||
setBusyId(row.id);
|
||||
try {
|
||||
await fn();
|
||||
toast({ title: label });
|
||||
void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
|
||||
} catch (error) {
|
||||
toast({ variant: 'destructive', title: 'Action failed', description: extractErrorMessage(error) });
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const openHandoverDocument = async (row: ImportUnloadedItem) => {
|
||||
setBusyId(row.id);
|
||||
const pdfWindow = window.open('', '_blank');
|
||||
try {
|
||||
const response = await warehouseService.downloadHandoverDocument(row.id);
|
||||
openPdfBlob(response.data, `handover-${row.bookingReference ?? row.id}.pdf`, pdfWindow);
|
||||
} catch (error) {
|
||||
pdfWindow?.close();
|
||||
toast({ variant: 'destructive', title: 'Handover document failed', description: extractErrorMessage(error) });
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="sm" mt="sm">
|
||||
<Group justify="space-between">
|
||||
@@ -1852,9 +2013,92 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
<Badge color="indigo" variant="light" size="sm">{r.currentStatus}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Button size="compact-xs" variant="light" color="orange" onClick={() => setInspectId(r.id)}>
|
||||
Inspect / Report
|
||||
</Button>
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
<Tooltip label="View details" withArrow>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={() => setViewItem(toInventoryItem(r))}>
|
||||
<Eye size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
{r.currentStatus === 'UNLOADED' && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="blue"
|
||||
loading={busyId === r.id}
|
||||
onClick={() => runRowAction(r, 'Inventory stored', () => storeMutation.mutateAsync(r.id))}
|
||||
>
|
||||
Store
|
||||
</Button>
|
||||
)}
|
||||
{['UNLOADED', 'STORED'].includes(r.currentStatus) && r.inspectionStatus === 'PASSED' && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
loading={busyId === r.id}
|
||||
onClick={() => runRowAction(r, 'Ready for pickup', () => readyMutation.mutateAsync(r.id))}
|
||||
>
|
||||
Ready Pickup
|
||||
</Button>
|
||||
)}
|
||||
{r.currentStatus === 'READY_FOR_PICKUP' && !r.releaseDate && (
|
||||
<>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="yellow"
|
||||
onClick={() => setReleaseItem(toInventoryItem(r))}
|
||||
>
|
||||
Truck Arrival
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{r.currentStatus === 'READY_FOR_PICKUP' && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="green"
|
||||
loading={busyId === r.id}
|
||||
onClick={() => runRowAction(r, 'Inventory dispatched', () => dispatchMutation.mutateAsync(r.id))}
|
||||
>
|
||||
Dispatch
|
||||
</Button>
|
||||
)}
|
||||
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="green"
|
||||
onClick={() => setDeliverItem(toInventoryItem(r))}
|
||||
>
|
||||
Deliver
|
||||
</Button>
|
||||
)}
|
||||
{r.inspectionStatus === 'PASSED' && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="teal"
|
||||
leftSection={<FileText size={14} />}
|
||||
onClick={() => openHandoverDocument(r)}
|
||||
>
|
||||
Handover
|
||||
</Button>
|
||||
)}
|
||||
<Button size="compact-xs" variant="light" color="orange" onClick={() => setInspectId(r.id)}>
|
||||
Inspect / Report
|
||||
</Button>
|
||||
<Tooltip label="Storage / fee preview" withArrow>
|
||||
<ActionIcon variant="subtle" color="teal" onClick={() => setFeeItem(toInventoryItem(r))}>
|
||||
<PackageCheck size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="History" withArrow>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={() => setHistoryItem(toInventoryItem(r))}>
|
||||
<History size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
@@ -1868,6 +2112,15 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
onClose={() => setInspectId(null)}
|
||||
inventoryId={inspectId}
|
||||
/>
|
||||
<InventoryDetailModal opened={Boolean(viewItem)} onClose={() => setViewItem(null)} item={viewItem} />
|
||||
<InventoryHistoryModal opened={Boolean(historyItem)} onClose={() => setHistoryItem(null)} item={historyItem} />
|
||||
<FeePreviewModal
|
||||
opened={Boolean(feeItem)}
|
||||
onClose={() => setFeeItem(null)}
|
||||
inventoryId={feeItem?.id ?? null}
|
||||
/>
|
||||
<ReleaseOrderModal opened={Boolean(releaseItem)} onClose={() => setReleaseItem(null)} item={releaseItem} />
|
||||
<DeliverInventoryModal opened={Boolean(deliverItem)} onClose={() => setDeliverItem(null)} item={deliverItem} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1905,20 +2158,340 @@ function ImportDispatchQueueTab({ enabled }: { enabled: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** New bulk Receive: Import / Export tabs with eligible PAID bookings. */
|
||||
function BulkReceiveModal({ opened, onClose, onReceived }: ReceiveInventoryModalProps) {
|
||||
const [location, setLocation] = useState<Location>({ warehouseId: '', yardId: '', zoneId: '' });
|
||||
const [tab, setTab] = useState<'IMPORT' | 'EXPORT'>('IMPORT');
|
||||
type WarehouseFlowDirection = 'IMPORT' | 'EXPORT' | 'BOTH';
|
||||
type ImportWarehouseTab = 'arrive-queue' | 'unloaded-queue' | 'dispatch-queue' | 'locate-booking';
|
||||
type ExportWarehouseTab = 'receive-queue' | 'received' | 'ready-to-load' | 'loaded' | 'dispatch-queue' | 'locate-booking';
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) setLocation({ warehouseId: '', yardId: '', zoneId: '' });
|
||||
}, [opened]);
|
||||
interface WarehouseQueueTab<TValue extends string> {
|
||||
value: TValue;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
interface WarehouseFlowWorkbenchProps {
|
||||
direction?: WarehouseFlowDirection;
|
||||
enabled?: boolean;
|
||||
onChanged?: () => void;
|
||||
}
|
||||
|
||||
function WarehouseQueueTabs<TValue extends string>({
|
||||
value,
|
||||
onChange,
|
||||
tabs,
|
||||
}: {
|
||||
value: TValue;
|
||||
onChange: (value: TValue) => void;
|
||||
tabs: WarehouseQueueTab<TValue>[];
|
||||
}) {
|
||||
return (
|
||||
<Tabs
|
||||
value={value}
|
||||
onChange={(next) => onChange((next as TValue) ?? value)}
|
||||
variant="pills"
|
||||
color="edr-green"
|
||||
keepMounted={false}
|
||||
classNames={{ list: 'ov-tablist', tab: 'ov-tab' }}
|
||||
>
|
||||
<ScrollArea type="auto" scrollbarSize={6} offsetScrollbars="x">
|
||||
<Tabs.List style={{ flexWrap: 'nowrap', width: 'max-content' }}>
|
||||
{tabs.map((tab) => {
|
||||
const active = value === tab.value;
|
||||
return (
|
||||
<Tabs.Tab
|
||||
key={tab.value}
|
||||
value={tab.value}
|
||||
leftSection={tab.icon}
|
||||
rightSection={
|
||||
tab.count !== undefined ? (
|
||||
<Badge
|
||||
size="sm"
|
||||
radius="sm"
|
||||
variant={active ? 'white' : 'light'}
|
||||
color={active ? 'edr-green' : 'gray'}
|
||||
styles={
|
||||
active
|
||||
? { root: { background: 'rgba(255,255,255,0.9)', color: '#15805f' } }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{tab.count}
|
||||
</Badge>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{tab.label}
|
||||
</Tabs.Tab>
|
||||
);
|
||||
})}
|
||||
</Tabs.List>
|
||||
</ScrollArea>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
function LocateBookingTab({ enabled }: { enabled: boolean }) {
|
||||
const [draft, setDraft] = useState<InventoryInquiryFilter>({});
|
||||
const [applied, setApplied] = useState<InventoryInquiryFilter>({});
|
||||
const [viewResult, setViewResult] = useState<InventoryInquiryResult | null>(null);
|
||||
const hasSearch = Boolean(
|
||||
applied.bookingReference ||
|
||||
applied.containerNumber ||
|
||||
applied.goodsName ||
|
||||
applied.cargoType ||
|
||||
applied.status,
|
||||
);
|
||||
const { data: results = [], isFetching } = useInventoryInquiry(applied, enabled && hasSearch);
|
||||
|
||||
const normalizeDraft = (): InventoryInquiryFilter => ({
|
||||
bookingReference: draft.bookingReference?.trim() || undefined,
|
||||
containerNumber: draft.containerNumber?.trim() || undefined,
|
||||
goodsName: draft.goodsName?.trim() || undefined,
|
||||
cargoType: draft.cargoType?.trim() || undefined,
|
||||
status: draft.status,
|
||||
});
|
||||
|
||||
const runSearch = () => setApplied(normalizeDraft());
|
||||
const reset = () => {
|
||||
setDraft({});
|
||||
setApplied({});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Receive at warehouse" centered size="80rem">
|
||||
<Stack gap="md">
|
||||
<LocationSelects value={location} onChange={setLocation} />
|
||||
<Stack gap="md" mt="sm">
|
||||
<Group gap="sm" wrap="wrap">
|
||||
<TextInput
|
||||
label="Booking reference"
|
||||
placeholder="e.g. BK-2026-000051"
|
||||
value={draft.bookingReference ?? ''}
|
||||
onChange={(e) => setDraft((filter) => ({ ...filter, bookingReference: e.currentTarget.value || undefined }))}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') runSearch();
|
||||
}}
|
||||
w={230}
|
||||
/>
|
||||
<TextInput
|
||||
label="Container number"
|
||||
placeholder="e.g. MSKU1234567"
|
||||
value={draft.containerNumber ?? ''}
|
||||
onChange={(e) => setDraft((filter) => ({ ...filter, containerNumber: e.currentTarget.value || undefined }))}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') runSearch();
|
||||
}}
|
||||
w={220}
|
||||
/>
|
||||
<TextInput
|
||||
label="Goods / cargo"
|
||||
placeholder="Coffee, steel, etc."
|
||||
value={draft.goodsName ?? draft.cargoType ?? ''}
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.value || undefined;
|
||||
setDraft((filter) => ({ ...filter, goodsName: value, cargoType: value }));
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') runSearch();
|
||||
}}
|
||||
w={200}
|
||||
/>
|
||||
<Select
|
||||
label="Status"
|
||||
placeholder="Any"
|
||||
clearable
|
||||
data={inventoryStatusOptions}
|
||||
value={draft.status ?? null}
|
||||
onChange={(value) => setDraft((filter) => ({ ...filter, status: value as InventoryInquiryFilter['status'] }))}
|
||||
w={190}
|
||||
/>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<Button leftSection={<Search size={16} />} onClick={runSearch}>
|
||||
Locate Booking
|
||||
</Button>
|
||||
<Button variant="default" onClick={reset}>
|
||||
Reset
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{isFetching ? (
|
||||
<Group justify="center" py="lg">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
) : !hasSearch ? (
|
||||
<Text c="dimmed" ta="center" py="lg" size="sm">
|
||||
Search by booking reference, container number, cargo or status to locate inventory.
|
||||
</Text>
|
||||
) : results.length === 0 ? (
|
||||
<Text c="dimmed" ta="center" py="lg" size="sm">
|
||||
No inventory found for the current filters.
|
||||
</Text>
|
||||
) : (
|
||||
<WarehouseInquiryTable results={results} onView={setViewResult} />
|
||||
)}
|
||||
|
||||
<InventoryInquiryDetailModal
|
||||
opened={Boolean(viewResult)}
|
||||
onClose={() => setViewResult(null)}
|
||||
result={viewResult}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function ImportWarehouseTabs({ enabled, onChanged }: { enabled: boolean; onChanged?: () => void }) {
|
||||
const [activeTab, setActiveTab] = useState<ImportWarehouseTab>('arrive-queue');
|
||||
const { data: arriveRows = [] } = useQuery(api.warehouses.importArriveQueue.queryOptions({ enabled }));
|
||||
const { data: unloadedRows = [] } = useQuery(api.warehouses.importUnloadedQueue.queryOptions({ enabled }));
|
||||
const { data: dispatchRows = [] } = useQuery(api.warehouses.importPickupReadyQueue.queryOptions({ enabled }));
|
||||
const tabs: WarehouseQueueTab<ImportWarehouseTab>[] = [
|
||||
{
|
||||
value: 'arrive-queue',
|
||||
label: 'Arrival Queue',
|
||||
icon: <PackageOpen size={17} strokeWidth={1.85} />,
|
||||
count: arriveRows.length,
|
||||
},
|
||||
{
|
||||
value: 'unloaded-queue',
|
||||
label: 'Unloaded Queue',
|
||||
icon: <ClipboardCheck size={17} strokeWidth={1.85} />,
|
||||
count: unloadedRows.length,
|
||||
},
|
||||
{
|
||||
value: 'dispatch-queue',
|
||||
label: 'Dispatch Queue',
|
||||
icon: <Send size={17} strokeWidth={1.85} />,
|
||||
count: dispatchRows.length,
|
||||
},
|
||||
{
|
||||
value: 'locate-booking',
|
||||
label: 'Locate Booking',
|
||||
icon: <Search size={17} strokeWidth={1.85} />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<WarehouseQueueTabs value={activeTab} onChange={setActiveTab} tabs={tabs} />
|
||||
|
||||
{activeTab === 'arrive-queue' && (
|
||||
<ImportArriveQueueTab enabled={enabled} onChanged={onChanged} />
|
||||
)}
|
||||
{activeTab === 'unloaded-queue' && (
|
||||
<ImportUnloadedQueueTab enabled={enabled} />
|
||||
)}
|
||||
{activeTab === 'dispatch-queue' && (
|
||||
<ImportDispatchQueueTab enabled={enabled} />
|
||||
)}
|
||||
{activeTab === 'locate-booking' && (
|
||||
<LocateBookingTab enabled={enabled} />
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function ExportWarehouseTabs({
|
||||
enabled,
|
||||
location,
|
||||
onChanged,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
location: Location;
|
||||
onChanged?: () => void;
|
||||
}) {
|
||||
const [activeTab, setActiveTab] = useState<ExportWarehouseTab>('receive-queue');
|
||||
const { data: eligibleRows = [] } = useQuery(api.warehouses.eligibleBookings.queryOptions({ enabled }));
|
||||
const { data: receivedRows = [] } = useQuery(api.warehouses.receivedExport.queryOptions({ enabled }));
|
||||
const { data: readyRows = [] } = useQuery(api.warehouses.readyToLoadExport.queryOptions({ enabled }));
|
||||
const { data: loadedRows = [] } = useQuery(api.warehouses.loadedExport.queryOptions({ enabled }));
|
||||
const exportEligibleCount = useMemo(
|
||||
() => eligibleRows.filter((row) => row.direction === 'EXPORT').length,
|
||||
[eligibleRows],
|
||||
);
|
||||
const tabs: WarehouseQueueTab<ExportWarehouseTab>[] = [
|
||||
{
|
||||
value: 'receive-queue',
|
||||
label: 'Receive to Warehouse',
|
||||
icon: <Truck size={17} strokeWidth={1.85} />,
|
||||
count: exportEligibleCount,
|
||||
},
|
||||
{
|
||||
value: 'received',
|
||||
label: 'Received',
|
||||
icon: <ClipboardCheck size={17} strokeWidth={1.85} />,
|
||||
count: receivedRows.length,
|
||||
},
|
||||
{
|
||||
value: 'ready-to-load',
|
||||
label: 'Ready To Load',
|
||||
icon: <Train size={17} strokeWidth={1.85} />,
|
||||
count: readyRows.length,
|
||||
},
|
||||
{
|
||||
value: 'loaded',
|
||||
label: 'Loaded',
|
||||
icon: <PackageCheck size={17} strokeWidth={1.85} />,
|
||||
count: loadedRows.length,
|
||||
},
|
||||
{
|
||||
value: 'dispatch-queue',
|
||||
label: 'Dispatch Queue',
|
||||
icon: <Send size={17} strokeWidth={1.85} />,
|
||||
count: loadedRows.length,
|
||||
},
|
||||
{
|
||||
value: 'locate-booking',
|
||||
label: 'Locate Booking',
|
||||
icon: <Search size={17} strokeWidth={1.85} />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<WarehouseQueueTabs value={activeTab} onChange={setActiveTab} tabs={tabs} />
|
||||
|
||||
{activeTab === 'receive-queue' && (
|
||||
<EligibleTab direction="EXPORT" location={location} enabled={enabled} onChanged={onChanged} />
|
||||
)}
|
||||
{activeTab === 'received' && (
|
||||
<ExportReceivedTab enabled={enabled} onChanged={onChanged} />
|
||||
)}
|
||||
{activeTab === 'ready-to-load' && (
|
||||
<ReadyToLoadTab enabled={enabled} onChanged={onChanged} />
|
||||
)}
|
||||
{activeTab === 'loaded' && (
|
||||
<LoadedExportTab enabled={enabled} dispatchable={false} onChanged={onChanged} />
|
||||
)}
|
||||
{activeTab === 'dispatch-queue' && (
|
||||
<LoadedExportTab enabled={enabled} dispatchable onChanged={onChanged} />
|
||||
)}
|
||||
{activeTab === 'locate-booking' && (
|
||||
<LocateBookingTab enabled={enabled} />
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export function WarehouseFlowWorkbench({
|
||||
direction = 'BOTH',
|
||||
enabled = true,
|
||||
onChanged,
|
||||
}: WarehouseFlowWorkbenchProps) {
|
||||
const [location, setLocation] = useState<Location>({ warehouseId: '', yardId: '', zoneId: '' });
|
||||
const [tab, setTab] = useState<Exclude<WarehouseFlowDirection, 'BOTH'>>(
|
||||
direction === 'EXPORT' ? 'EXPORT' : 'IMPORT',
|
||||
);
|
||||
const activeDirection = direction === 'BOTH' ? tab : direction;
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled) setLocation({ warehouseId: '', yardId: '', zoneId: '' });
|
||||
}, [enabled, direction]);
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{activeDirection === 'EXPORT' && (
|
||||
<LocationSelects value={location} onChange={setLocation} />
|
||||
)}
|
||||
|
||||
{direction === 'BOTH' ? (
|
||||
<Tabs value={tab} onChange={(v) => setTab((v as 'IMPORT' | 'EXPORT') ?? 'IMPORT')}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="IMPORT" leftSection={<PackageSearch size={16} />}>
|
||||
@@ -1930,54 +2503,27 @@ function BulkReceiveModal({ opened, onClose, onReceived }: ReceiveInventoryModal
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="IMPORT">
|
||||
<Tabs defaultValue="arrive-queue" mt="xs">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="arrive-queue" leftSection={<Train size={14} />}>
|
||||
Arrive Queue
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="unloaded-queue">Unloaded Queue</Tabs.Tab>
|
||||
<Tabs.Tab value="dispatch-queue">Dispatch Queue</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="arrive-queue">
|
||||
<ImportArriveQueueTab enabled={opened} onChanged={onReceived} />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="unloaded-queue">
|
||||
<ImportUnloadedQueueTab enabled={opened} />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="dispatch-queue">
|
||||
<ImportDispatchQueueTab enabled={opened} />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
<ImportWarehouseTabs enabled={enabled} onChanged={onChanged} />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="EXPORT">
|
||||
<Tabs defaultValue="receive-queue" mt="xs">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="receive-queue">Receive Queue</Tabs.Tab>
|
||||
<Tabs.Tab value="received">Received</Tabs.Tab>
|
||||
<Tabs.Tab value="ready-to-load">Ready To Load</Tabs.Tab>
|
||||
<Tabs.Tab value="loaded">Loaded</Tabs.Tab>
|
||||
<Tabs.Tab value="dispatch-queue">Dispatch Queue</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="receive-queue">
|
||||
<EligibleTab direction="EXPORT" location={location} enabled={opened} onChanged={onReceived} />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="received">
|
||||
<ExportReceivedTab enabled={opened} onChanged={onReceived} />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="ready-to-load">
|
||||
<ReadyToLoadTab enabled={opened} onChanged={onReceived} />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="loaded">
|
||||
<LoadedExportTab enabled={opened} dispatchable={false} onChanged={onReceived} />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="dispatch-queue">
|
||||
<LoadedExportTab enabled={opened} dispatchable onChanged={onReceived} />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
<ExportWarehouseTabs enabled={enabled} location={location} onChanged={onChanged} />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
) : activeDirection === 'IMPORT' ? (
|
||||
<ImportWarehouseTabs enabled={enabled} onChanged={onChanged} />
|
||||
) : (
|
||||
<ExportWarehouseTabs enabled={enabled} location={location} onChanged={onChanged} />
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/** New bulk Receive: Import / Export tabs with eligible PAID bookings. */
|
||||
function BulkReceiveModal({ opened, onClose, onReceived }: ReceiveInventoryModalProps) {
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Receive at warehouse" centered size="80rem">
|
||||
<Stack gap="md">
|
||||
<WarehouseFlowWorkbench enabled={opened} direction="BOTH" onChanged={onReceived} />
|
||||
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
|
||||
@@ -148,12 +148,13 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Exit inspection and release paper" centered size="lg">
|
||||
<Modal opened={opened} onClose={onClose} title="Customer truck arrival and exit weighing" centered size="lg">
|
||||
<Stack gap="md">
|
||||
<Alert icon={<Info size={16} />} color="orange" variant="light">
|
||||
<Text size="sm">
|
||||
Save the exit inspection before generating the exit paper. Gate clearance is blocked when
|
||||
recorded net weight does not equal gross weight minus tare weight.
|
||||
Register the customer truck and driver at arrival, record tare weight, then record gross
|
||||
weight at exit after loading. Gate clearance is blocked when recorded net weight does not
|
||||
equal gross weight minus tare weight.
|
||||
</Text>
|
||||
</Alert>
|
||||
<TextInput
|
||||
@@ -224,7 +225,7 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="orange" onClick={handleSubmit} loading={releaseMutation.isPending || downloading}>
|
||||
Exit Inspection & View Exit Paper
|
||||
Save Truck Arrival & View Exit Paper
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -19,6 +19,7 @@ interface WarehouseInventoryTableProps {
|
||||
onInspect?: (item: WarehouseInventoryItem) => void;
|
||||
onFeePreview?: (item: WarehouseInventoryItem) => void;
|
||||
onReleaseDocument?: (item: WarehouseInventoryItem) => void;
|
||||
onHandoverDocument?: (item: WarehouseInventoryItem) => void;
|
||||
onLastMile?: (item: WarehouseInventoryItem) => void;
|
||||
selectedIds?: Set<string>;
|
||||
onToggleSelect?: (id: string) => void;
|
||||
@@ -55,6 +56,7 @@ export function WarehouseInventoryTable({
|
||||
onInspect,
|
||||
onFeePreview,
|
||||
onReleaseDocument,
|
||||
onHandoverDocument,
|
||||
onLastMile,
|
||||
selectedIds,
|
||||
onToggleSelect,
|
||||
@@ -105,6 +107,10 @@ export function WarehouseInventoryTable({
|
||||
const kind = itemKind(item);
|
||||
const busy = busyId === item.id;
|
||||
const nextAction = getNextInventoryAction(item);
|
||||
const canGenerateHandover =
|
||||
item.inspectionStatus === 'PASSED' &&
|
||||
Boolean(item.bookingId) &&
|
||||
(!item.booking?.tradeDirection || item.booking.tradeDirection === 'IMPORT');
|
||||
|
||||
return (
|
||||
<Table.Tr key={item.id}>
|
||||
@@ -164,7 +170,7 @@ export function WarehouseInventoryTable({
|
||||
loading={busy}
|
||||
onClick={() => onAdvance(item, nextAction)}
|
||||
>
|
||||
{nextAction === 'release' ? 'Exit Inspection' : humanizeEnum(nextAction.replace(/-/g, '_'))}
|
||||
{nextAction === 'release' ? 'Truck Arrival' : humanizeEnum(nextAction.replace(/-/g, '_'))}
|
||||
</Button>
|
||||
)}
|
||||
{item.status === 'READY_FOR_PICKUP' && (
|
||||
@@ -217,6 +223,13 @@ export function WarehouseInventoryTable({
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onHandoverDocument && canGenerateHandover && (
|
||||
<Tooltip label="Generate customer handover document" withArrow>
|
||||
<ActionIcon variant="subtle" color="teal" onClick={() => onHandoverDocument(item)}>
|
||||
<FileText size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onLastMile && item.booking?.lastMileDeliveryAddress && (
|
||||
<Tooltip label="Last mile delivery" withArrow>
|
||||
<ActionIcon variant="subtle" color="blue" onClick={() => onLastMile(item)}>
|
||||
|
||||
@@ -9,7 +9,7 @@ export { WarehouseInquiryTable } from './WarehouseInquiryTable';
|
||||
export { CreateWarehouseModal } from './CreateWarehouseModal';
|
||||
export { CreateYardModal } from './CreateYardModal';
|
||||
export { CreateZoneModal } from './CreateZoneModal';
|
||||
export { ReceiveInventoryModal } from './ReceiveInventoryModal';
|
||||
export { ReceiveInventoryModal, WarehouseFlowWorkbench } from './ReceiveInventoryModal';
|
||||
export { WarehouseInfoCard } from './WarehouseInfoCard';
|
||||
export { MoveInventoryModal } from './MoveInventoryModal';
|
||||
export { ReserveInventoryModal } from './ReserveInventoryModal';
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { WarehouseFeeInvoice, WarehouseInventoryItem } from '@/types/warehouse';
|
||||
import type {
|
||||
ImportUnloadedItem,
|
||||
WarehouseFeeInvoice,
|
||||
WarehouseInventoryItem,
|
||||
} from '@/types/warehouse';
|
||||
import type { BookingDetail } from '@/types/booking';
|
||||
|
||||
type PdfLine = {
|
||||
@@ -19,6 +23,11 @@ export interface WarehouseExitPaperContext {
|
||||
releasedAt?: Date;
|
||||
}
|
||||
|
||||
export interface WarehouseHandoverPdfContext {
|
||||
item: ImportUnloadedItem | WarehouseInventoryItem;
|
||||
handedOverAt?: Date;
|
||||
}
|
||||
|
||||
const escapePdfText = (value: string) =>
|
||||
value.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
|
||||
|
||||
@@ -214,6 +223,67 @@ const containerSummary = (booking?: BookingDetail | null, inventory?: WarehouseI
|
||||
.join(', ');
|
||||
};
|
||||
|
||||
const shortId = (value: unknown) => {
|
||||
const text = firstText(value);
|
||||
return text === '-' ? '-' : text.slice(0, 8);
|
||||
};
|
||||
|
||||
const compactWeight = (value: unknown) => {
|
||||
const num = Number(value ?? 0);
|
||||
if (!Number.isFinite(num) || num <= 0) return '-';
|
||||
return num.toLocaleString(undefined, { maximumFractionDigits: 3 });
|
||||
};
|
||||
|
||||
const handoverValue = (item: ImportUnloadedItem | WarehouseInventoryItem, key: string) =>
|
||||
(item as unknown as Record<string, unknown>)[key];
|
||||
|
||||
const handoverBookingValue = (item: ImportUnloadedItem | WarehouseInventoryItem, key: string) =>
|
||||
((item as WarehouseInventoryItem).booking as unknown as Record<string, unknown> | null | undefined)?.[key];
|
||||
|
||||
export function buildWarehouseHandoverPdf({ item, handedOverAt = new Date() }: WarehouseHandoverPdfContext) {
|
||||
const bookingReference = firstText(
|
||||
handoverValue(item, 'bookingReference'),
|
||||
handoverBookingValue(item, 'reference'),
|
||||
item.bookingId,
|
||||
);
|
||||
const customerName = firstText(
|
||||
handoverValue(item, 'customerName'),
|
||||
handoverBookingValue(item, 'customerName'),
|
||||
handoverBookingValue(item, 'companyName'),
|
||||
);
|
||||
const containerNumber = firstText(handoverValue(item, 'containerNumber'));
|
||||
const cargoType = firstText(handoverValue(item, 'cargoType'), handoverValue(item, 'cargoDescription'));
|
||||
const currentStatus = firstText(handoverValue(item, 'currentStatus'), (item as WarehouseInventoryItem).status);
|
||||
const handoverReference = `HND-${bookingReference.replace(/[^a-zA-Z0-9]+/g, '-')}`;
|
||||
const goodsSummary = firstText(containerNumber, cargoType, (item as WarehouseInventoryItem).goodsId);
|
||||
|
||||
return buildSimplePdf([
|
||||
{ text: 'Ethio-Djibouti Railway S.C.', size: 12, bold: true, yGap: 0, align: 'center' },
|
||||
{ text: 'Import Goods Handover Document', size: 23, bold: true, yGap: 28, align: 'center' },
|
||||
{ text: '[ EDR TO CUSTOMER ]', size: 14, bold: true, color: 'green', yGap: 24, align: 'center' },
|
||||
{ text: `Document No: ${handoverReference}`, bold: true, yGap: 30, align: 'center' },
|
||||
{ text: `Handover Date & Time: ${fmtDate(handedOverAt)}`, align: 'center' },
|
||||
{ text: `From: Ethio-Djibouti Railway S.C.`, align: 'center' },
|
||||
{ text: `To Customer: ${customerName}`, align: 'center' },
|
||||
{ text: `Customer ID: ${shortId(handoverValue(item, 'customerId'))}`, align: 'center' },
|
||||
{ text: `Booking Reference: ${bookingReference}`, align: 'center' },
|
||||
{ text: `Booking ID: ${shortId(item.bookingId)}`, align: 'center' },
|
||||
{ text: `Train Schedule: ${firstText(handoverValue(item, 'trainSchedule'))}`, align: 'center' },
|
||||
{ text: `Arrival Time: ${fmtDate(handoverValue(item, 'arrivalTime') ?? (item as WarehouseInventoryItem).arrivedAt)}`, align: 'center' },
|
||||
{ text: `Release Order: ${firstText((item as WarehouseInventoryItem).releaseOrderReference)}`, align: 'center' },
|
||||
{ text: `Release Date: ${fmtDate((item as WarehouseInventoryItem).releaseDate)}`, align: 'center' },
|
||||
{ text: 'GOODS LIST', size: 13, bold: true, yGap: 30, align: 'center' },
|
||||
{ text: `1. ${goodsSummary}`, bold: true, align: 'center' },
|
||||
{ text: `Container: ${containerNumber} Cargo: ${cargoType}`, align: 'center' },
|
||||
{ text: `Weight: ${compactWeight(item.weight)} Status: ${currentStatus}`, align: 'center' },
|
||||
{ text: `Inspection: ${firstText(item.inspectionStatus)} Pickup Option: ${firstText(handoverValue(item, 'pickupOption'), 'TERMINAL_PICKUP')}`, align: 'center' },
|
||||
{ text: `Warehouse: ${firstText((item as WarehouseInventoryItem).warehouse?.name, (item as WarehouseInventoryItem).warehouse?.code)} Yard: ${firstText((item as WarehouseInventoryItem).yard?.name, (item as WarehouseInventoryItem).yard?.code)}`, align: 'center' },
|
||||
{ text: 'This document confirms EDR handed over the listed import goods to the customer after warehouse inspection.', yGap: 30, align: 'center' },
|
||||
], [
|
||||
...buildWarehouseOfficerSealBand(),
|
||||
]);
|
||||
}
|
||||
|
||||
export function buildWarehouseExitPaperPdf(input: WarehouseFeeInvoice | WarehouseExitPaperContext, releasedItemArg?: WarehouseInventoryItem) {
|
||||
const context: WarehouseExitPaperContext =
|
||||
'invoice' in input ? input : { invoice: input, releasedItem: releasedItemArg };
|
||||
|
||||
@@ -331,6 +331,7 @@ export const URL_CONSTANTS = {
|
||||
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`,
|
||||
HANDOVER_DOCUMENT: (id: string) => `/warehouse-inventory/${id}/handover-document`,
|
||||
DELIVER: (id: string) => `/warehouse-inventory/${id}/deliver`,
|
||||
// Receive (Import/Export bulk)
|
||||
ELIGIBLE_BOOKINGS: (direction?: string) =>
|
||||
|
||||
@@ -313,9 +313,15 @@ export default function TrainScheduleV2DetailPage() {
|
||||
const canDispatch = schedule.status === "SCHEDULED";
|
||||
const finalizeStep = hasContainerStep ? 3 : 2;
|
||||
const canModifyBookings = canEditBookings && !["DISPATCHED", "ARRIVED"].includes(schedule.status);
|
||||
const canPrintMarshalling = schedule.direction === "IMPORT" || schedule.direction === "EXPORT";
|
||||
const canPrintMarshalling =
|
||||
(schedule.direction === "IMPORT" || schedule.direction === "EXPORT") &&
|
||||
["DISPATCHED", "ARRIVED"].includes(schedule.status);
|
||||
|
||||
const openMarshallingDocument = async () => {
|
||||
const openMarshallingDocument = async (options?: {
|
||||
title?: string;
|
||||
successDescription?: string;
|
||||
errorTitle?: string;
|
||||
}) => {
|
||||
const pdfWindow = window.open("", "_blank");
|
||||
try {
|
||||
const blob = await downloadMarshalling.mutateAsync({
|
||||
@@ -326,15 +332,17 @@ export default function TrainScheduleV2DetailPage() {
|
||||
const filename = `${prefix}-${schedule.trainNumber ?? scheduleId}.pdf`;
|
||||
const opened = openPdfBlob(blob, filename, pdfWindow);
|
||||
toast({
|
||||
title: "Marshalling document ready",
|
||||
description: opened
|
||||
? "The PDF opened in a browser tab for printing or saving."
|
||||
: "The browser blocked the preview tab, so the PDF was downloaded.",
|
||||
title: options?.title ?? "Marshalling document ready",
|
||||
description:
|
||||
options?.successDescription ??
|
||||
(opened
|
||||
? "The PDF opened in a browser tab for printing or saving."
|
||||
: "The browser blocked the preview tab, so the PDF was downloaded."),
|
||||
});
|
||||
} catch (error) {
|
||||
pdfWindow?.close();
|
||||
toast({
|
||||
title: "Could not open marshalling document",
|
||||
title: options?.errorTitle ?? "Could not open marshalling document",
|
||||
description: parseError(error, "Make sure the train has wagon allocations, then try again."),
|
||||
variant: "destructive",
|
||||
});
|
||||
@@ -725,7 +733,11 @@ export default function TrainScheduleV2DetailPage() {
|
||||
onClick={async () => {
|
||||
try {
|
||||
await dispatch.mutateAsync(scheduleId);
|
||||
toast({ title: "Train dispatched" });
|
||||
await openMarshallingDocument({
|
||||
title: "Train dispatched",
|
||||
successDescription: "Marshalling document generated for the dispatched train.",
|
||||
errorTitle: "Train dispatched, but document could not open",
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Dispatch failed",
|
||||
|
||||
@@ -231,8 +231,8 @@ export default function ArrivalQueuePage() {
|
||||
<Table.Td ta="center">{train.totalCargoes}</Table.Td>
|
||||
<Table.Td>
|
||||
<Stack gap={2}>
|
||||
<Badge variant="light" color={fullyUnloaded ? 'green' : 'teal'} size="sm">
|
||||
{fullyUnloaded ? 'UNLOADED' : train.status}
|
||||
<Badge variant="light" color="teal" size="sm">
|
||||
{train.status}
|
||||
</Badge>
|
||||
<Text size="xs" c="dimmed">
|
||||
{Math.max(unloadedBookings, 0)}/{train.totalBookings} unloaded
|
||||
|
||||
@@ -66,14 +66,14 @@ const statusLabel = (status?: string | null) =>
|
||||
: (status ?? 'PENDING').replace(/_/g, ' ');
|
||||
|
||||
function ExportTrainDetailRows({
|
||||
scheduleId,
|
||||
train,
|
||||
onOpenHistory,
|
||||
}: {
|
||||
scheduleId: string;
|
||||
train: ExportTrain;
|
||||
onOpenHistory: (inventoryId: string) => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const { data: items = [], isLoading } = useExportDjiboutiTrainItems(scheduleId);
|
||||
const { data: items = [], isLoading } = useExportDjiboutiTrainItems(train.scheduleId);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -92,10 +92,11 @@ function ExportTrainDetailRows({
|
||||
}
|
||||
|
||||
return (
|
||||
<Table.ScrollContainer minWidth={1320}>
|
||||
<Table.ScrollContainer minWidth={1420}>
|
||||
<Table highlightOnHover verticalSpacing="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Wagon</Table.Th>
|
||||
<Table.Th>Booking ID</Table.Th>
|
||||
<Table.Th>Booking Reference</Table.Th>
|
||||
<Table.Th>Customer ID</Table.Th>
|
||||
@@ -115,6 +116,11 @@ function ExportTrainDetailRows({
|
||||
<Table.Tbody>
|
||||
{items.map((item: ExportTrainItem) => (
|
||||
<Table.Tr key={`${item.bookingId}-${item.itemType}-${item.itemId ?? item.inventoryId ?? 'item'}`}>
|
||||
<Table.Td>
|
||||
<Text size="xs" fw={600}>
|
||||
{item.sequenceNo ? `#${item.sequenceNo}` : '-'} {item.wagonNumber ?? ''}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{item.bookingId.slice(0, 8)}</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={600}>
|
||||
@@ -384,7 +390,7 @@ export default function ExportDjiboutiUnloadingQueuePage() {
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={12} bg="var(--mantine-color-gray-0)">
|
||||
<ExportTrainDetailRows
|
||||
scheduleId={train.scheduleId}
|
||||
train={train}
|
||||
onOpenHistory={setHistoryInventoryId}
|
||||
/>
|
||||
</Table.Td>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Button, Card } from '@mantine/core';
|
||||
import { PackageSearch } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
import { WarehouseFlowWorkbench } from '@/components/warehouses';
|
||||
|
||||
export default function ExportWarehouseFlowPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Export Operations"
|
||||
subtitle="Manage export receive, terminal inventory, loading readiness, loaded items, and dispatch flow."
|
||||
action={
|
||||
<Button variant="light" leftSection={<PackageSearch size={16} />} onClick={() => navigate('/dashboard/import-warehouse')}>
|
||||
Import Operations
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<WarehouseFlowWorkbench direction="EXPORT" />
|
||||
</Card>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Button, Card } from '@mantine/core';
|
||||
import { Truck } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
import { WarehouseFlowWorkbench } from '@/components/warehouses';
|
||||
|
||||
export default function ImportWarehouseFlowPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Import Operations"
|
||||
subtitle="Manage import gate flow, marshalling handoff, arrived trains, unloaded bookings, and dispatch-ready inventory."
|
||||
action={
|
||||
<Button variant="light" leftSection={<Truck size={16} />} onClick={() => navigate('/dashboard/export-warehouse')}>
|
||||
Export Operations
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<WarehouseFlowWorkbench direction="IMPORT" />
|
||||
</Card>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { Button, Card, Group, Select, Stack, TextInput } from '@mantine/core';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import { PackagePlus, Search } from 'lucide-react';
|
||||
import { PackageOpen, Search, Truck } from 'lucide-react';
|
||||
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
import {
|
||||
InventoryWorkbench,
|
||||
ReceiveInventoryModal,
|
||||
inventoryStatusOptions,
|
||||
} from '@/components/warehouses';
|
||||
import {
|
||||
@@ -19,13 +18,13 @@ import {
|
||||
import type { InventoryFilter, InventoryStatus } from '@/types/warehouse';
|
||||
|
||||
export default function WarehouseInventoryPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const initialStatus = (searchParams.get('status') as InventoryStatus | null) ?? undefined;
|
||||
const [filter, setFilter] = useState<InventoryFilter>(
|
||||
initialStatus ? { status: initialStatus } : {},
|
||||
);
|
||||
const [search, setSearch] = useState('');
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
const [debouncedSearch] = useDebouncedValue(search, 300);
|
||||
const queryFilter = useMemo<InventoryFilter>(
|
||||
@@ -57,9 +56,18 @@ export default function WarehouseInventoryPage() {
|
||||
title="Warehouse Inventory"
|
||||
subtitle="Track received items through the storage, reservation, loading and dispatch lifecycle."
|
||||
action={
|
||||
<Button leftSection={<PackagePlus size={16} />} onClick={() => setModalOpen(true)}>
|
||||
Receive Inventory
|
||||
</Button>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<PackageOpen size={16} />}
|
||||
onClick={() => navigate('/dashboard/import-warehouse')}
|
||||
>
|
||||
Import
|
||||
</Button>
|
||||
<Button leftSection={<Truck size={16} />} onClick={() => navigate('/dashboard/export-warehouse')}>
|
||||
Export
|
||||
</Button>
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -126,8 +134,6 @@ export default function WarehouseInventoryPage() {
|
||||
<InventoryWorkbench items={inventoryQuery.data ?? []} isLoading={inventoryQuery.isLoading} />
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<ReceiveInventoryModal opened={modalOpen} onClose={() => setModalOpen(false)} />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -137,6 +137,10 @@ export const warehouseService = {
|
||||
apiClient.get<Blob>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RELEASE_DOCUMENT(id), {
|
||||
responseType: 'blob',
|
||||
}),
|
||||
downloadHandoverDocument: (id: string) =>
|
||||
apiClient.get<Blob>(URL_CONSTANTS.WAREHOUSE_INVENTORY.HANDOVER_DOCUMENT(id), {
|
||||
responseType: 'blob',
|
||||
}),
|
||||
deliver: (id: string, payload: DeliverInventoryPayload) =>
|
||||
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.DELIVER(id), payload),
|
||||
|
||||
|
||||
@@ -517,6 +517,9 @@ export interface ExportTrainItem {
|
||||
bookingReference: string | null;
|
||||
customerId: string | null;
|
||||
customerName: string | null;
|
||||
wagonNumber: string | null;
|
||||
sequenceNo: number | null;
|
||||
allocatedWeightTons: number | null;
|
||||
itemType: 'CONTAINER' | 'CARGO';
|
||||
itemId: string | null;
|
||||
inventoryId: string | null;
|
||||
@@ -566,6 +569,9 @@ export interface ImportUnloadedItem {
|
||||
pickupOption: string;
|
||||
lastMileRequested: boolean;
|
||||
currentStatus: string;
|
||||
releaseDate: string | null;
|
||||
releaseOrderReference: string | null;
|
||||
deliveredAt: string | null;
|
||||
}
|
||||
|
||||
export interface ImportTrainItem {
|
||||
@@ -573,6 +579,9 @@ export interface ImportTrainItem {
|
||||
bookingReference: string | null;
|
||||
customerId: string | null;
|
||||
customerName: string | null;
|
||||
wagonNumber: string | null;
|
||||
sequenceNo: number | null;
|
||||
allocatedWeightTons: number | null;
|
||||
containerNumber: string | null;
|
||||
cargoType: string | null;
|
||||
weight: number | null;
|
||||
|
||||
@@ -3,7 +3,6 @@ import { fileURLToPath } from "node:url";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { loadEnv, type Plugin } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
@@ -11,7 +10,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const require = createRequire(import.meta.url);
|
||||
const streamBrowserifyPath = require.resolve("stream-browserify");
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
export default defineConfig(() => {
|
||||
return {
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Box, Group, Text } from "@mantine/core";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { CreditCard, Download } from "lucide-react";
|
||||
import { CheckCircle2, CreditCard, Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import { paymentsService, type PaymentMethod } from "@/services/payments.service";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
@@ -49,6 +51,19 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
||||
window.location.href = redirectUrl;
|
||||
},
|
||||
});
|
||||
const approveDeliveryMutation = useMutation({
|
||||
mutationFn: () => bookingsService.approveDelivery(booking.id),
|
||||
onSuccess: (data) => {
|
||||
toast.success(`Delivery approved as ${data.signerDisplayName}`);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Could not approve delivery. Please try again.",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const pricing = booking.pricingBreakdown;
|
||||
// A general contract is paid once it's FULLY_EXECUTED (signed) — it never
|
||||
@@ -62,6 +77,10 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
||||
: status === "SELECTED_FOR_BATCH");
|
||||
const showCountdown = canPay && !!booking.paymentDeadline;
|
||||
const isExpired = status === "EXPIRED";
|
||||
const canApproveDelivery =
|
||||
booking.tradeDirection === "IMPORT" &&
|
||||
!isNegative(status) &&
|
||||
!["DRAFT", "DRAFT_DOCUMENTS_PENDING"].includes(status);
|
||||
const isPendingConsolidation = status === "PENDING_CONSOLIDATION";
|
||||
const isClearance = [
|
||||
"AWAITING_DOCUMENTS",
|
||||
@@ -81,14 +100,30 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
||||
<PageHeader
|
||||
booking={booking}
|
||||
actions={
|
||||
canPay &&
|
||||
!showCountdown && (
|
||||
<HeaderButton
|
||||
green
|
||||
icon={<CreditCard size={16} />}
|
||||
label="Pay now"
|
||||
onClick={() => setPayModalOpen(true)}
|
||||
/>
|
||||
(canPay || canApproveDelivery) && (
|
||||
<Group gap={8} wrap="nowrap">
|
||||
{canApproveDelivery && (
|
||||
<HeaderButton
|
||||
green
|
||||
icon={<CheckCircle2 size={16} />}
|
||||
label={
|
||||
approveDeliveryMutation.isPending
|
||||
? "Approving..."
|
||||
: "Approve Delivery"
|
||||
}
|
||||
disabled={approveDeliveryMutation.isPending}
|
||||
onClick={() => approveDeliveryMutation.mutate()}
|
||||
/>
|
||||
)}
|
||||
{canPay && !showCountdown && (
|
||||
<HeaderButton
|
||||
green
|
||||
icon={<CreditCard size={16} />}
|
||||
label="Pay now"
|
||||
onClick={() => setPayModalOpen(true)}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
)
|
||||
}
|
||||
menuActions={{
|
||||
|
||||
@@ -72,6 +72,13 @@ export interface SignContractPayload {
|
||||
consentText?: string;
|
||||
}
|
||||
|
||||
export interface ApproveDeliveryResponse {
|
||||
bookingId: string;
|
||||
inventoryId: string;
|
||||
approvedAt: string;
|
||||
signerDisplayName: string;
|
||||
}
|
||||
|
||||
export interface BookingListFilter {
|
||||
status?: string;
|
||||
/** Comma-separated statuses (overrides `status` when set). */
|
||||
@@ -242,6 +249,13 @@ export const bookingsService = {
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
approveDelivery: async (id: string): Promise<ApproveDeliveryResponse> => {
|
||||
const { data } = await client.post(
|
||||
`/api/warehouse-inventory/bookings/${id}/approve-delivery`,
|
||||
);
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
getBookableSchedules: async (
|
||||
query: Freight.BookableSchedulesQuery = {},
|
||||
): Promise<Freight.BookableScheduleItem[]> => {
|
||||
|
||||
Reference in New Issue
Block a user