mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Goods recieved notes and Booking delivery
This commit is contained in:
@@ -18,6 +18,7 @@
|
|||||||
"seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts",
|
"seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts",
|
||||||
"seed:freight-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-freight-demo.ts",
|
"seed:freight-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-freight-demo.ts",
|
||||||
"seed:warehouse-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-warehouse-demo.ts",
|
"seed:warehouse-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-warehouse-demo.ts",
|
||||||
|
"seed:warehouse-export-receive-ready": "ts-node -r tsconfig-paths/register src/scripts/seed-warehouse-export-receive-ready.ts",
|
||||||
"seed:export-djibouti-interchange-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-export-djibouti-interchange-demo.ts",
|
"seed:export-djibouti-interchange-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-export-djibouti-interchange-demo.ts",
|
||||||
"seed:import-djibouti-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-import-djibouti-demo.ts",
|
"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:approved-first-lastmile-demo-bookings": "ts-node -r tsconfig-paths/register src/scripts/seed-approved-first-lastmile-demo-bookings.ts",
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddGrnNumberToWarehouseInventory1828000000000 implements MigrationInterface {
|
||||||
|
name = 'AddGrnNumberToWarehouseInventory1828000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.warehouse_inventory
|
||||||
|
ADD COLUMN IF NOT EXISTS grn_number VARCHAR(100) NULL
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.warehouse_inventory
|
||||||
|
SET grn_number = substring(notes FROM 'GRN Number: ([^\\n\\r]+)')
|
||||||
|
WHERE grn_number IS NULL
|
||||||
|
AND notes IS NOT NULL
|
||||||
|
AND notes ~ 'GRN Number: '
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_warehouse_inventory_grn_number
|
||||||
|
ON freight.warehouse_inventory(grn_number)
|
||||||
|
WHERE grn_number IS NOT NULL
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_warehouse_inventory_grn_number`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.warehouse_inventory
|
||||||
|
DROP COLUMN IF EXISTS grn_number
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -110,6 +110,9 @@ export class WarehouseInventory extends BaseEntity {
|
|||||||
@Column({ name: 'volume', type: 'numeric', precision: 12, scale: 3, nullable: true })
|
@Column({ name: 'volume', type: 'numeric', precision: 12, scale: 3, nullable: true })
|
||||||
volume?: number | null;
|
volume?: number | null;
|
||||||
|
|
||||||
|
@Column({ name: 'grn_number', type: 'varchar', length: 100, nullable: true })
|
||||||
|
grnNumber?: string | null;
|
||||||
|
|
||||||
@Column({ name: 'status', type: 'varchar', length: 32, default: 'RECEIVED' })
|
@Column({ name: 'status', type: 'varchar', length: 32, default: 'RECEIVED' })
|
||||||
status!: WarehouseInventoryStatus;
|
status!: WarehouseInventoryStatus;
|
||||||
|
|
||||||
|
|||||||
@@ -273,6 +273,16 @@ export class WarehouseInventoryController {
|
|||||||
return res.send(buffer);
|
return res.send(buffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':id/grn-document')
|
||||||
|
@ApiOperation({ summary: 'View goods received note PDF' })
|
||||||
|
async grnDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
|
||||||
|
const { filename, buffer } = await this.inventoryService.grnDocument(id);
|
||||||
|
res.setHeader('Content-Type', 'application/pdf');
|
||||||
|
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
|
||||||
|
res.setHeader('Content-Length', buffer.length);
|
||||||
|
return res.send(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id/handover-document')
|
@Get(':id/handover-document')
|
||||||
@ApiOperation({ summary: 'View import goods handover document PDF' })
|
@ApiOperation({ summary: 'View import goods handover document PDF' })
|
||||||
async handoverDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
|
async handoverDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ const isLoadableWagonStatus = (status: string | null | undefined) =>
|
|||||||
LOADABLE_WAGON_STATUSES.includes(normalizeWagonStatus(status));
|
LOADABLE_WAGON_STATUSES.includes(normalizeWagonStatus(status));
|
||||||
|
|
||||||
const CUSTOMER_DELIVERY_APPROVAL_PREFIX = 'CUSTOMER_DELIVERY_APPROVAL:';
|
const CUSTOMER_DELIVERY_APPROVAL_PREFIX = 'CUSTOMER_DELIVERY_APPROVAL:';
|
||||||
|
const HANDOVER_DOCUMENT_MARKER = '[Handover Document]';
|
||||||
|
|
||||||
export interface InventoryInquiryResult {
|
export interface InventoryInquiryResult {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -249,6 +250,7 @@ export interface ReadyToLoadRow {
|
|||||||
containerNumber: string | null;
|
containerNumber: string | null;
|
||||||
cargoType: string | null;
|
cargoType: string | null;
|
||||||
weight: number | null;
|
weight: number | null;
|
||||||
|
grnNumber: string | null;
|
||||||
origin: string | null;
|
origin: string | null;
|
||||||
destination: string | null;
|
destination: string | null;
|
||||||
inspectionStatus: string | null;
|
inspectionStatus: string | null;
|
||||||
@@ -295,6 +297,7 @@ export interface ImportUnloadedRow {
|
|||||||
containerNumber: string | null;
|
containerNumber: string | null;
|
||||||
cargoType: string | null;
|
cargoType: string | null;
|
||||||
weight: number | null;
|
weight: number | null;
|
||||||
|
grnNumber: string | null;
|
||||||
trainSchedule: string | null;
|
trainSchedule: string | null;
|
||||||
inspectionStatus: string | null;
|
inspectionStatus: string | null;
|
||||||
pickupOption: string;
|
pickupOption: string;
|
||||||
@@ -302,6 +305,8 @@ export interface ImportUnloadedRow {
|
|||||||
currentStatus: string;
|
currentStatus: string;
|
||||||
releaseDate: string | null;
|
releaseDate: string | null;
|
||||||
releaseOrderReference: string | null;
|
releaseOrderReference: string | null;
|
||||||
|
handoverDocumentReference: string | null;
|
||||||
|
handoverDocumentDate: string | null;
|
||||||
deliveredAt: string | null;
|
deliveredAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -415,7 +420,10 @@ export class WarehouseInventoryService {
|
|||||||
|
|
||||||
const search = filter.search?.trim();
|
const search = filter.search?.trim();
|
||||||
const where: FindManyOptions<WarehouseInventory>['where'] = search
|
const where: FindManyOptions<WarehouseInventory>['where'] = search
|
||||||
? { ...base, notes: ILike(`%${search}%`) }
|
? [
|
||||||
|
{ ...base, notes: ILike(`%${search}%`) },
|
||||||
|
{ ...base, grnNumber: ILike(`%${search}%`) },
|
||||||
|
]
|
||||||
: base;
|
: base;
|
||||||
|
|
||||||
const items = await this.inventoryRepository.findAll({
|
const items = await this.inventoryRepository.findAll({
|
||||||
@@ -766,6 +774,7 @@ export class WarehouseInventoryService {
|
|||||||
const [booking] = await manager.query(
|
const [booking] = await manager.query(
|
||||||
`SELECT b.reference AS "reference",
|
`SELECT b.reference AS "reference",
|
||||||
b.payment_status AS "paymentStatus",
|
b.payment_status AS "paymentStatus",
|
||||||
|
b.freight_type AS "freightType",
|
||||||
b.cargo_total_weight_vgm AS "weight",
|
b.cargo_total_weight_vgm AS "weight",
|
||||||
company.name AS "customer",
|
company.name AS "customer",
|
||||||
company.tin AS "customerTin",
|
company.tin AS "customerTin",
|
||||||
@@ -847,6 +856,12 @@ export class WarehouseInventoryService {
|
|||||||
const existing = await manager.getRepository(WarehouseInventory).findOne({ where: { bookingId } });
|
const existing = await manager.getRepository(WarehouseInventory).findOne({ where: { bookingId } });
|
||||||
if (existing) { skip('Already received'); continue; }
|
if (existing) { skip('Already received'); continue; }
|
||||||
|
|
||||||
|
const containerQuantity = Number(booking.containerQuantity ?? 0);
|
||||||
|
if (booking.freightType === 'CONTAINER' && containerQuantity <= 0) {
|
||||||
|
skip('Container booking has no container quantity');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const grnNumber = this.generateGrnNumber(dto.direction, bookingId, now);
|
const grnNumber = this.generateGrnNumber(dto.direction, bookingId, now);
|
||||||
const truckEntrance = dto.truckEntrance
|
const truckEntrance = dto.truckEntrance
|
||||||
@@ -867,8 +882,9 @@ export class WarehouseInventoryService {
|
|||||||
yardId: dto.yardId,
|
yardId: dto.yardId,
|
||||||
zoneId: dto.zoneId,
|
zoneId: dto.zoneId,
|
||||||
bookingId,
|
bookingId,
|
||||||
quantity: Number(booking.containerQuantity) || 1,
|
quantity: booking.freightType === 'CONTAINER' ? containerQuantity : 1,
|
||||||
weight: Number(booking.weight) || 0,
|
weight: Number(booking.weight) || 0,
|
||||||
|
grnNumber,
|
||||||
status: 'RECEIVED',
|
status: 'RECEIVED',
|
||||||
arrivedAt: now,
|
arrivedAt: now,
|
||||||
notes: receiveNote,
|
notes: receiveNote,
|
||||||
@@ -962,6 +978,7 @@ export class WarehouseInventoryService {
|
|||||||
ct.container_number AS "containerNumber",
|
ct.container_number AS "containerNumber",
|
||||||
COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType",
|
COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType",
|
||||||
inv.weight AS "weight",
|
inv.weight AS "weight",
|
||||||
|
COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber",
|
||||||
oy.code AS "origin",
|
oy.code AS "origin",
|
||||||
dy.code AS "destination",
|
dy.code AS "destination",
|
||||||
oy.country AS "originCountry",
|
oy.country AS "originCountry",
|
||||||
@@ -1021,6 +1038,7 @@ export class WarehouseInventoryService {
|
|||||||
ORDER BY c.container_number LIMIT 1) AS "containerNumber",
|
ORDER BY c.container_number LIMIT 1) AS "containerNumber",
|
||||||
COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType",
|
COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType",
|
||||||
inv.weight AS "weight",
|
inv.weight AS "weight",
|
||||||
|
COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber",
|
||||||
ts.train_number AS "trainSchedule",
|
ts.train_number AS "trainSchedule",
|
||||||
inv.inspection_status AS "inspectionStatus",
|
inv.inspection_status AS "inspectionStatus",
|
||||||
CASE WHEN b.last_mile_delivery_address IS NOT NULL
|
CASE WHEN b.last_mile_delivery_address IS NOT NULL
|
||||||
@@ -1029,6 +1047,8 @@ export class WarehouseInventoryService {
|
|||||||
inv.status AS "currentStatus",
|
inv.status AS "currentStatus",
|
||||||
inv.release_date AS "releaseDate",
|
inv.release_date AS "releaseDate",
|
||||||
inv.release_order_reference AS "releaseOrderReference",
|
inv.release_order_reference AS "releaseOrderReference",
|
||||||
|
substring(inv.notes FROM 'Handover Reference: ([^\\n\\r]+)') AS "handoverDocumentReference",
|
||||||
|
substring(inv.notes FROM 'Generated At: ([^\\n\\r]+)') AS "handoverDocumentDate",
|
||||||
inv.delivered_at AS "deliveredAt",
|
inv.delivered_at AS "deliveredAt",
|
||||||
oy.country AS "originCountry",
|
oy.country AS "originCountry",
|
||||||
dy.country AS "destinationCountry"
|
dy.country AS "destinationCountry"
|
||||||
@@ -1669,6 +1689,7 @@ export class WarehouseInventoryService {
|
|||||||
quantity,
|
quantity,
|
||||||
weight,
|
weight,
|
||||||
volume: dto.volume ?? null,
|
volume: dto.volume ?? null,
|
||||||
|
grnNumber,
|
||||||
status: 'RECEIVED',
|
status: 'RECEIVED',
|
||||||
arrivedAt: now,
|
arrivedAt: now,
|
||||||
notes: receiveNote,
|
notes: receiveNote,
|
||||||
@@ -1933,24 +1954,31 @@ export class WarehouseInventoryService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const releaseDate = dto.releaseDate ? new Date(dto.releaseDate) : new Date();
|
const isTruckLeaving = dto.grossWeight !== undefined && Boolean(dto.gateOutTime);
|
||||||
const reference = dto.reference?.trim() || null;
|
const releaseDate = isTruckLeaving
|
||||||
|
? dto.releaseDate ? new Date(dto.releaseDate) : new Date()
|
||||||
|
: item.releaseDate ?? null;
|
||||||
|
const reference = dto.reference?.trim() || (await this.generateReleaseReference(item));
|
||||||
const exitInspectionNote = this.buildExitInspectionNote(dto);
|
const exitInspectionNote = this.buildExitInspectionNote(dto);
|
||||||
|
|
||||||
await this.dataSource.transaction(async (manager) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
await manager.getRepository(WarehouseInventory).update(id, {
|
await manager.getRepository(WarehouseInventory).update(id, {
|
||||||
releaseDate,
|
releaseDate,
|
||||||
releaseOrderReference: reference,
|
releaseOrderReference: reference,
|
||||||
notes: [item.notes?.trim(), exitInspectionNote].filter(Boolean).join('\n\n'),
|
notes: this.replaceExitInspectionNote(item.notes, exitInspectionNote),
|
||||||
});
|
});
|
||||||
await this.activityLog.record(
|
await this.activityLog.record(
|
||||||
{
|
{
|
||||||
activityType: 'INVENTORY_RELEASED',
|
activityType: 'INVENTORY_RELEASED',
|
||||||
inventoryId: id,
|
inventoryId: id,
|
||||||
warehouseId: item.warehouseId,
|
warehouseId: item.warehouseId,
|
||||||
description: reference
|
description: isTruckLeaving
|
||||||
? `Release order ${reference} sent to customer`
|
? reference
|
||||||
: 'Release order sent to customer',
|
? `Exit paper ${reference} generated`
|
||||||
|
: 'Exit paper generated'
|
||||||
|
: reference
|
||||||
|
? `Truck arrival ${reference} registered`
|
||||||
|
: 'Truck arrival registered',
|
||||||
performedBy: dto.performedBy,
|
performedBy: dto.performedBy,
|
||||||
},
|
},
|
||||||
manager,
|
manager,
|
||||||
@@ -2039,6 +2067,106 @@ export class WarehouseInventoryService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Hand import goods to the customer + capture proof of delivery (READY_FOR_PICKUP → DELIVERED). */
|
/** Hand import goods to the customer + capture proof of delivery (READY_FOR_PICKUP → DELIVERED). */
|
||||||
|
async grnDocument(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||||
|
const [row] = await this.dataSource.query(
|
||||||
|
`SELECT inv.id,
|
||||||
|
COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber",
|
||||||
|
COALESCE(inv.arrived_at, inv.created_at) AS "receivedAt",
|
||||||
|
inv.quantity,
|
||||||
|
inv.weight,
|
||||||
|
inv.volume,
|
||||||
|
inv.status,
|
||||||
|
inv.notes,
|
||||||
|
b.id AS "bookingId",
|
||||||
|
b.reference AS "bookingReference",
|
||||||
|
b.status AS "bookingStatus",
|
||||||
|
b.freight_type AS "freightType",
|
||||||
|
b.trade_direction AS "tradeDirection",
|
||||||
|
b.cargo_total_weight_vgm AS "bookingDeclaredWeight",
|
||||||
|
company.name AS "customerName",
|
||||||
|
company.tin AS "customerTin",
|
||||||
|
service_type.service_name AS "serviceType",
|
||||||
|
origin_yard.label AS "originYardLabel",
|
||||||
|
origin_yard.code AS "originYardCode",
|
||||||
|
destination_yard.label AS "destinationYardLabel",
|
||||||
|
destination_yard.code AS "destinationYardCode",
|
||||||
|
COALESCE(container.container_number, booking_container.container_number) AS "containerNumber",
|
||||||
|
booking_container."containerSummary" AS "bookingContainerSummary",
|
||||||
|
COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription",
|
||||||
|
wh.name AS "warehouseName",
|
||||||
|
wh.code AS "warehouseCode",
|
||||||
|
yard.name AS "yardName",
|
||||||
|
yard.code AS "yardCode",
|
||||||
|
zone.name AS "zoneName",
|
||||||
|
zone.code AS "zoneCode"
|
||||||
|
FROM freight.warehouse_inventory inv
|
||||||
|
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
|
||||||
|
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||||
|
LEFT JOIN freight.service_types service_type ON service_type.id = b.service_type_id
|
||||||
|
LEFT JOIN freight.yards origin_yard ON origin_yard.id = b.origin_yard_id
|
||||||
|
LEFT JOIN freight.yards destination_yard ON destination_yard.id = b.destination_yard_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 LATERAL (
|
||||||
|
SELECT MIN(bc.container_number) AS container_number,
|
||||||
|
STRING_AGG(
|
||||||
|
CONCAT_WS(' ', bc.quantity::text, COALESCE(ct.label, ct.code, 'container')),
|
||||||
|
', '
|
||||||
|
ORDER BY COALESCE(ct.label, ct.code, bc.container_type_id::text)
|
||||||
|
) AS "containerSummary"
|
||||||
|
FROM freight.booking_container bc
|
||||||
|
LEFT JOIN freight.container_types ct ON ct.id = bc.container_type_id
|
||||||
|
WHERE bc.booking_id = b.id
|
||||||
|
AND bc.deleted_at IS NULL
|
||||||
|
) booking_container ON true
|
||||||
|
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)
|
||||||
|
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.grnNumber) {
|
||||||
|
throw new BadRequestException('GRN number is missing for this inventory item');
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = this.buildGrnDocumentHtml({
|
||||||
|
grnNumber: row.grnNumber,
|
||||||
|
receivedAt: row.receivedAt ? new Date(row.receivedAt) : new Date(),
|
||||||
|
bookingReference: row.bookingReference ?? row.bookingId ?? 'N/A',
|
||||||
|
bookingStatus: row.bookingStatus ?? null,
|
||||||
|
customerName: row.customerName ?? null,
|
||||||
|
customerTin: row.customerTin ?? null,
|
||||||
|
serviceType: row.serviceType ?? null,
|
||||||
|
freightType: row.freightType ?? null,
|
||||||
|
tradeDirection: row.tradeDirection ?? null,
|
||||||
|
route: [row.originYardLabel ?? row.originYardCode, row.destinationYardLabel ?? row.destinationYardCode]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' to ') || null,
|
||||||
|
containerNumber: row.containerNumber ?? null,
|
||||||
|
bookingContainerSummary: row.bookingContainerSummary ?? null,
|
||||||
|
cargoDescription: row.cargoDescription ?? null,
|
||||||
|
quantity: Number(row.quantity ?? 0),
|
||||||
|
weight: Number(row.weight ?? 0),
|
||||||
|
volume: row.volume == null ? null : Number(row.volume),
|
||||||
|
bookingDeclaredWeight: Number(row.bookingDeclaredWeight ?? 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,
|
||||||
|
receiveSummary: this.extractReceiveSummary(row.notes),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
filename: `grn-${String(row.grnNumber).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
|
||||||
|
buffer: await this.releaseDocuments.htmlToPdfBuffer(html),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async approveDeliveryForBooking(
|
async approveDeliveryForBooking(
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
userId?: string,
|
userId?: string,
|
||||||
@@ -2121,8 +2249,17 @@ export class WarehouseInventoryService {
|
|||||||
b.status AS "bookingStatus",
|
b.status AS "bookingStatus",
|
||||||
b.freight_type AS "freightType",
|
b.freight_type AS "freightType",
|
||||||
b.trade_direction AS "tradeDirection",
|
b.trade_direction AS "tradeDirection",
|
||||||
|
b.scheduled_date AS "scheduledDate",
|
||||||
|
b.cargo_total_weight_vgm AS "bookingDeclaredWeight",
|
||||||
|
b.last_mile_delivery_address AS "lastMileDeliveryAddress",
|
||||||
company.name AS "customerName",
|
company.name AS "customerName",
|
||||||
|
service_type.service_name AS "serviceType",
|
||||||
|
origin_yard.label AS "originYardLabel",
|
||||||
|
origin_yard.code AS "originYardCode",
|
||||||
|
destination_yard.label AS "destinationYardLabel",
|
||||||
|
destination_yard.code AS "destinationYardCode",
|
||||||
COALESCE(container.container_number, booking_container.container_number) AS "containerNumber",
|
COALESCE(container.container_number, booking_container.container_number) AS "containerNumber",
|
||||||
|
booking_container."containerSummary" AS "bookingContainerSummary",
|
||||||
COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription",
|
COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription",
|
||||||
wh.name AS "warehouseName",
|
wh.name AS "warehouseName",
|
||||||
wh.code AS "warehouseCode",
|
wh.code AS "warehouseCode",
|
||||||
@@ -2134,14 +2271,25 @@ export class WarehouseInventoryService {
|
|||||||
FROM freight.warehouse_inventory inv
|
FROM freight.warehouse_inventory inv
|
||||||
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
|
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.companies company ON company.id = b.company_id
|
||||||
|
LEFT JOIN freight.service_types service_type ON service_type.id = b.service_type_id
|
||||||
|
LEFT JOIN freight.yards origin_yard ON origin_yard.id = b.origin_yard_id
|
||||||
|
LEFT JOIN freight.yards destination_yard ON destination_yard.id = b.destination_yard_id
|
||||||
LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_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_yards yard ON yard.id = inv.yard_id
|
||||||
LEFT JOIN freight.warehouse_zones zone ON zone.id = inv.zone_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.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL
|
||||||
LEFT JOIN freight.booking_container booking_container ON (
|
LEFT JOIN LATERAL (
|
||||||
booking_container.booking_id = b.id
|
SELECT MIN(bc.container_number) AS container_number,
|
||||||
AND booking_container.deleted_at IS NULL
|
STRING_AGG(
|
||||||
)
|
CONCAT_WS(' ', bc.quantity::text, COALESCE(ct.label, ct.code, 'container')),
|
||||||
|
', '
|
||||||
|
ORDER BY COALESCE(ct.label, ct.code, bc.container_type_id::text)
|
||||||
|
) AS "containerSummary"
|
||||||
|
FROM freight.booking_container bc
|
||||||
|
LEFT JOIN freight.container_types ct ON ct.id = bc.container_type_id
|
||||||
|
WHERE bc.booking_id = b.id
|
||||||
|
AND bc.deleted_at IS NULL
|
||||||
|
) booking_container ON true
|
||||||
LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.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.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_schedule_bookings tsb ON tsb.booking_id = b.id AND tsb.deleted_at IS NULL
|
||||||
@@ -2158,18 +2306,37 @@ export class WarehouseInventoryService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const bookingReference = row.bookingReference || row.bookingId || 'N/A';
|
const bookingReference = row.bookingReference || row.bookingId || 'N/A';
|
||||||
|
const reference =
|
||||||
|
this.extractHandoverDocumentLine(row.notes, 'Handover Reference') ||
|
||||||
|
`HND-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}`;
|
||||||
|
const generatedAtValue = this.extractHandoverDocumentLine(row.notes, 'Generated At');
|
||||||
|
const generatedAt = generatedAtValue ? new Date(generatedAtValue) : new Date();
|
||||||
|
const handedOverAt = Number.isNaN(generatedAt.getTime()) ? new Date() : generatedAt;
|
||||||
|
if (!generatedAtValue) {
|
||||||
|
await this.inventoryRepository.update(id, {
|
||||||
|
notes: this.replaceHandoverDocumentNote(row.notes, this.buildHandoverDocumentNote(reference, handedOverAt)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const html = this.buildHandoverDocumentHtml({
|
const html = this.buildHandoverDocumentHtml({
|
||||||
reference: `HND-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}`,
|
reference,
|
||||||
handedOverAt: new Date(row.handoverDate ?? Date.now()),
|
handedOverAt,
|
||||||
bookingReference,
|
bookingReference,
|
||||||
bookingStatus: row.bookingStatus ?? null,
|
bookingStatus: row.bookingStatus ?? null,
|
||||||
customerName: row.customerName ?? null,
|
customerName: row.customerName ?? null,
|
||||||
|
serviceType: row.serviceType ?? null,
|
||||||
freightType: row.freightType ?? null,
|
freightType: row.freightType ?? null,
|
||||||
tradeDirection: row.tradeDirection ?? null,
|
tradeDirection: row.tradeDirection ?? null,
|
||||||
|
route: [row.originYardLabel ?? row.originYardCode, row.destinationYardLabel ?? row.destinationYardCode]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' to ') || null,
|
||||||
|
scheduledDate: row.scheduledDate ? new Date(row.scheduledDate) : null,
|
||||||
containerNumber: row.containerNumber ?? null,
|
containerNumber: row.containerNumber ?? null,
|
||||||
|
bookingContainerSummary: row.bookingContainerSummary ?? null,
|
||||||
cargoDescription: row.cargoDescription ?? null,
|
cargoDescription: row.cargoDescription ?? null,
|
||||||
quantity: Number(row.quantity ?? 0),
|
quantity: Number(row.quantity ?? 0),
|
||||||
weight: Number(row.weight ?? 0),
|
weight: Number(row.weight ?? 0),
|
||||||
|
bookingDeclaredWeight: Number(row.bookingDeclaredWeight ?? 0),
|
||||||
warehouse: [row.warehouseName, row.warehouseCode].filter(Boolean).join(' / ') || null,
|
warehouse: [row.warehouseName, row.warehouseCode].filter(Boolean).join(' / ') || null,
|
||||||
yard: [row.yardName, row.yardCode].filter(Boolean).join(' / ') || null,
|
yard: [row.yardName, row.yardCode].filter(Boolean).join(' / ') || null,
|
||||||
zone: [row.zoneName, row.zoneCode].filter(Boolean).join(' / ') || null,
|
zone: [row.zoneName, row.zoneCode].filter(Boolean).join(' / ') || null,
|
||||||
@@ -2178,11 +2345,12 @@ export class WarehouseInventoryService {
|
|||||||
releaseOrderReference: row.releaseOrderReference ?? null,
|
releaseOrderReference: row.releaseOrderReference ?? null,
|
||||||
releaseDate: row.releaseDate ? new Date(row.releaseDate) : null,
|
releaseDate: row.releaseDate ? new Date(row.releaseDate) : null,
|
||||||
trainSchedule: row.trainSchedule ?? null,
|
trainSchedule: row.trainSchedule ?? null,
|
||||||
|
lastMileDeliveryAddress: row.lastMileDeliveryAddress ?? null,
|
||||||
customerApproval: this.extractCustomerDeliveryApproval(row.notes),
|
customerApproval: this.extractCustomerDeliveryApproval(row.notes),
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
filename: `handover-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
|
filename: `handover-${String(reference).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
|
||||||
buffer: await this.releaseDocuments.htmlToPdfBuffer(html),
|
buffer: await this.releaseDocuments.htmlToPdfBuffer(html),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -2666,6 +2834,128 @@ export class WarehouseInventoryService {
|
|||||||
return this.findById(id);
|
return this.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildGrnDocumentHtml(data: {
|
||||||
|
grnNumber: string;
|
||||||
|
receivedAt: Date;
|
||||||
|
bookingReference: string;
|
||||||
|
bookingStatus: string | null;
|
||||||
|
customerName: string | null;
|
||||||
|
customerTin: string | null;
|
||||||
|
serviceType: string | null;
|
||||||
|
freightType: string | null;
|
||||||
|
tradeDirection: string | null;
|
||||||
|
route: string | null;
|
||||||
|
containerNumber: string | null;
|
||||||
|
bookingContainerSummary: string | null;
|
||||||
|
cargoDescription: string | null;
|
||||||
|
quantity: number;
|
||||||
|
weight: number;
|
||||||
|
volume: number | null;
|
||||||
|
bookingDeclaredWeight: number;
|
||||||
|
warehouse: string | null;
|
||||||
|
yard: string | null;
|
||||||
|
zone: string | null;
|
||||||
|
inventoryStatus: string | null;
|
||||||
|
receiveSummary: string | null;
|
||||||
|
}): string {
|
||||||
|
const esc = (value: unknown) =>
|
||||||
|
String(value ?? '-')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
const receivedAt = data.receivedAt.toLocaleString('en-GB', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
const rows: Array<[string, unknown]> = [
|
||||||
|
['Booking Reference', data.bookingReference],
|
||||||
|
['Customer / Consignee', data.customerName],
|
||||||
|
['Customer TIN', data.customerTin],
|
||||||
|
['Booking Status', data.bookingStatus],
|
||||||
|
['Service Type', data.serviceType],
|
||||||
|
['Freight Type', data.freightType],
|
||||||
|
['Trade Direction', data.tradeDirection],
|
||||||
|
['Route', data.route],
|
||||||
|
['Container Number', data.containerNumber],
|
||||||
|
['Booking Containers', data.bookingContainerSummary],
|
||||||
|
['Cargo / Goods Description', data.cargoDescription],
|
||||||
|
['Quantity', data.quantity],
|
||||||
|
['Received Weight', `${data.weight.toLocaleString()} kg`],
|
||||||
|
['Booking Declared Weight', data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} kg` : null],
|
||||||
|
['Volume', data.volume == null ? null : data.volume.toLocaleString()],
|
||||||
|
['Warehouse', data.warehouse],
|
||||||
|
['Yard', data.yard],
|
||||||
|
['Zone', data.zone],
|
||||||
|
['Inventory Status', data.inventoryStatus],
|
||||||
|
...(data.receiveSummary ? [['Receive Details', data.receiveSummary] as [string, string]] : []),
|
||||||
|
];
|
||||||
|
|
||||||
|
return `<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Goods Received Note</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 210px; gap: 24px; border-top: 5px solid #0f766e; padding-top: 18px; }
|
||||||
|
.brand { font-size: 12px; color: #064c27; text-transform: uppercase; letter-spacing: .13em; font-weight: 800; }
|
||||||
|
h1 { margin: 8px 0 0; font-size: 31px; 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: #0f766e; margin: 16px 0 22px; }
|
||||||
|
.notice { width: 76%; margin: 0 0 18px; padding: 13px 18px; background: #f0fdfa; border: 1px solid #5eead4; border-left: 5px solid #0f766e; font-size: 13px; line-height: 1.45; }
|
||||||
|
.section-title { margin: 18px 0 8px; font-size: 13px; font-weight: 800; color: #0f766e; 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; white-space: pre-line; }
|
||||||
|
.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 1fr; gap: 34px; align-items: start; margin-top: 42px; }
|
||||||
|
.line { border-top: 1.4px solid #061323; padding-top: 7px; font-size: 10.8px; color: #24384f; min-height: 42px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="top">
|
||||||
|
<div>
|
||||||
|
<div class="brand">Ethio-Djibouti Railway S.C.</div>
|
||||||
|
<h1>Goods Received Note</h1>
|
||||||
|
<div class="subtitle">Warehouse receiving confirmation</div>
|
||||||
|
</div>
|
||||||
|
<div class="ref">
|
||||||
|
GRN Number
|
||||||
|
<strong>${esc(data.grnNumber)}</strong>
|
||||||
|
Received: ${esc(receivedAt)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rule"></div>
|
||||||
|
<div class="notice">
|
||||||
|
This Goods Received Note confirms that the listed goods were received into EDR warehouse custody at the stated location.
|
||||||
|
</div>
|
||||||
|
<div class="section-title">Receiving 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">Receipt Clause</div>
|
||||||
|
<div class="clause">
|
||||||
|
This document records warehouse receipt only. Loading, dispatch, release, delivery, customs, and fee clearance remain subject to their respective operational approvals.
|
||||||
|
</div>
|
||||||
|
<div class="signatures">
|
||||||
|
<div class="line">Warehouse receiver name / signature / date</div>
|
||||||
|
<div class="line">Driver or customer representative name / signature / date</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
private buildReleaseDocumentHtml(data: {
|
private buildReleaseDocumentHtml(data: {
|
||||||
reference: string;
|
reference: string;
|
||||||
issuedAt: Date;
|
issuedAt: Date;
|
||||||
@@ -2721,7 +3011,7 @@ export class WarehouseInventoryService {
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<title>Warehouse Gate Clearance / Release Order</title>
|
<title>Warehouse Release / Exit Paper</title>
|
||||||
<style>
|
<style>
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
@page { size: A4; margin: 12mm 15mm 14mm; }
|
@page { size: A4; margin: 12mm 15mm 14mm; }
|
||||||
@@ -2752,8 +3042,8 @@ export class WarehouseInventoryService {
|
|||||||
<div class="top">
|
<div class="top">
|
||||||
<div>
|
<div>
|
||||||
<div class="brand">Ethio-Djibouti Railway S.C.</div>
|
<div class="brand">Ethio-Djibouti Railway S.C.</div>
|
||||||
<h1>Warehouse Gate Clearance / Release Order</h1>
|
<h1>Warehouse Release / Exit Paper</h1>
|
||||||
<div class="subtitle">Official warehouse release and exit authorization</div>
|
<div class="subtitle">Official gate clearance and warehouse exit authorization</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ref">
|
<div class="ref">
|
||||||
Document / Release No.
|
Document / Release No.
|
||||||
@@ -2763,7 +3053,7 @@ export class WarehouseInventoryService {
|
|||||||
</div>
|
</div>
|
||||||
<div class="rule"></div>
|
<div class="rule"></div>
|
||||||
<div class="notice">
|
<div class="notice">
|
||||||
This clearance document confirms that the listed booking/goods are authorized for warehouse exit, subject to gate identity verification and confirmation that no blocking warehouse fees remain unpaid.
|
This Exit Paper confirms that the listed booking/goods are authorized for warehouse exit, subject to gate identity verification and confirmation that no blocking warehouse fees remain unpaid.
|
||||||
</div>
|
</div>
|
||||||
<div class="section-title">Release Particulars</div>
|
<div class="section-title">Release Particulars</div>
|
||||||
<table>
|
<table>
|
||||||
@@ -2792,12 +3082,17 @@ export class WarehouseInventoryService {
|
|||||||
bookingReference: string;
|
bookingReference: string;
|
||||||
bookingStatus: string | null;
|
bookingStatus: string | null;
|
||||||
customerName: string | null;
|
customerName: string | null;
|
||||||
|
serviceType: string | null;
|
||||||
freightType: string | null;
|
freightType: string | null;
|
||||||
tradeDirection: string | null;
|
tradeDirection: string | null;
|
||||||
|
route: string | null;
|
||||||
|
scheduledDate: Date | null;
|
||||||
containerNumber: string | null;
|
containerNumber: string | null;
|
||||||
|
bookingContainerSummary: string | null;
|
||||||
cargoDescription: string | null;
|
cargoDescription: string | null;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
weight: number;
|
weight: number;
|
||||||
|
bookingDeclaredWeight: number;
|
||||||
warehouse: string | null;
|
warehouse: string | null;
|
||||||
yard: string | null;
|
yard: string | null;
|
||||||
zone: string | null;
|
zone: string | null;
|
||||||
@@ -2806,6 +3101,7 @@ export class WarehouseInventoryService {
|
|||||||
releaseOrderReference: string | null;
|
releaseOrderReference: string | null;
|
||||||
releaseDate: Date | null;
|
releaseDate: Date | null;
|
||||||
trainSchedule: string | null;
|
trainSchedule: string | null;
|
||||||
|
lastMileDeliveryAddress: string | null;
|
||||||
customerApproval: {
|
customerApproval: {
|
||||||
approvedAt: string;
|
approvedAt: string;
|
||||||
signerDisplayName: string;
|
signerDisplayName: string;
|
||||||
@@ -2835,13 +3131,18 @@ export class WarehouseInventoryService {
|
|||||||
['Booking Reference', data.bookingReference],
|
['Booking Reference', data.bookingReference],
|
||||||
['Customer / Consignee', data.customerName],
|
['Customer / Consignee', data.customerName],
|
||||||
['Booking Status', data.bookingStatus],
|
['Booking Status', data.bookingStatus],
|
||||||
|
['Service Type', data.serviceType],
|
||||||
['Freight Type', data.freightType],
|
['Freight Type', data.freightType],
|
||||||
['Trade Direction', data.tradeDirection],
|
['Trade Direction', data.tradeDirection],
|
||||||
|
['Route', data.route],
|
||||||
|
['Scheduled Date', fmt(data.scheduledDate)],
|
||||||
['Train Schedule', data.trainSchedule],
|
['Train Schedule', data.trainSchedule],
|
||||||
['Container Number', data.containerNumber],
|
['Container Number', data.containerNumber],
|
||||||
|
['Booking Containers', data.bookingContainerSummary],
|
||||||
['Cargo / Goods Description', data.cargoDescription],
|
['Cargo / Goods Description', data.cargoDescription],
|
||||||
['Quantity', data.quantity],
|
['Quantity', data.quantity],
|
||||||
['Declared Weight', `${data.weight.toLocaleString()} kg`],
|
['Inventory Weight', `${data.weight.toLocaleString()} kg`],
|
||||||
|
['Booking Declared Weight', data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} kg` : null],
|
||||||
['Warehouse', data.warehouse],
|
['Warehouse', data.warehouse],
|
||||||
['Yard', data.yard],
|
['Yard', data.yard],
|
||||||
['Zone', data.zone],
|
['Zone', data.zone],
|
||||||
@@ -2849,6 +3150,7 @@ export class WarehouseInventoryService {
|
|||||||
['Inspection Status', data.inspectionStatus],
|
['Inspection Status', data.inspectionStatus],
|
||||||
['Release Order', data.releaseOrderReference],
|
['Release Order', data.releaseOrderReference],
|
||||||
['Release Date', fmt(data.releaseDate)],
|
['Release Date', fmt(data.releaseDate)],
|
||||||
|
['Last-mile Delivery Address', data.lastMileDeliveryAddress],
|
||||||
];
|
];
|
||||||
const approval = data.customerApproval;
|
const approval = data.customerApproval;
|
||||||
|
|
||||||
@@ -2898,7 +3200,8 @@ export class WarehouseInventoryService {
|
|||||||
</div>
|
</div>
|
||||||
<div class="rule"></div>
|
<div class="rule"></div>
|
||||||
<div class="notice">
|
<div class="notice">
|
||||||
This document confirms EDR handed over the listed import goods to the customer after warehouse inspection passed.
|
This handover document is separate from the warehouse Exit Paper. It records the booking, route, cargo, container,
|
||||||
|
inspection, release, and customer approval details for the goods being handed to the customer.
|
||||||
</div>
|
</div>
|
||||||
<div class="section-title">Handover Particulars</div>
|
<div class="section-title">Handover Particulars</div>
|
||||||
<table>
|
<table>
|
||||||
@@ -2911,7 +3214,9 @@ export class WarehouseInventoryService {
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr><th>1. Goods</th><td>${esc(data.cargoDescription || data.containerNumber || data.bookingReference)}</td></tr>
|
<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>Container</th><td>${esc(data.containerNumber)}</td></tr>
|
||||||
<tr><th>Weight</th><td>${esc(`${data.weight.toLocaleString()} kg`)}</td></tr>
|
<tr><th>Booking Containers</th><td>${esc(data.bookingContainerSummary)}</td></tr>
|
||||||
|
<tr><th>Inventory Weight</th><td>${esc(`${data.weight.toLocaleString()} kg`)}</td></tr>
|
||||||
|
<tr><th>Booking Declared Weight</th><td>${esc(data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} kg` : null)}</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div class="section-title">Handover Clause</div>
|
<div class="section-title">Handover Clause</div>
|
||||||
@@ -3156,6 +3461,21 @@ export class WarehouseInventoryService {
|
|||||||
return `GRN-${direction.toUpperCase()}-${stamp}-${suffix}`;
|
return `GRN-${direction.toUpperCase()}-${stamp}-${suffix}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async generateReleaseReference(item: WarehouseInventory): Promise<string> {
|
||||||
|
let bookingReference = item.booking?.reference;
|
||||||
|
if (!bookingReference && item.bookingId) {
|
||||||
|
const [booking]: Array<{ reference: string | null }> = await this.dataSource.query(
|
||||||
|
`SELECT reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL LIMIT 1`,
|
||||||
|
[item.bookingId],
|
||||||
|
);
|
||||||
|
bookingReference = booking?.reference ?? undefined;
|
||||||
|
}
|
||||||
|
if (bookingReference) {
|
||||||
|
return `REL-${String(bookingReference).replace(/^BK-?/i, '')}`;
|
||||||
|
}
|
||||||
|
return `REL-${new Date().toISOString().slice(0, 10).replace(/-/g, '')}-${item.id.replace(/-/g, '').slice(0, 8).toUpperCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
private buildExitInspectionNote(dto: ReleaseOrderDto): string | null {
|
private buildExitInspectionNote(dto: ReleaseOrderDto): string | null {
|
||||||
const hasExitInspection =
|
const hasExitInspection =
|
||||||
Boolean(dto.truckPlateNumber?.trim()) ||
|
Boolean(dto.truckPlateNumber?.trim()) ||
|
||||||
@@ -3179,17 +3499,27 @@ export class WarehouseInventoryService {
|
|||||||
if (!dto.driverName?.trim()) {
|
if (!dto.driverName?.trim()) {
|
||||||
throw new BadRequestException('Driver name is required for exit inspection');
|
throw new BadRequestException('Driver name is required for exit inspection');
|
||||||
}
|
}
|
||||||
if (dto.tareWeight === undefined || dto.grossWeight === undefined) {
|
if (dto.tareWeight === undefined) {
|
||||||
throw new BadRequestException('Tare weight and gross weight are required for exit inspection');
|
throw new BadRequestException('Tare weight is required for truck arrival');
|
||||||
}
|
}
|
||||||
|
|
||||||
const tareWeight = Number(dto.tareWeight);
|
const tareWeight = Number(dto.tareWeight);
|
||||||
const grossWeight = Number(dto.grossWeight);
|
const grossWeight = dto.grossWeight === undefined ? null : Number(dto.grossWeight);
|
||||||
const computedNetWeight = Number((grossWeight - tareWeight).toFixed(3));
|
const computedNetWeight =
|
||||||
const submittedNetWeight = dto.netWeight === undefined ? computedNetWeight : Number(dto.netWeight);
|
grossWeight == null ? null : Number((grossWeight - tareWeight).toFixed(3));
|
||||||
|
const submittedNetWeight =
|
||||||
|
dto.netWeight === undefined || computedNetWeight == null ? computedNetWeight : Number(dto.netWeight);
|
||||||
|
|
||||||
if (Math.abs(submittedNetWeight - computedNetWeight) > 0.001) {
|
if (grossWeight != null && !dto.gateOutTime) {
|
||||||
throw new BadRequestException('Weight mismatch: net weight must equal gross weight minus tare weight.');
|
throw new BadRequestException('Gate out time is required for truck exit');
|
||||||
|
}
|
||||||
|
if (grossWeight != null && computedNetWeight != null && submittedNetWeight != null) {
|
||||||
|
if (Math.abs(submittedNetWeight - computedNetWeight) > 0.001) {
|
||||||
|
throw new BadRequestException('Weight mismatch: net weight must equal gross weight minus tare weight.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ((dto.grossWeight !== undefined || dto.gateOutTime || dto.netWeight !== undefined) && grossWeight == null) {
|
||||||
|
throw new BadRequestException('Gross weight is required for truck exit');
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = [
|
const rows = [
|
||||||
@@ -3205,14 +3535,27 @@ export class WarehouseInventoryService {
|
|||||||
dto.containerNumber?.trim() ? `Container Number: ${dto.containerNumber.trim()}` : null,
|
dto.containerNumber?.trim() ? `Container Number: ${dto.containerNumber.trim()}` : null,
|
||||||
dto.gateInTime ? `Gate In Time: ${dto.gateInTime}` : null,
|
dto.gateInTime ? `Gate In Time: ${dto.gateInTime}` : null,
|
||||||
`Tare Weight: ${tareWeight} kg`,
|
`Tare Weight: ${tareWeight} kg`,
|
||||||
`Gross Weight: ${grossWeight} kg`,
|
grossWeight == null ? null : `Gross Weight: ${grossWeight} kg`,
|
||||||
`Net Weight: ${computedNetWeight} kg`,
|
computedNetWeight == null ? null : `Net Weight: ${computedNetWeight} kg`,
|
||||||
dto.gateOutTime ? `Gate Out Time: ${dto.gateOutTime}` : null,
|
dto.gateOutTime ? `Gate Out Time: ${dto.gateOutTime}` : null,
|
||||||
];
|
];
|
||||||
|
|
||||||
return rows.filter(Boolean).join('\n');
|
return rows.filter(Boolean).join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private replaceExitInspectionNote(notes: string | null | undefined, exitInspectionNote: string | null): string | null {
|
||||||
|
const trimmed = notes?.trim();
|
||||||
|
if (!exitInspectionNote) return trimmed || null;
|
||||||
|
if (!trimmed) return exitInspectionNote;
|
||||||
|
|
||||||
|
const marker = '[Exit Inspection]';
|
||||||
|
const index = trimmed.lastIndexOf(marker);
|
||||||
|
if (index < 0) {
|
||||||
|
return `${trimmed}\n\n${exitInspectionNote}`;
|
||||||
|
}
|
||||||
|
return [trimmed.slice(0, index).trim(), exitInspectionNote].filter(Boolean).join('\n\n');
|
||||||
|
}
|
||||||
|
|
||||||
private extractExitInspectionNote(notes?: string | null): string | null {
|
private extractExitInspectionNote(notes?: string | null): string | null {
|
||||||
if (!notes) return null;
|
if (!notes) return null;
|
||||||
const marker = '[Exit Inspection]';
|
const marker = '[Exit Inspection]';
|
||||||
@@ -3221,6 +3564,40 @@ export class WarehouseInventoryService {
|
|||||||
return notes.slice(index + marker.length).trim() || null;
|
return notes.slice(index + marker.length).trim() || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private extractReceiveSummary(notes?: string | null): string | null {
|
||||||
|
if (!notes?.trim()) return null;
|
||||||
|
const withoutExit = notes.split('\n\n[Exit Inspection]')[0] ?? notes;
|
||||||
|
const withoutHandover = withoutExit.split(`\n\n${HANDOVER_DOCUMENT_MARKER}`)[0] ?? withoutExit;
|
||||||
|
return this.stripCustomerDeliveryApproval(withoutHandover)?.trim() || withoutHandover.trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildHandoverDocumentNote(reference: string, generatedAt: Date): string {
|
||||||
|
return [
|
||||||
|
HANDOVER_DOCUMENT_MARKER,
|
||||||
|
`Handover Reference: ${reference}`,
|
||||||
|
`Generated At: ${generatedAt.toISOString()}`,
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
private replaceHandoverDocumentNote(notes: string | null | undefined, handoverDocumentNote: string): string {
|
||||||
|
const trimmed = notes?.trim();
|
||||||
|
if (!trimmed) return handoverDocumentNote;
|
||||||
|
const index = trimmed.lastIndexOf(HANDOVER_DOCUMENT_MARKER);
|
||||||
|
if (index < 0) {
|
||||||
|
return `${trimmed}\n\n${handoverDocumentNote}`;
|
||||||
|
}
|
||||||
|
return [trimmed.slice(0, index).trim(), handoverDocumentNote].filter(Boolean).join('\n\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractHandoverDocumentLine(notes: string | null | undefined, label: string): string | null {
|
||||||
|
if (!notes) return null;
|
||||||
|
const index = notes.lastIndexOf(HANDOVER_DOCUMENT_MARKER);
|
||||||
|
if (index < 0) return null;
|
||||||
|
const section = notes.slice(index + HANDOVER_DOCUMENT_MARKER.length);
|
||||||
|
const match = section.match(new RegExp(`^${label}:\\s*(.+)$`, 'im'));
|
||||||
|
return match?.[1]?.trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
private buildReceiveNote(input: {
|
private buildReceiveNote(input: {
|
||||||
grnNumber: string;
|
grnNumber: string;
|
||||||
direction?: string | null;
|
direction?: string | null;
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { config } from 'dotenv';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
|
||||||
|
config({ path: resolve(__dirname, '../../.env') });
|
||||||
|
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
import { AppModule } from '../app.module';
|
||||||
|
import { Booking } from '../modules/bookings/entities/booking.entity';
|
||||||
|
import { BookingContainer } from '../modules/bookings/entities/booking-container.entity';
|
||||||
|
import { WarehouseInventory } from '../modules/warehouses/entities/warehouse-inventory.entity';
|
||||||
|
import { CargoType } from '../modules/rule-engine/entities/cargo-type.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';
|
||||||
|
|
||||||
|
const BOOKING_REFS = [
|
||||||
|
'WH-EXP-RCV-001',
|
||||||
|
'WH-EXP-RCV-002',
|
||||||
|
'WH-EXP-RCV-003',
|
||||||
|
'WH-EXP-RCV-004',
|
||||||
|
'WH-EXP-RCV-005',
|
||||||
|
];
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const app = await NestFactory.createApplicationContext(AppModule, {
|
||||||
|
logger: ['error', 'warn', 'log'],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dataSource = app.get(DataSource);
|
||||||
|
const yardRepo = dataSource.getRepository(Yard);
|
||||||
|
const serviceTypeRepo = dataSource.getRepository(ServiceType);
|
||||||
|
const cargoTypeRepo = dataSource.getRepository(CargoType);
|
||||||
|
const containerTypeRepo = dataSource.getRepository(ContainerType);
|
||||||
|
const bookingRepo = dataSource.getRepository(Booking);
|
||||||
|
const bookingContainerRepo = dataSource.getRepository(BookingContainer);
|
||||||
|
const inventoryRepo = dataSource.getRepository(WarehouseInventory);
|
||||||
|
|
||||||
|
const originYard =
|
||||||
|
(await yardRepo.findOne({ where: { code: 'MOJO' } })) ??
|
||||||
|
(await yardRepo.findOne({ where: { country: 'Ethiopia' } }));
|
||||||
|
const destinationYard =
|
||||||
|
(await yardRepo.findOne({ where: { code: 'DJIB_PORT' } })) ??
|
||||||
|
(await yardRepo.findOne({ where: { country: 'Djibouti' } }));
|
||||||
|
const serviceType =
|
||||||
|
(await serviceTypeRepo.findOne({ where: { code: 'RAIL_CONTAINER', includesFirstMile: false, isActive: true } })) ??
|
||||||
|
(await serviceTypeRepo.findOne({ where: { includesFirstMile: false, isActive: true } }));
|
||||||
|
const cargoType = await cargoTypeRepo.findOne({ where: { isActive: true } });
|
||||||
|
const containerType =
|
||||||
|
(await containerTypeRepo.findOne({ where: { code: '40FT', isActive: true } })) ??
|
||||||
|
(await containerTypeRepo.findOne({ where: { code: '40', isActive: true } })) ??
|
||||||
|
(await containerTypeRepo.findOne({ where: { sizeFt: 40, isActive: true } })) ??
|
||||||
|
(await containerTypeRepo.findOne({ where: { isActive: true } }));
|
||||||
|
|
||||||
|
const missing = [
|
||||||
|
!originYard ? 'MOJO/Ethiopia origin yard' : '',
|
||||||
|
!destinationYard ? 'DJIB_PORT/Djibouti destination yard' : '',
|
||||||
|
!serviceType ? 'active service type without first mile' : '',
|
||||||
|
!containerType ? 'active container type' : '',
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
if (missing.length) {
|
||||||
|
throw new Error(`Cannot seed warehouse export receive-ready bookings, missing: ${missing.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let created = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
for (const [index, reference] of BOOKING_REFS.entries()) {
|
||||||
|
const existing = await bookingRepo.findOne({ where: { reference } });
|
||||||
|
if (existing) {
|
||||||
|
skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerQuantity = index === 4 ? 2 : 1;
|
||||||
|
const weightKg = 18_000 + index * 1_250 + (containerQuantity - 1) * 9_000;
|
||||||
|
const scheduledDate = new Date(now + index * 60 * 60_000);
|
||||||
|
|
||||||
|
const booking = await bookingRepo.save(
|
||||||
|
bookingRepo.create({
|
||||||
|
reference,
|
||||||
|
originYardId: originYard!.id,
|
||||||
|
destinationYardId: destinationYard!.id,
|
||||||
|
serviceTypeId: serviceType!.id,
|
||||||
|
status: 'PAID',
|
||||||
|
paymentStatus: 'PAID',
|
||||||
|
scheduledDate,
|
||||||
|
contractType: 'SPOT',
|
||||||
|
equipmentReturn: 'TERMINAL',
|
||||||
|
paymentCurrency: 'ETB',
|
||||||
|
totalAmount: 0,
|
||||||
|
isGovernment: false,
|
||||||
|
tradeDirection: 'EXPORT',
|
||||||
|
freightType: 'CONTAINER',
|
||||||
|
cargoTypeId: cargoType?.id ?? null,
|
||||||
|
cargoFreeText: cargoType ? null : `Warehouse export receive-ready cargo ${index + 1}`,
|
||||||
|
cargoTotalWeightVgm: weightKg,
|
||||||
|
schedulingStatus: 'NOT_SCHEDULED',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await bookingContainerRepo.save(
|
||||||
|
bookingContainerRepo.create({
|
||||||
|
bookingId: booking.id,
|
||||||
|
containerTypeId: containerType!.id,
|
||||||
|
containerNumber: `EDRU${String(730100 + index).padStart(6, '0')}`,
|
||||||
|
containerSize: containerType!.sizeFt ? `${containerType!.sizeFt}ft` : containerType!.code,
|
||||||
|
quantity: containerQuantity,
|
||||||
|
hazardousQuantity: 0,
|
||||||
|
reeferQuantity: 0,
|
||||||
|
vgmPerUnitTons: Number((weightKg / containerQuantity / 1000).toFixed(3)),
|
||||||
|
totalVgmTons: Number((weightKg / 1000).toFixed(3)),
|
||||||
|
wagonsRequired: Math.max(1, containerQuantity * Number(containerType!.wagonsPerUnit ?? 1)),
|
||||||
|
isOverweight: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const inventory = await inventoryRepo.findOne({ where: { bookingId: booking.id } });
|
||||||
|
if (inventory) {
|
||||||
|
throw new Error(`Seed invariant failed: booking ${reference} unexpectedly has warehouse inventory`);
|
||||||
|
}
|
||||||
|
|
||||||
|
created += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Warehouse export receive-ready seed complete. Created ${created}, skipped ${skipped}.`);
|
||||||
|
console.log(`Booking refs: ${BOOKING_REFS.join(', ')}`);
|
||||||
|
console.log('Open Backoffice Warehouse > Receive for loading > Export / Receive to Warehouse.');
|
||||||
|
} finally {
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error('Warehouse export receive-ready seed failed:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -23,11 +23,20 @@ function DetailRow({ label, value }: { label: string; value: React.ReactNode })
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const noteLineValue = (notes: string | null | undefined, label: string) => {
|
||||||
|
const match = notes?.match(new RegExp(`^${label}:\\s*(.+)$`, 'im'));
|
||||||
|
return match?.[1]?.trim() ?? '';
|
||||||
|
};
|
||||||
|
|
||||||
export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailModalProps) {
|
export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailModalProps) {
|
||||||
const bookingReference = item?.booking?.reference ?? '-';
|
const bookingReference = item?.booking?.reference ?? '-';
|
||||||
|
const handoverReference = item?.handoverDocumentReference ?? noteLineValue(item?.notes, 'Handover Reference');
|
||||||
|
const handoverDate = item?.handoverDocumentDate ?? noteLineValue(item?.notes, 'Generated At');
|
||||||
const inventorySummary = [
|
const inventorySummary = [
|
||||||
item?.status?.replace(/_/g, ' '),
|
item?.status?.replace(/_/g, ' '),
|
||||||
|
item?.grnNumber ? `GRN ${item.grnNumber}` : null,
|
||||||
item?.releaseOrderReference ? `Release ${item.releaseOrderReference}` : null,
|
item?.releaseOrderReference ? `Release ${item.releaseOrderReference}` : null,
|
||||||
|
handoverReference ? `Handover ${handoverReference}` : null,
|
||||||
item?.warehouse ? `${item.warehouse.name} (${item.warehouse.code})` : null,
|
item?.warehouse ? `${item.warehouse.name} (${item.warehouse.code})` : null,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
@@ -61,11 +70,13 @@ export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailM
|
|||||||
<Divider label="Booking & item" labelPosition="left" />
|
<Divider label="Booking & item" labelPosition="left" />
|
||||||
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||||
<DetailRow label="Booking reference" value={bookingReference} />
|
<DetailRow label="Booking reference" value={bookingReference} />
|
||||||
|
<DetailRow label="GRN" value={item.grnNumber ?? '-'} />
|
||||||
<DetailRow label="Booking status" value={item.booking?.status ?? '-'} />
|
<DetailRow label="Booking status" value={item.booking?.status ?? '-'} />
|
||||||
<DetailRow label="Payment status" value={item.booking?.paymentStatus ?? '-'} />
|
<DetailRow label="Payment status" value={item.booking?.paymentStatus ?? '-'} />
|
||||||
<DetailRow label="Trade direction" value={item.booking?.tradeDirection ?? '-'} />
|
<DetailRow label="Trade direction" value={item.booking?.tradeDirection ?? '-'} />
|
||||||
<DetailRow label="Inventory status" value={item.status.replace(/_/g, ' ')} />
|
<DetailRow label="Inventory status" value={item.status.replace(/_/g, ' ')} />
|
||||||
<DetailRow label="Release reference" value={item.releaseOrderReference ?? '-'} />
|
<DetailRow label="Release reference" value={item.releaseOrderReference ?? '-'} />
|
||||||
|
<DetailRow label="Handover reference" value={handoverReference || '-'} />
|
||||||
<DetailRow label="Quantity" value={formatNumber(item.quantity)} />
|
<DetailRow label="Quantity" value={formatNumber(item.quantity)} />
|
||||||
<DetailRow label="Weight" value={`${formatNumber(item.weight)} kg`} />
|
<DetailRow label="Weight" value={`${formatNumber(item.weight)} kg`} />
|
||||||
<DetailRow label="Volume" value={item.volume == null ? '-' : formatNumber(item.volume)} />
|
<DetailRow label="Volume" value={item.volume == null ? '-' : formatNumber(item.volume)} />
|
||||||
@@ -83,6 +94,7 @@ export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailM
|
|||||||
<DetailRow label="Dispatched" value={formatDate(item.dispatchedAt)} />
|
<DetailRow label="Dispatched" value={formatDate(item.dispatchedAt)} />
|
||||||
<DetailRow label="Ready for pickup" value={formatDate(item.readyForPickupAt)} />
|
<DetailRow label="Ready for pickup" value={formatDate(item.readyForPickupAt)} />
|
||||||
<DetailRow label="Released" value={formatDate(item.releaseDate)} />
|
<DetailRow label="Released" value={formatDate(item.releaseDate)} />
|
||||||
|
<DetailRow label="Handover generated" value={formatDate(handoverDate)} />
|
||||||
<DetailRow label="Delivered" value={formatDate(item.deliveredAt)} />
|
<DetailRow label="Delivered" value={formatDate(item.deliveredAt)} />
|
||||||
<DetailRow label="Release reference" value={item.releaseOrderReference ?? '-'} />
|
<DetailRow label="Release reference" value={item.releaseOrderReference ?? '-'} />
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Fragment, useEffect, useMemo, useState } from 'react';
|
import { Fragment, useEffect, useMemo, useState, type MouseEvent } from 'react';
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Alert,
|
Alert,
|
||||||
@@ -79,6 +79,45 @@ interface ReceiveInventoryModalProps {
|
|||||||
onReceived?: () => void;
|
onReceived?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function GrnDocumentButton({ inventoryId, grnNumber }: { inventoryId: string; grnNumber?: string | null }) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const openDocument = async (event: MouseEvent<HTMLButtonElement>) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (!grnNumber) {
|
||||||
|
toast({ variant: 'destructive', title: 'GRN document unavailable', description: 'This item has no GRN number yet.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
const pdfWindow = window.open('', '_blank');
|
||||||
|
try {
|
||||||
|
const response = await warehouseService.downloadGrnDocument(inventoryId);
|
||||||
|
const opened = openPdfBlob(response.data, `grn-${grnNumber}.pdf`, pdfWindow);
|
||||||
|
toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' });
|
||||||
|
} catch (error) {
|
||||||
|
pdfWindow?.close();
|
||||||
|
toast({ variant: 'destructive', title: 'GRN document failed', description: extractErrorMessage(error) });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
variant="subtle"
|
||||||
|
color="teal"
|
||||||
|
leftSection={<FileText size={12} />}
|
||||||
|
disabled={!grnNumber}
|
||||||
|
loading={loading}
|
||||||
|
onClick={openDocument}
|
||||||
|
>
|
||||||
|
{grnNumber ?? 'No GRN'}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface Location {
|
interface Location {
|
||||||
warehouseId: string;
|
warehouseId: string;
|
||||||
yardId: string;
|
yardId: string;
|
||||||
@@ -620,11 +659,13 @@ function EligibleTab({
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const { data: allRows = [], isLoading } = useQuery(
|
const { data: allRows = [], isLoading } = useQuery(
|
||||||
api.warehouses.eligibleBookings.queryOptions({ enabled }),
|
api.warehouses.eligibleBookings.queryOptions({
|
||||||
|
input: { direction },
|
||||||
|
enabled,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
const rows = useMemo(() => allRows.filter((r) => r.direction === direction), [allRows, direction]);
|
const rows = useMemo(() => allRows.filter((r) => r.direction === direction), [allRows, direction]);
|
||||||
const bulkReceive = useMutation(api.warehouses.bulkReceive.mutationOptions());
|
const bulkReceive = useMutation(api.warehouses.bulkReceive.mutationOptions());
|
||||||
const loadPassed = useMutation(api.warehouses.loadPassedExport.mutationOptions());
|
|
||||||
const requestFirstMile = useMutation({
|
const requestFirstMile = useMutation({
|
||||||
mutationFn: (reference: string) => firstMileService.accept(reference),
|
mutationFn: (reference: string) => firstMileService.accept(reference),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -782,10 +823,24 @@ function EligibleTab({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { form, lockedFields, packagingFreightType: nextPackagingFreightType } = truckEntranceFromBookings(selectedRows);
|
const { form, lockedFields, packagingFreightType: nextPackagingFreightType } = truckEntranceFromBookings(selectedRows);
|
||||||
|
const totalContainerQuantity = selectedRows.reduce(
|
||||||
|
(sum, row) => sum + Number(row.containerQuantity ?? 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const normalizedForm =
|
||||||
|
nextPackagingFreightType === 'CONTAINER' && totalContainerQuantity > 0
|
||||||
|
? {
|
||||||
|
...form,
|
||||||
|
unitCount: totalContainerQuantity,
|
||||||
|
}
|
||||||
|
: form;
|
||||||
setPendingReceiveIds(filteredIds);
|
setPendingReceiveIds(filteredIds);
|
||||||
setReceivedAt(new Date().toISOString());
|
setReceivedAt(new Date().toISOString());
|
||||||
setTruckForm(form);
|
setTruckForm(normalizedForm);
|
||||||
setLockedTruckFields(lockedFields);
|
setLockedTruckFields({
|
||||||
|
...lockedFields,
|
||||||
|
unitCount: nextPackagingFreightType === 'CONTAINER' && totalContainerQuantity > 0,
|
||||||
|
});
|
||||||
setPackagingFreightType(nextPackagingFreightType);
|
setPackagingFreightType(nextPackagingFreightType);
|
||||||
setTruckOpen(true);
|
setTruckOpen(true);
|
||||||
};
|
};
|
||||||
@@ -798,18 +853,6 @@ function EligibleTab({
|
|||||||
await receiveBookings(pendingReceiveIds, toTruckEntrancePayload(truckForm));
|
await receiveBookings(pendingReceiveIds, toTruckEntrancePayload(truckForm));
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadPassedExport = async () => {
|
|
||||||
try {
|
|
||||||
const r = await loadPassed.mutateAsync(undefined);
|
|
||||||
toast({
|
|
||||||
title: `${r.loadedCount} loaded`,
|
|
||||||
description: r.skippedCount ? `${r.skippedCount} skipped — inspection not passed` : undefined,
|
|
||||||
});
|
|
||||||
onChanged?.();
|
|
||||||
} catch (error) {
|
|
||||||
toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(error) });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="sm" mt="sm">
|
<Stack gap="sm" mt="sm">
|
||||||
@@ -828,18 +871,6 @@ function EligibleTab({
|
|||||||
Selected: <b>{selected.size}</b> / {statusFilteredRows.length} eligible
|
Selected: <b>{selected.size}</b> / {statusFilteredRows.length} eligible
|
||||||
</Text>
|
</Text>
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
{direction === 'EXPORT' && (
|
|
||||||
<Button
|
|
||||||
size="compact-sm"
|
|
||||||
variant="light"
|
|
||||||
color="teal"
|
|
||||||
leftSection={<Truck size={14} />}
|
|
||||||
loading={loadPassed.isPending}
|
|
||||||
onClick={loadPassedExport}
|
|
||||||
>
|
|
||||||
Load Passed Export Items
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
<Button
|
||||||
size="compact-sm"
|
size="compact-sm"
|
||||||
color={direction === 'EXPORT' ? 'edr-green' : undefined}
|
color={direction === 'EXPORT' ? 'edr-green' : undefined}
|
||||||
@@ -849,7 +880,7 @@ function EligibleTab({
|
|||||||
loading={bulkReceive.isPending}
|
loading={bulkReceive.isPending}
|
||||||
onClick={() => openTruckReceive(selected.size > 0 ? [...selected] : selectableRows.map((r) => r.id))}
|
onClick={() => openTruckReceive(selected.size > 0 ? [...selected] : selectableRows.map((r) => r.id))}
|
||||||
>
|
>
|
||||||
{direction === 'EXPORT' ? 'Receive to Warehouse' : 'Receive All to Warehouse'}
|
{direction === 'EXPORT' ? 'Receive All for Loading' : 'Receive All to Warehouse'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="compact-sm"
|
size="compact-sm"
|
||||||
@@ -858,7 +889,7 @@ function EligibleTab({
|
|||||||
loading={bulkReceive.isPending}
|
loading={bulkReceive.isPending}
|
||||||
onClick={() => openTruckReceive([...selected])}
|
onClick={() => openTruckReceive([...selected])}
|
||||||
>
|
>
|
||||||
Receive Selected
|
{direction === 'EXPORT' ? 'Receive Selected for Loading' : 'Receive Selected'}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -1000,7 +1031,7 @@ function EligibleTab({
|
|||||||
loading={bulkReceive.isPending}
|
loading={bulkReceive.isPending}
|
||||||
onClick={() => openTruckReceive([r.id])}
|
onClick={() => openTruckReceive([r.id])}
|
||||||
>
|
>
|
||||||
{canReceive ? 'Receive to Warehouse' : 'Await First Mile'}
|
{canReceive ? (direction === 'EXPORT' ? 'Receive for Loading' : 'Receive to Warehouse') : 'Await First Mile'}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
@@ -1015,7 +1046,7 @@ function EligibleTab({
|
|||||||
<Modal
|
<Modal
|
||||||
opened={truckOpen}
|
opened={truckOpen}
|
||||||
onClose={() => setTruckOpen(false)}
|
onClose={() => setTruckOpen(false)}
|
||||||
title="Receive to Warehouse"
|
title={direction === 'EXPORT' ? 'Receive for Loading' : 'Receive to Warehouse'}
|
||||||
centered
|
centered
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
@@ -1175,6 +1206,7 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
|
|||||||
/>
|
/>
|
||||||
</Table.Th>
|
</Table.Th>
|
||||||
<Table.Th>Booking Ref</Table.Th>
|
<Table.Th>Booking Ref</Table.Th>
|
||||||
|
<Table.Th>GRN</Table.Th>
|
||||||
<Table.Th>Booking ID</Table.Th>
|
<Table.Th>Booking ID</Table.Th>
|
||||||
<Table.Th>Customer ID</Table.Th>
|
<Table.Th>Customer ID</Table.Th>
|
||||||
<Table.Th>Customer Name</Table.Th>
|
<Table.Th>Customer Name</Table.Th>
|
||||||
@@ -1201,7 +1233,13 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
|
|||||||
/>
|
/>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
|
<Stack gap={2}>
|
||||||
|
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
|
||||||
|
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
|
||||||
|
</Stack>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Text size="xs" c="dimmed">{r.bookingId ? `${r.bookingId.slice(0, 8)}…` : '—'}</Text>
|
<Text size="xs" c="dimmed">{r.bookingId ? `${r.bookingId.slice(0, 8)}…` : '—'}</Text>
|
||||||
@@ -1322,6 +1360,7 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
|
|||||||
/>
|
/>
|
||||||
</Table.Th>
|
</Table.Th>
|
||||||
<Table.Th>Booking Ref</Table.Th>
|
<Table.Th>Booking Ref</Table.Th>
|
||||||
|
<Table.Th>GRN</Table.Th>
|
||||||
<Table.Th>Booking ID</Table.Th>
|
<Table.Th>Booking ID</Table.Th>
|
||||||
<Table.Th>Customer ID</Table.Th>
|
<Table.Th>Customer ID</Table.Th>
|
||||||
<Table.Th>Customer Name</Table.Th>
|
<Table.Th>Customer Name</Table.Th>
|
||||||
@@ -1344,7 +1383,13 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
|
|||||||
/>
|
/>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
|
<Stack gap={2}>
|
||||||
|
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
|
||||||
|
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
|
||||||
|
</Stack>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Text size="xs" c="dimmed">{r.bookingId ? `${r.bookingId.slice(0, 8)}…` : '—'}</Text>
|
<Text size="xs" c="dimmed">{r.bookingId ? `${r.bookingId.slice(0, 8)}…` : '—'}</Text>
|
||||||
@@ -1492,6 +1537,7 @@ function LoadedExportTab({
|
|||||||
</Table.Th>
|
</Table.Th>
|
||||||
)}
|
)}
|
||||||
<Table.Th>Booking Ref</Table.Th>
|
<Table.Th>Booking Ref</Table.Th>
|
||||||
|
<Table.Th>GRN</Table.Th>
|
||||||
<Table.Th>Booking ID</Table.Th>
|
<Table.Th>Booking ID</Table.Th>
|
||||||
<Table.Th>Customer ID</Table.Th>
|
<Table.Th>Customer ID</Table.Th>
|
||||||
<Table.Th>Customer Name</Table.Th>
|
<Table.Th>Customer Name</Table.Th>
|
||||||
@@ -1516,7 +1562,13 @@ function LoadedExportTab({
|
|||||||
</Table.Td>
|
</Table.Td>
|
||||||
)}
|
)}
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
|
<Stack gap={2}>
|
||||||
|
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
|
||||||
|
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
|
||||||
|
</Stack>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Text size="xs" c="dimmed">{r.bookingId ? `${r.bookingId.slice(0, 8)}…` : '—'}</Text>
|
<Text size="xs" c="dimmed">{r.bookingId ? `${r.bookingId.slice(0, 8)}…` : '—'}</Text>
|
||||||
@@ -1866,12 +1918,15 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
|||||||
bookingId: row.bookingId,
|
bookingId: row.bookingId,
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
weight: Number(row.weight) || 0,
|
weight: Number(row.weight) || 0,
|
||||||
|
grnNumber: row.grnNumber,
|
||||||
status: row.currentStatus,
|
status: row.currentStatus,
|
||||||
arrivedAt: row.arrivalTime,
|
arrivedAt: row.arrivalTime,
|
||||||
unloadedAt: row.arrivalTime,
|
unloadedAt: row.arrivalTime,
|
||||||
inspectionStatus: row.inspectionStatus,
|
inspectionStatus: row.inspectionStatus,
|
||||||
releaseDate: row.releaseDate,
|
releaseDate: row.releaseDate,
|
||||||
releaseOrderReference: row.releaseOrderReference,
|
releaseOrderReference: row.releaseOrderReference,
|
||||||
|
handoverDocumentReference: row.handoverDocumentReference,
|
||||||
|
handoverDocumentDate: row.handoverDocumentDate,
|
||||||
deliveredAt: row.deliveredAt,
|
deliveredAt: row.deliveredAt,
|
||||||
booking: row.bookingId
|
booking: row.bookingId
|
||||||
? {
|
? {
|
||||||
@@ -1902,6 +1957,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
|||||||
try {
|
try {
|
||||||
const response = await warehouseService.downloadHandoverDocument(row.id);
|
const response = await warehouseService.downloadHandoverDocument(row.id);
|
||||||
openPdfBlob(response.data, `handover-${row.bookingReference ?? row.id}.pdf`, pdfWindow);
|
openPdfBlob(response.data, `handover-${row.bookingReference ?? row.id}.pdf`, pdfWindow);
|
||||||
|
void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
pdfWindow?.close();
|
pdfWindow?.close();
|
||||||
toast({ variant: 'destructive', title: 'Handover document failed', description: extractErrorMessage(error) });
|
toast({ variant: 'destructive', title: 'Handover document failed', description: extractErrorMessage(error) });
|
||||||
@@ -1910,6 +1966,20 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openReleaseDocument = async (row: ImportUnloadedItem) => {
|
||||||
|
setBusyId(row.id);
|
||||||
|
const pdfWindow = window.open('', '_blank');
|
||||||
|
try {
|
||||||
|
const response = await warehouseService.downloadReleaseDocument(row.id);
|
||||||
|
openPdfBlob(response.data, `release-${row.bookingReference ?? row.id}.pdf`, pdfWindow);
|
||||||
|
} catch (error) {
|
||||||
|
pdfWindow?.close();
|
||||||
|
toast({ variant: 'destructive', title: 'Exit paper failed', description: extractErrorMessage(error) });
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="sm" mt="sm">
|
<Stack gap="sm" mt="sm">
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
@@ -1959,6 +2029,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
|||||||
</Table.Th>
|
</Table.Th>
|
||||||
<Table.Th>Booking ID</Table.Th>
|
<Table.Th>Booking ID</Table.Th>
|
||||||
<Table.Th>Booking Ref</Table.Th>
|
<Table.Th>Booking Ref</Table.Th>
|
||||||
|
<Table.Th>GRN</Table.Th>
|
||||||
<Table.Th>Customer ID</Table.Th>
|
<Table.Th>Customer ID</Table.Th>
|
||||||
<Table.Th>Customer Name</Table.Th>
|
<Table.Th>Customer Name</Table.Th>
|
||||||
<Table.Th>Arrival Time</Table.Th>
|
<Table.Th>Arrival Time</Table.Th>
|
||||||
@@ -1987,7 +2058,13 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
|||||||
<Text size="xs" c="dimmed">{r.bookingId ? `${r.bookingId.slice(0, 8)}…` : '—'}</Text>
|
<Text size="xs" c="dimmed">{r.bookingId ? `${r.bookingId.slice(0, 8)}…` : '—'}</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
|
<Stack gap={2}>
|
||||||
|
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
|
||||||
|
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
|
||||||
|
</Stack>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Text size="xs" c="dimmed">{r.customerId ? `${r.customerId.slice(0, 8)}…` : '—'}</Text>
|
<Text size="xs" c="dimmed">{r.customerId ? `${r.customerId.slice(0, 8)}…` : '—'}</Text>
|
||||||
@@ -2049,7 +2126,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
|||||||
color="yellow"
|
color="yellow"
|
||||||
onClick={() => setReleaseItem(toInventoryItem(r))}
|
onClick={() => setReleaseItem(toInventoryItem(r))}
|
||||||
>
|
>
|
||||||
Truck Arrival
|
{r.releaseOrderReference ? 'Truck Leaving' : 'Truck Arrival'}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -2064,6 +2141,18 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
|||||||
Dispatch
|
Dispatch
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
variant="light"
|
||||||
|
color="orange"
|
||||||
|
leftSection={<FileText size={14} />}
|
||||||
|
loading={busyId === r.id}
|
||||||
|
onClick={() => openReleaseDocument(r)}
|
||||||
|
>
|
||||||
|
Exit Paper
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
|
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
|
||||||
<Button
|
<Button
|
||||||
size="compact-xs"
|
size="compact-xs"
|
||||||
@@ -2082,7 +2171,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
|||||||
leftSection={<FileText size={14} />}
|
leftSection={<FileText size={14} />}
|
||||||
onClick={() => openHandoverDocument(r)}
|
onClick={() => openHandoverDocument(r)}
|
||||||
>
|
>
|
||||||
Handover
|
{r.handoverDocumentReference ? 'View Handover' : 'Handover'}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button size="compact-xs" variant="light" color="orange" onClick={() => setInspectId(r.id)}>
|
<Button size="compact-xs" variant="light" color="orange" onClick={() => setInspectId(r.id)}>
|
||||||
@@ -2398,7 +2487,12 @@ function ExportWarehouseTabs({
|
|||||||
onChanged?: () => void;
|
onChanged?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [activeTab, setActiveTab] = useState<ExportWarehouseTab>('receive-queue');
|
const [activeTab, setActiveTab] = useState<ExportWarehouseTab>('receive-queue');
|
||||||
const { data: eligibleRows = [] } = useQuery(api.warehouses.eligibleBookings.queryOptions({ enabled }));
|
const { data: eligibleRows = [] } = useQuery(
|
||||||
|
api.warehouses.eligibleBookings.queryOptions({
|
||||||
|
input: { direction: 'EXPORT' },
|
||||||
|
enabled,
|
||||||
|
}),
|
||||||
|
);
|
||||||
const { data: receivedRows = [] } = useQuery(api.warehouses.receivedExport.queryOptions({ enabled }));
|
const { data: receivedRows = [] } = useQuery(api.warehouses.receivedExport.queryOptions({ enabled }));
|
||||||
const { data: readyRows = [] } = useQuery(api.warehouses.readyToLoadExport.queryOptions({ enabled }));
|
const { data: readyRows = [] } = useQuery(api.warehouses.readyToLoadExport.queryOptions({ enabled }));
|
||||||
const { data: loadedRows = [] } = useQuery(api.warehouses.loadedExport.queryOptions({ enabled }));
|
const { data: loadedRows = [] } = useQuery(api.warehouses.loadedExport.queryOptions({ enabled }));
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Alert, Button, Group, Modal, NumberInput, Select, Stack, Text, TextInput } from '@mantine/core';
|
import { Alert, Button, Group, Modal, NumberInput, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
|
||||||
import { Info, Scale } from 'lucide-react';
|
import { Info, Scale } from 'lucide-react';
|
||||||
|
|
||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation } from '@tanstack/react-query';
|
||||||
@@ -46,6 +46,77 @@ const toIsoDateTime = (value: string) => {
|
|||||||
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
|
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toLocalDateTimeInput = (value?: string | null) => {
|
||||||
|
if (!value) return '';
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return '';
|
||||||
|
const offsetMs = date.getTimezoneOffset() * 60_000;
|
||||||
|
return new Date(date.getTime() - offsetMs).toISOString().slice(0, 16);
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateReleaseReference = (item: WarehouseInventoryItem | null) => {
|
||||||
|
const bookingReference = item?.booking?.reference;
|
||||||
|
if (bookingReference) return `REL-${bookingReference.replace(/^BK-?/i, '')}`;
|
||||||
|
if (item?.bookingId) return `REL-${item.bookingId.replace(/-/g, '').slice(0, 8).toUpperCase()}`;
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const lineValue = (notes: string | null | undefined, label: string) => {
|
||||||
|
const match = notes?.match(new RegExp(`^${label}:\\s*(.+)$`, 'im'));
|
||||||
|
return match?.[1]?.trim() ?? '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const lineNumber = (notes: string | null | undefined, label: string): number | '' => {
|
||||||
|
const value = lineValue(notes, label).replace(/\s*kg$/i, '');
|
||||||
|
if (!value) return '';
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const splitContainerNumbers = (value: string | null | undefined) =>
|
||||||
|
(value ?? '')
|
||||||
|
.split(/[,;\n]+/)
|
||||||
|
.map((number) => number.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const getItemContainerNumber = (item: WarehouseInventoryItem | null) =>
|
||||||
|
(item as (WarehouseInventoryItem & { containerNumber?: string | null }) | null)?.containerNumber ?? '';
|
||||||
|
|
||||||
|
const isContainerInventory = (item: WarehouseInventoryItem | null, containerCount: number) => {
|
||||||
|
const freightType = (item as (WarehouseInventoryItem & { booking?: { freightType?: string | null } | null }) | null)
|
||||||
|
?.booking?.freightType;
|
||||||
|
return Boolean(item?.containerId || containerCount > 0 || freightType === 'CONTAINER');
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialContainerNumbers = (item: WarehouseInventoryItem | null, savedContainerNumber: string) => {
|
||||||
|
const savedNumbers = splitContainerNumbers(savedContainerNumber);
|
||||||
|
const itemNumbers = splitContainerNumbers(getItemContainerNumber(item));
|
||||||
|
const sourceNumbers = savedNumbers.length ? savedNumbers : itemNumbers;
|
||||||
|
const quantityCount = isContainerInventory(item, sourceNumbers.length) ? Number(item?.quantity ?? 0) : 0;
|
||||||
|
const expectedCount = Math.max(1, sourceNumbers.length, quantityCount);
|
||||||
|
return Array.from({ length: expectedCount }, (_, index) => sourceNumbers[index] ?? '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseInspectionNote = (notes: string | null | undefined) => {
|
||||||
|
const marker = '[Exit Inspection]';
|
||||||
|
const index = notes?.lastIndexOf(marker) ?? -1;
|
||||||
|
const note = index >= 0 ? notes?.slice(index + marker.length) : notes;
|
||||||
|
return {
|
||||||
|
truckPlateNumber: lineValue(note, 'Truck Plate'),
|
||||||
|
trailerPlateNumber: lineValue(note, 'Trailer Plate'),
|
||||||
|
driverName: lineValue(note, 'Driver'),
|
||||||
|
driverLicense: lineValue(note, 'Driver License'),
|
||||||
|
driverPhone: lineValue(note, 'Driver Phone'),
|
||||||
|
truckType: lineValue(note, 'Truck Type'),
|
||||||
|
containerNumber: lineValue(note, 'Container Number'),
|
||||||
|
gateInTime: toLocalDateTimeInput(lineValue(note, 'Gate In Time')),
|
||||||
|
tareWeight: lineNumber(note, 'Tare Weight'),
|
||||||
|
grossWeight: lineNumber(note, 'Gross Weight'),
|
||||||
|
netWeight: lineNumber(note, 'Net Weight'),
|
||||||
|
gateOutTime: toLocalDateTimeInput(lineValue(note, 'Gate Out Time')),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalProps) {
|
export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalProps) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const releaseMutation = useMutation(api.warehouses.release.mutationOptions());
|
const releaseMutation = useMutation(api.warehouses.release.mutationOptions());
|
||||||
@@ -56,7 +127,7 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
|
|||||||
const [driverLicense, setDriverLicense] = useState('');
|
const [driverLicense, setDriverLicense] = useState('');
|
||||||
const [driverPhone, setDriverPhone] = useState('');
|
const [driverPhone, setDriverPhone] = useState('');
|
||||||
const [truckType, setTruckType] = useState('');
|
const [truckType, setTruckType] = useState('');
|
||||||
const [containerNumber, setContainerNumber] = useState('');
|
const [containerNumbers, setContainerNumbers] = useState<string[]>(['']);
|
||||||
const [gateInTime, setGateInTime] = useState('');
|
const [gateInTime, setGateInTime] = useState('');
|
||||||
const [tareWeight, setTareWeight] = useState<number | ''>('');
|
const [tareWeight, setTareWeight] = useState<number | ''>('');
|
||||||
const [grossWeight, setGrossWeight] = useState<number | ''>('');
|
const [grossWeight, setGrossWeight] = useState<number | ''>('');
|
||||||
@@ -66,26 +137,32 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (opened) {
|
if (opened) {
|
||||||
setReference(item?.releaseOrderReference ?? '');
|
const inspection = parseInspectionNote(item?.notes);
|
||||||
setTruckPlateNumber('');
|
setReference(item?.releaseOrderReference ?? generateReleaseReference(item));
|
||||||
setTrailerPlateNumber('');
|
setTruckPlateNumber(inspection.truckPlateNumber);
|
||||||
setDriverName('');
|
setTrailerPlateNumber(inspection.trailerPlateNumber);
|
||||||
setDriverLicense('');
|
setDriverName(inspection.driverName);
|
||||||
setDriverPhone('');
|
setDriverLicense(inspection.driverLicense);
|
||||||
setTruckType('');
|
setDriverPhone(inspection.driverPhone);
|
||||||
setContainerNumber('');
|
setTruckType(inspection.truckType);
|
||||||
setGateInTime('');
|
setContainerNumbers(initialContainerNumbers(item, inspection.containerNumber));
|
||||||
setTareWeight('');
|
setGateInTime(inspection.gateInTime);
|
||||||
setGrossWeight('');
|
setTareWeight(inspection.tareWeight);
|
||||||
setNetWeight(item?.weight != null ? Number(item.weight) : '');
|
setGrossWeight(inspection.grossWeight);
|
||||||
setGateOutTime('');
|
setNetWeight(item?.weight == null ? inspection.netWeight : Number(item.weight));
|
||||||
|
setGateOutTime(inspection.gateOutTime);
|
||||||
}
|
}
|
||||||
}, [opened, item]);
|
}, [opened, item]);
|
||||||
|
|
||||||
|
const savedInspection = parseInspectionNote(item?.notes);
|
||||||
|
const isExitStep = savedInspection.tareWeight !== '';
|
||||||
|
const isEntranceLocked = isExitStep;
|
||||||
|
const systemNetWeight = item?.weight == null ? netWeight : Number(item.weight);
|
||||||
const computedNetWeight =
|
const computedNetWeight =
|
||||||
tareWeight !== '' && grossWeight !== '' ? Number((Number(grossWeight) - Number(tareWeight)).toFixed(3)) : null;
|
tareWeight !== '' && grossWeight !== '' ? Number((Number(grossWeight) - Number(tareWeight)).toFixed(3)) : null;
|
||||||
const weightMismatch =
|
const weightMismatch =
|
||||||
computedNetWeight != null && netWeight !== '' && Math.abs(Number(netWeight) - computedNetWeight) > 0.001;
|
computedNetWeight != null && systemNetWeight !== '' && Math.abs(Number(systemNetWeight) - computedNetWeight) > 0.001;
|
||||||
|
const title = isExitStep ? 'Customer truck leaving and exit weighing' : 'Customer truck arrival weighing';
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
@@ -93,11 +170,19 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
|
|||||||
toast({ variant: 'destructive', title: 'Truck plate and driver name are required' });
|
toast({ variant: 'destructive', title: 'Truck plate and driver name are required' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (tareWeight === '' || grossWeight === '') {
|
if (!gateInTime || tareWeight === '') {
|
||||||
toast({ variant: 'destructive', title: 'Tare and gross weight are required' });
|
toast({ variant: 'destructive', title: 'Gate in time and tare weight are required' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (weightMismatch) {
|
if (isExitStep && (!gateOutTime || grossWeight === '')) {
|
||||||
|
toast({ variant: 'destructive', title: 'Gate out time and gross weight are required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isExitStep && systemNetWeight === '') {
|
||||||
|
toast({ variant: 'destructive', title: 'System recorded net weight is missing' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isExitStep && weightMismatch) {
|
||||||
toast({
|
toast({
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
title: 'Weight mismatch',
|
title: 'Weight mismatch',
|
||||||
@@ -105,7 +190,7 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const pdfWindow = window.open('', '_blank');
|
const pdfWindow = isExitStep ? window.open('', '_blank') : null;
|
||||||
try {
|
try {
|
||||||
const released = await releaseMutation.mutateAsync({
|
const released = await releaseMutation.mutateAsync({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
@@ -119,14 +204,22 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
|
|||||||
driverLicense: driverLicense.trim() || undefined,
|
driverLicense: driverLicense.trim() || undefined,
|
||||||
driverPhone: driverPhone.trim() || undefined,
|
driverPhone: driverPhone.trim() || undefined,
|
||||||
truckType: truckType.trim() || undefined,
|
truckType: truckType.trim() || undefined,
|
||||||
containerNumber: containerNumber.trim() || undefined,
|
containerNumber: containerNumbers.map((number) => number.trim()).filter(Boolean).join(', ') || undefined,
|
||||||
gateInTime: toIsoDateTime(gateInTime),
|
gateInTime: toIsoDateTime(gateInTime),
|
||||||
tareWeight: Number(tareWeight),
|
tareWeight: Number(tareWeight),
|
||||||
grossWeight: Number(grossWeight),
|
grossWeight: grossWeight === '' ? undefined : Number(grossWeight),
|
||||||
netWeight: netWeight === '' ? computedNetWeight ?? undefined : Number(netWeight),
|
netWeight: isExitStep && systemNetWeight !== '' ? Number(systemNetWeight) : undefined,
|
||||||
gateOutTime: toIsoDateTime(gateOutTime),
|
gateOutTime: isExitStep ? toIsoDateTime(gateOutTime) : undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
if (!isExitStep) {
|
||||||
|
toast({
|
||||||
|
title: 'Truck arrival saved',
|
||||||
|
description: `${released.releaseOrderReference ?? reference} is ready for exit weighing.`,
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
setDownloading(true);
|
setDownloading(true);
|
||||||
const response = await warehouseService.downloadReleaseDocument(item.id);
|
const response = await warehouseService.downloadReleaseDocument(item.id);
|
||||||
const blob = response.data;
|
const blob = response.data;
|
||||||
@@ -148,20 +241,27 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal opened={opened} onClose={onClose} title="Customer truck arrival and exit weighing" centered size="lg">
|
<Modal opened={opened} onClose={onClose} title={title} centered size="lg">
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Alert icon={<Info size={16} />} color="orange" variant="light">
|
<Alert icon={<Info size={16} />} color="orange" variant="light">
|
||||||
<Text size="sm">
|
{isExitStep ? (
|
||||||
Register the customer truck and driver at arrival, record tare weight, then record gross
|
<Text size="sm">
|
||||||
weight at exit after loading. Gate clearance is blocked when recorded net weight does not
|
Record the truck leaving time and gross weight. The system recorded net weight is locked,
|
||||||
equal gross weight minus tare weight.
|
and the exit paper is generated only when it equals gross weight minus tare weight.
|
||||||
</Text>
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text size="sm">
|
||||||
|
Register the customer truck and driver at arrival, then save gate in time and tare weight.
|
||||||
|
Reopen this form when the truck is leaving to complete the exit weighing.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Alert>
|
</Alert>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Release document reference"
|
label="Release document reference"
|
||||||
placeholder="e.g. REL-2026-001"
|
placeholder="e.g. REL-2026-001"
|
||||||
value={reference}
|
value={reference}
|
||||||
onChange={(e) => setReference(e.currentTarget.value)}
|
onChange={(e) => setReference(e.currentTarget.value)}
|
||||||
|
readOnly={isEntranceLocked}
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
label="Registered first / last-mile truck"
|
label="Registered first / last-mile truck"
|
||||||
@@ -169,6 +269,7 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
|
|||||||
searchable
|
searchable
|
||||||
clearable
|
clearable
|
||||||
data={REGISTERED_FIRST_LAST_MILE_TRUCKS}
|
data={REGISTERED_FIRST_LAST_MILE_TRUCKS}
|
||||||
|
disabled={isEntranceLocked}
|
||||||
value={REGISTERED_FIRST_LAST_MILE_TRUCKS.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null}
|
value={REGISTERED_FIRST_LAST_MILE_TRUCKS.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
const truck = REGISTERED_FIRST_LAST_MILE_TRUCKS.find((row) => row.value === value);
|
const truck = REGISTERED_FIRST_LAST_MILE_TRUCKS.find((row) => row.value === value);
|
||||||
@@ -182,35 +283,53 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
|
|||||||
required
|
required
|
||||||
value={truckPlateNumber}
|
value={truckPlateNumber}
|
||||||
onChange={(e) => setTruckPlateNumber(e.currentTarget.value)}
|
onChange={(e) => setTruckPlateNumber(e.currentTarget.value)}
|
||||||
|
readOnly={isEntranceLocked}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Trailer plate number"
|
label="Trailer plate number"
|
||||||
value={trailerPlateNumber}
|
value={trailerPlateNumber}
|
||||||
onChange={(e) => setTrailerPlateNumber(e.currentTarget.value)}
|
onChange={(e) => setTrailerPlateNumber(e.currentTarget.value)}
|
||||||
|
readOnly={isEntranceLocked}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
<Group grow>
|
<Group grow>
|
||||||
<TextInput label="Driver name" required value={driverName} onChange={(e) => setDriverName(e.currentTarget.value)} />
|
<TextInput label="Driver name" required value={driverName} onChange={(e) => setDriverName(e.currentTarget.value)} readOnly={isEntranceLocked} />
|
||||||
<TextInput label="Driver license" value={driverLicense} onChange={(e) => setDriverLicense(e.currentTarget.value)} />
|
<TextInput label="Driver license" value={driverLicense} onChange={(e) => setDriverLicense(e.currentTarget.value)} readOnly={isEntranceLocked} />
|
||||||
</Group>
|
</Group>
|
||||||
<Group grow>
|
<Group grow>
|
||||||
<TextInput label="Driver phone" value={driverPhone} onChange={(e) => setDriverPhone(e.currentTarget.value)} />
|
<TextInput label="Driver phone" value={driverPhone} onChange={(e) => setDriverPhone(e.currentTarget.value)} readOnly={isEntranceLocked} />
|
||||||
<TextInput label="Truck type" value={truckType} onChange={(e) => setTruckType(e.currentTarget.value)} />
|
<TextInput label="Truck type" value={truckType} onChange={(e) => setTruckType(e.currentTarget.value)} readOnly={isEntranceLocked} />
|
||||||
</Group>
|
</Group>
|
||||||
<Group grow>
|
<Group grow>
|
||||||
<TextInput label="Container number" value={containerNumber} onChange={(e) => setContainerNumber(e.currentTarget.value)} />
|
<Stack gap={6}>
|
||||||
<TextInput label="Gate in time" type="datetime-local" value={gateInTime} onChange={(e) => setGateInTime(e.currentTarget.value)} />
|
<SimpleGrid cols={containerNumbers.length > 1 ? 2 : 1} spacing="sm">
|
||||||
|
{containerNumbers.map((containerNumber, index) => (
|
||||||
|
<TextInput
|
||||||
|
key={index}
|
||||||
|
label={containerNumbers.length > 1 ? `Container number ${index + 1}` : 'Container number'}
|
||||||
|
value={containerNumber}
|
||||||
|
onChange={(e) =>
|
||||||
|
setContainerNumbers((numbers) =>
|
||||||
|
numbers.map((number, numberIndex) => (numberIndex === index ? e.currentTarget.value : number)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
readOnly={isEntranceLocked}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
<TextInput label="Gate in time" type="datetime-local" value={gateInTime} onChange={(e) => setGateInTime(e.currentTarget.value)} readOnly={isEntranceLocked} />
|
||||||
</Group>
|
</Group>
|
||||||
<Group grow>
|
<Group grow>
|
||||||
<NumberInput label="Tare weight (kg)" required min={0} value={tareWeight} onChange={(v) => setTareWeight(v === '' ? '' : Number(v))} />
|
<NumberInput label="Tare weight (kg)" required min={0} value={tareWeight} onChange={(v) => setTareWeight(v === '' ? '' : Number(v))} readOnly={isEntranceLocked} />
|
||||||
<NumberInput label="Gross weight (kg)" required min={0} value={grossWeight} onChange={(v) => setGrossWeight(v === '' ? '' : Number(v))} />
|
<NumberInput label="Gross weight (kg)" required={isExitStep} min={0} value={grossWeight} onChange={(v) => setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep} />
|
||||||
<NumberInput label="Recorded net weight (kg)" min={0} value={netWeight} onChange={(v) => setNetWeight(v === '' ? '' : Number(v))} />
|
<NumberInput label="Recorded net weight (system kg)" min={0} value={systemNetWeight} readOnly />
|
||||||
</Group>
|
</Group>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Text size="sm" c={weightMismatch ? 'red' : 'dimmed'}>
|
<Text size="sm" c={weightMismatch ? 'red' : 'dimmed'}>
|
||||||
Computed net: <b>{computedNetWeight == null ? '-' : `${computedNetWeight.toLocaleString()} kg`}</b>
|
Computed net: <b>{computedNetWeight == null ? '-' : `${computedNetWeight.toLocaleString()} kg`}</b>
|
||||||
</Text>
|
</Text>
|
||||||
<TextInput label="Gate out time" type="datetime-local" value={gateOutTime} onChange={(e) => setGateOutTime(e.currentTarget.value)} />
|
<TextInput label="Gate out time" type="datetime-local" value={gateOutTime} onChange={(e) => setGateOutTime(e.currentTarget.value)} disabled={!isExitStep} />
|
||||||
</Group>
|
</Group>
|
||||||
{weightMismatch && (
|
{weightMismatch && (
|
||||||
<Alert icon={<Scale size={16} />} color="red" variant="light">
|
<Alert icon={<Scale size={16} />} color="red" variant="light">
|
||||||
@@ -225,7 +344,7 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
|
|||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button color="orange" onClick={handleSubmit} loading={releaseMutation.isPending || downloading}>
|
<Button color="orange" onClick={handleSubmit} loading={releaseMutation.isPending || downloading}>
|
||||||
Save Truck Arrival & View Exit Paper
|
{isExitStep ? 'Save Truck Leaving & View Exit Paper' : 'Save Truck Arrival'}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
|
import { useState, type MouseEvent } from 'react';
|
||||||
import { ActionIcon, Badge, Button, Checkbox, Group, Table, Text, Tooltip } from '@mantine/core';
|
import { ActionIcon, Badge, Button, Checkbox, Group, Table, Text, Tooltip } from '@mantine/core';
|
||||||
import { ArrowRightLeft, ClipboardList, Coins, Eye, FileText, History, MapPin } from 'lucide-react';
|
import { ArrowRightLeft, ClipboardList, Coins, Eye, FileText, History, MapPin } from 'lucide-react';
|
||||||
|
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { warehouseService } from '@/services/warehouse.service';
|
||||||
import {
|
import {
|
||||||
getNextInventoryAction,
|
getNextInventoryAction,
|
||||||
type InventoryAction,
|
type InventoryAction,
|
||||||
type WarehouseInventoryItem,
|
type WarehouseInventoryItem,
|
||||||
} from '@/types/warehouse';
|
} from '@/types/warehouse';
|
||||||
import { InventoryStatusBadge } from './badges';
|
import { InventoryStatusBadge } from './badges';
|
||||||
import { formatDate, formatNumber, humanizeEnum } from './options';
|
import { extractErrorMessage, formatDate, formatNumber, humanizeEnum } from './options';
|
||||||
|
import { openPdfBlob } from './pdf';
|
||||||
|
|
||||||
interface WarehouseInventoryTableProps {
|
interface WarehouseInventoryTableProps {
|
||||||
items: WarehouseInventoryItem[];
|
items: WarehouseInventoryItem[];
|
||||||
@@ -46,6 +50,56 @@ const actionColor: Record<InventoryAction, string> = {
|
|||||||
deliver: 'green',
|
deliver: 'green',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const releaseActionLabel = (item: WarehouseInventoryItem) =>
|
||||||
|
item.releaseOrderReference ? 'Truck Leaving' : 'Truck Arrival';
|
||||||
|
|
||||||
|
const noteLineValue = (notes: string | null | undefined, label: string) => {
|
||||||
|
const match = notes?.match(new RegExp(`^${label}:\\s*(.+)$`, 'im'));
|
||||||
|
return match?.[1]?.trim() ?? '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handoverDocumentReference = (item: WarehouseInventoryItem) =>
|
||||||
|
item.handoverDocumentReference ?? noteLineValue(item.notes, 'Handover Reference');
|
||||||
|
|
||||||
|
function GrnDocumentButton({ item }: { item: WarehouseInventoryItem }) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const openDocument = async (event: MouseEvent<HTMLButtonElement>) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (!item.grnNumber) {
|
||||||
|
toast({ variant: 'destructive', title: 'GRN document unavailable', description: 'This item has no GRN number yet.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
const pdfWindow = window.open('', '_blank');
|
||||||
|
try {
|
||||||
|
const response = await warehouseService.downloadGrnDocument(item.id);
|
||||||
|
const opened = openPdfBlob(response.data, `grn-${item.grnNumber}.pdf`, pdfWindow);
|
||||||
|
toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' });
|
||||||
|
} catch (error) {
|
||||||
|
pdfWindow?.close();
|
||||||
|
toast({ variant: 'destructive', title: 'GRN document failed', description: extractErrorMessage(error) });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
variant="subtle"
|
||||||
|
color="teal"
|
||||||
|
leftSection={<FileText size={12} />}
|
||||||
|
disabled={!item.grnNumber}
|
||||||
|
loading={loading}
|
||||||
|
onClick={openDocument}
|
||||||
|
>
|
||||||
|
{item.grnNumber ?? 'No GRN'}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function WarehouseInventoryTable({
|
export function WarehouseInventoryTable({
|
||||||
items,
|
items,
|
||||||
busyId,
|
busyId,
|
||||||
@@ -90,6 +144,7 @@ export function WarehouseInventoryTable({
|
|||||||
</Table.Th>
|
</Table.Th>
|
||||||
)}
|
)}
|
||||||
<Table.Th>Booking</Table.Th>
|
<Table.Th>Booking</Table.Th>
|
||||||
|
<Table.Th>GRN</Table.Th>
|
||||||
<Table.Th>Facility</Table.Th>
|
<Table.Th>Facility</Table.Th>
|
||||||
<Table.Th>Warehouse</Table.Th>
|
<Table.Th>Warehouse</Table.Th>
|
||||||
<Table.Th>Yard</Table.Th>
|
<Table.Th>Yard</Table.Th>
|
||||||
@@ -111,6 +166,7 @@ export function WarehouseInventoryTable({
|
|||||||
item.inspectionStatus === 'PASSED' &&
|
item.inspectionStatus === 'PASSED' &&
|
||||||
Boolean(item.bookingId) &&
|
Boolean(item.bookingId) &&
|
||||||
(!item.booking?.tradeDirection || item.booking.tradeDirection === 'IMPORT');
|
(!item.booking?.tradeDirection || item.booking.tradeDirection === 'IMPORT');
|
||||||
|
const handoverReference = handoverDocumentReference(item);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Table.Tr key={item.id}>
|
<Table.Tr key={item.id}>
|
||||||
@@ -136,6 +192,9 @@ export function WarehouseInventoryTable({
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<GrnDocumentButton item={item} />
|
||||||
|
</Table.Td>
|
||||||
<Table.Td>{item.warehouse?.facility?.name ?? '-'}</Table.Td>
|
<Table.Td>{item.warehouse?.facility?.name ?? '-'}</Table.Td>
|
||||||
<Table.Td>{item.warehouse?.code ?? '-'}</Table.Td>
|
<Table.Td>{item.warehouse?.code ?? '-'}</Table.Td>
|
||||||
<Table.Td>{item.yard?.code ?? '-'}</Table.Td>
|
<Table.Td>{item.yard?.code ?? '-'}</Table.Td>
|
||||||
@@ -170,7 +229,7 @@ export function WarehouseInventoryTable({
|
|||||||
loading={busy}
|
loading={busy}
|
||||||
onClick={() => onAdvance(item, nextAction)}
|
onClick={() => onAdvance(item, nextAction)}
|
||||||
>
|
>
|
||||||
{nextAction === 'release' ? 'Truck Arrival' : humanizeEnum(nextAction.replace(/-/g, '_'))}
|
{nextAction === 'release' ? releaseActionLabel(item) : humanizeEnum(nextAction.replace(/-/g, '_'))}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{item.status === 'READY_FOR_PICKUP' && (
|
{item.status === 'READY_FOR_PICKUP' && (
|
||||||
@@ -224,7 +283,10 @@ export function WarehouseInventoryTable({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{onHandoverDocument && canGenerateHandover && (
|
{onHandoverDocument && canGenerateHandover && (
|
||||||
<Tooltip label="Generate customer handover document" withArrow>
|
<Tooltip
|
||||||
|
label={handoverReference ? `View handover document ${handoverReference}` : 'Generate customer handover document'}
|
||||||
|
withArrow
|
||||||
|
>
|
||||||
<ActionIcon variant="subtle" color="teal" onClick={() => onHandoverDocument(item)}>
|
<ActionIcon variant="subtle" color="teal" onClick={() => onHandoverDocument(item)}>
|
||||||
<FileText size={16} />
|
<FileText size={16} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|||||||
@@ -389,6 +389,7 @@ export const URL_CONSTANTS = {
|
|||||||
MARK_READY_PICKUP: (id: string) => `/warehouse-inventory/${id}/ready-for-pickup`,
|
MARK_READY_PICKUP: (id: string) => `/warehouse-inventory/${id}/ready-for-pickup`,
|
||||||
RELEASE: (id: string) => `/warehouse-inventory/${id}/release`,
|
RELEASE: (id: string) => `/warehouse-inventory/${id}/release`,
|
||||||
RELEASE_DOCUMENT: (id: string) => `/warehouse-inventory/${id}/release-document`,
|
RELEASE_DOCUMENT: (id: string) => `/warehouse-inventory/${id}/release-document`,
|
||||||
|
GRN_DOCUMENT: (id: string) => `/warehouse-inventory/${id}/grn-document`,
|
||||||
HANDOVER_DOCUMENT: (id: string) => `/warehouse-inventory/${id}/handover-document`,
|
HANDOVER_DOCUMENT: (id: string) => `/warehouse-inventory/${id}/handover-document`,
|
||||||
DELIVER: (id: string) => `/warehouse-inventory/${id}/deliver`,
|
DELIVER: (id: string) => `/warehouse-inventory/${id}/deliver`,
|
||||||
// Receive (Import/Export bulk)
|
// Receive (Import/Export bulk)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
|
//export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
|
||||||
|
|
||||||
// export const API_BASE_URL = 'http://localhost:3001';
|
export const API_BASE_URL = 'http://localhost:3001';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* URL that streams an uploaded file through the API by its UUID. Routes the
|
* URL that streams an uploaded file through the API by its UUID. Routes the
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Button, Card } from '@mantine/core';
|
import { useState } from 'react';
|
||||||
import { PackageSearch } from 'lucide-react';
|
import { Button, Card, Group, Modal, Stack } from '@mantine/core';
|
||||||
|
import { PackageSearch, Truck } from 'lucide-react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
import { PageContainer, PageHeader } from '@/components/page';
|
import { PageContainer, PageHeader } from '@/components/page';
|
||||||
@@ -7,6 +8,7 @@ import { WarehouseFlowWorkbench } from '@/components/warehouses';
|
|||||||
|
|
||||||
export default function ExportWarehouseFlowPage() {
|
export default function ExportWarehouseFlowPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [receiveOpen, setReceiveOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
@@ -14,15 +16,41 @@ export default function ExportWarehouseFlowPage() {
|
|||||||
title="Export Operations"
|
title="Export Operations"
|
||||||
subtitle="Manage export receive, terminal inventory, loading readiness, loaded items, and dispatch flow."
|
subtitle="Manage export receive, terminal inventory, loading readiness, loaded items, and dispatch flow."
|
||||||
action={
|
action={
|
||||||
<Button variant="light" leftSection={<PackageSearch size={16} />} onClick={() => navigate('/dashboard/import-warehouse')}>
|
<Group gap="xs">
|
||||||
Import Operations
|
<Button
|
||||||
</Button>
|
fw={700}
|
||||||
|
leftSection={<Truck size={16} />}
|
||||||
|
onClick={() => setReceiveOpen(true)}
|
||||||
|
>
|
||||||
|
Receive for Loading
|
||||||
|
</Button>
|
||||||
|
<Button variant="light" leftSection={<PackageSearch size={16} />} onClick={() => navigate('/dashboard/import-warehouse')}>
|
||||||
|
Import Operations
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<WarehouseFlowWorkbench direction="EXPORT" />
|
<WarehouseFlowWorkbench direction="EXPORT" />
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={receiveOpen}
|
||||||
|
onClose={() => setReceiveOpen(false)}
|
||||||
|
title="Receive for loading"
|
||||||
|
centered
|
||||||
|
size="80rem"
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<WarehouseFlowWorkbench enabled={receiveOpen} direction="EXPORT" />
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button variant="default" onClick={() => setReceiveOpen(false)}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -656,11 +656,11 @@ export const api = {
|
|||||||
({ filter }) => ["warehouse-inventory", "inquiry", filter],
|
({ filter }) => ["warehouse-inventory", "inquiry", filter],
|
||||||
),
|
),
|
||||||
|
|
||||||
eligibleBookings: endpoint<void, EligibleBooking[]>(
|
eligibleBookings: endpoint<{ direction?: 'IMPORT' | 'EXPORT' } | void, EligibleBooking[]>(
|
||||||
"warehouse-inventory",
|
"warehouse-inventory",
|
||||||
"eligible-bookings",
|
"eligible-bookings",
|
||||||
() => warehouseService.eligibleBookings().then((r) => r.data),
|
(input) => warehouseService.eligibleBookings(input?.direction).then((r) => r.data),
|
||||||
() => ["warehouse-inventory", "eligible-bookings"],
|
(input) => ["warehouse-inventory", "eligible-bookings", input?.direction ?? "ALL"],
|
||||||
),
|
),
|
||||||
|
|
||||||
readyToLoadExport: endpoint<void, ReadyToLoadRow[]>(
|
readyToLoadExport: endpoint<void, ReadyToLoadRow[]>(
|
||||||
|
|||||||
@@ -137,6 +137,10 @@ export const warehouseService = {
|
|||||||
apiClient.get<Blob>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RELEASE_DOCUMENT(id), {
|
apiClient.get<Blob>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RELEASE_DOCUMENT(id), {
|
||||||
responseType: 'blob',
|
responseType: 'blob',
|
||||||
}),
|
}),
|
||||||
|
downloadGrnDocument: (id: string) =>
|
||||||
|
apiClient.get<Blob>(URL_CONSTANTS.WAREHOUSE_INVENTORY.GRN_DOCUMENT(id), {
|
||||||
|
responseType: 'blob',
|
||||||
|
}),
|
||||||
downloadHandoverDocument: (id: string) =>
|
downloadHandoverDocument: (id: string) =>
|
||||||
apiClient.get<Blob>(URL_CONSTANTS.WAREHOUSE_INVENTORY.HANDOVER_DOCUMENT(id), {
|
apiClient.get<Blob>(URL_CONSTANTS.WAREHOUSE_INVENTORY.HANDOVER_DOCUMENT(id), {
|
||||||
responseType: 'blob',
|
responseType: 'blob',
|
||||||
|
|||||||
@@ -190,6 +190,7 @@ export interface WarehouseInventoryItem {
|
|||||||
quantity: number;
|
quantity: number;
|
||||||
weight: number;
|
weight: number;
|
||||||
volume: number | null;
|
volume: number | null;
|
||||||
|
grnNumber: string | null;
|
||||||
status: InventoryStatus;
|
status: InventoryStatus;
|
||||||
inspectionStatus: string | null;
|
inspectionStatus: string | null;
|
||||||
arrivedAt: string | null;
|
arrivedAt: string | null;
|
||||||
@@ -203,6 +204,8 @@ export interface WarehouseInventoryItem {
|
|||||||
readyForPickupAt: string | null;
|
readyForPickupAt: string | null;
|
||||||
releaseDate: string | null;
|
releaseDate: string | null;
|
||||||
releaseOrderReference: string | null;
|
releaseOrderReference: string | null;
|
||||||
|
handoverDocumentReference?: string | null;
|
||||||
|
handoverDocumentDate?: string | null;
|
||||||
deliveredAt: string | null;
|
deliveredAt: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
warehouse?: Warehouse | null;
|
warehouse?: Warehouse | null;
|
||||||
@@ -472,6 +475,7 @@ export interface ReadyToLoadRow {
|
|||||||
containerNumber: string | null;
|
containerNumber: string | null;
|
||||||
cargoType: string | null;
|
cargoType: string | null;
|
||||||
weight: number | null;
|
weight: number | null;
|
||||||
|
grnNumber: string | null;
|
||||||
origin: string | null;
|
origin: string | null;
|
||||||
destination: string | null;
|
destination: string | null;
|
||||||
inspectionStatus: string | null;
|
inspectionStatus: string | null;
|
||||||
@@ -564,6 +568,7 @@ export interface ImportUnloadedItem {
|
|||||||
containerNumber: string | null;
|
containerNumber: string | null;
|
||||||
cargoType: string | null;
|
cargoType: string | null;
|
||||||
weight: number | null;
|
weight: number | null;
|
||||||
|
grnNumber: string | null;
|
||||||
trainSchedule: string | null;
|
trainSchedule: string | null;
|
||||||
inspectionStatus: string | null;
|
inspectionStatus: string | null;
|
||||||
pickupOption: string;
|
pickupOption: string;
|
||||||
@@ -571,6 +576,8 @@ export interface ImportUnloadedItem {
|
|||||||
currentStatus: string;
|
currentStatus: string;
|
||||||
releaseDate: string | null;
|
releaseDate: string | null;
|
||||||
releaseOrderReference: string | null;
|
releaseOrderReference: string | null;
|
||||||
|
handoverDocumentReference: string | null;
|
||||||
|
handoverDocumentDate: string | null;
|
||||||
deliveredAt: string | null;
|
deliveredAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
|
//export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
|
||||||
// export const API_BASE_URL = 'http://localhost:3001';
|
export const API_BASE_URL = 'http://localhost:3001';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* URL that streams an uploaded file through the API by its UUID. Routes the
|
* URL that streams an uploaded file through the API by its UUID. Routes the
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
ContractSignButton,
|
ContractSignButton,
|
||||||
bookingIsSignable,
|
bookingIsSignable,
|
||||||
} from "@/pages/bookings/contract/ContractSignButton";
|
} from "@/pages/bookings/contract/ContractSignButton";
|
||||||
|
import { ApproveDeliveryButton } from "@/pages/bookings/delivery/ApproveDeliveryButton";
|
||||||
|
|
||||||
interface BookingRowProps {
|
interface BookingRowProps {
|
||||||
booking: any;
|
booking: any;
|
||||||
@@ -35,6 +36,7 @@ export const BookingRow = memo(function BookingRow({
|
|||||||
// Contract ready for signature → "View & sign" jumps straight to the
|
// Contract ready for signature → "View & sign" jumps straight to the
|
||||||
// full-page contract viewer where the signature flow lives.
|
// full-page contract viewer where the signature flow lives.
|
||||||
const canSign = bookingIsSignable(booking);
|
const canSign = bookingIsSignable(booking);
|
||||||
|
const canApproveDelivery = booking.status === "COMPLETED";
|
||||||
const origin = booking.originYard?.label ?? booking.originYard?.code ?? "—";
|
const origin = booking.originYard?.label ?? booking.originYard?.code ?? "—";
|
||||||
const dest =
|
const dest =
|
||||||
booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—";
|
booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—";
|
||||||
@@ -99,6 +101,12 @@ export const BookingRow = memo(function BookingRow({
|
|||||||
<PayNowButton booking={booking} size="sm" />
|
<PayNowButton booking={booking} size="sm" />
|
||||||
) : canSign ? (
|
) : canSign ? (
|
||||||
<ContractSignButton booking={booking} size="sm" />
|
<ContractSignButton booking={booking} size="sm" />
|
||||||
|
) : canApproveDelivery ? (
|
||||||
|
<ApproveDeliveryButton
|
||||||
|
bookingId={booking.id}
|
||||||
|
size="sm"
|
||||||
|
stopPropagation
|
||||||
|
/>
|
||||||
) : hasInlineAction ? (
|
) : hasInlineAction ? (
|
||||||
<BookingActionButton booking={booking} size="sm" />
|
<BookingActionButton booking={booking} size="sm" />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useFileViewer } from "@/hooks/useFileViewer";
|
|||||||
import { paymentsService, type PaymentMethod } from "@/services/payments.service";
|
import { paymentsService, type PaymentMethod } from "@/services/payments.service";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
|
import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton";
|
||||||
import { ActivityCard } from "./components/ActivityCard";
|
import { ActivityCard } from "./components/ActivityCard";
|
||||||
import { ClearanceCard } from "./components/ClearanceCard";
|
import { ClearanceCard } from "./components/ClearanceCard";
|
||||||
import { ContainersCard } from "./components/ContainersCard";
|
import { ContainersCard } from "./components/ContainersCard";
|
||||||
@@ -72,6 +73,7 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
|||||||
(isGeneralContract
|
(isGeneralContract
|
||||||
? status === "FULLY_EXECUTED"
|
? status === "FULLY_EXECUTED"
|
||||||
: status === "SELECTED_FOR_BATCH");
|
: status === "SELECTED_FOR_BATCH");
|
||||||
|
const canApproveDelivery = status === "COMPLETED";
|
||||||
const showCountdown = canPay && !!booking.paymentDeadline;
|
const showCountdown = canPay && !!booking.paymentDeadline;
|
||||||
const isExpired = status === "EXPIRED";
|
const isExpired = status === "EXPIRED";
|
||||||
const isPendingConsolidation = status === "PENDING_CONSOLIDATION";
|
const isPendingConsolidation = status === "PENDING_CONSOLIDATION";
|
||||||
@@ -93,14 +95,20 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
booking={booking}
|
booking={booking}
|
||||||
actions={
|
actions={
|
||||||
canPay &&
|
(canApproveDelivery || (canPay && !showCountdown)) && (
|
||||||
!showCountdown && (
|
<Group gap={8} wrap="nowrap">
|
||||||
<HeaderButton
|
{canApproveDelivery && (
|
||||||
green
|
<ApproveDeliveryButton bookingId={booking.id} />
|
||||||
icon={<CreditCard size={16} />}
|
)}
|
||||||
label="Pay now"
|
{canPay && !showCountdown && (
|
||||||
onClick={() => setPayModalOpen(true)}
|
<HeaderButton
|
||||||
/>
|
green
|
||||||
|
icon={<CreditCard size={16} />}
|
||||||
|
label="Pay now"
|
||||||
|
onClick={() => setPayModalOpen(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
menuActions={{
|
menuActions={{
|
||||||
@@ -248,4 +256,4 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
|||||||
{viewer}
|
{viewer}
|
||||||
</PageShell>
|
</PageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { Button, type ButtonProps } from "@mantine/core";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { CheckCircle2 } from "lucide-react";
|
||||||
|
import type { MouseEvent } from "react";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
|
||||||
|
type ApproveDeliveryButtonProps = ButtonProps & {
|
||||||
|
bookingId: string;
|
||||||
|
stopPropagation?: boolean;
|
||||||
|
onApproved?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const errorMessage = (error: unknown) => {
|
||||||
|
const data = (error as { response?: { data?: { message?: string | string[] } } })
|
||||||
|
?.response?.data;
|
||||||
|
if (Array.isArray(data?.message)) return data.message.join(", ");
|
||||||
|
if (data?.message) return data.message;
|
||||||
|
return error instanceof Error ? error.message : "Could not approve delivery";
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ApproveDeliveryButton({
|
||||||
|
bookingId,
|
||||||
|
stopPropagation,
|
||||||
|
onApproved,
|
||||||
|
size = "sm",
|
||||||
|
variant = "filled",
|
||||||
|
...props
|
||||||
|
}: ApproveDeliveryButtonProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
...api.bookings.approveDelivery.mutationOptions(),
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success("Delivery approved and handover signed");
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: api.bookings.get.queryKey({ id: bookingId }) }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["companies", "getDashboard"] }),
|
||||||
|
]);
|
||||||
|
onApproved?.();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
const message = errorMessage(error);
|
||||||
|
toast.error(message);
|
||||||
|
if (message.toLowerCase().includes("save your signature")) {
|
||||||
|
navigate("/signature");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
|
||||||
|
if (stopPropagation) event.stopPropagation();
|
||||||
|
mutation.mutate({ id: bookingId });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
{...props}
|
||||||
|
size={size}
|
||||||
|
variant={variant}
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={<CheckCircle2 size={16} />}
|
||||||
|
loading={mutation.isPending}
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
Approve delivery
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
} from "@/types/fileUploadSettings";
|
} from "@/types/fileUploadSettings";
|
||||||
import {
|
import {
|
||||||
bookingsService,
|
bookingsService,
|
||||||
|
type ApproveDeliveryResponse,
|
||||||
BookingListFilter,
|
BookingListFilter,
|
||||||
CreateBookingPayload,
|
CreateBookingPayload,
|
||||||
GeneratePriceResponse,
|
GeneratePriceResponse,
|
||||||
@@ -297,6 +298,12 @@ export const api = {
|
|||||||
({ orderId }) => bookingsService.checkPayment(orderId),
|
({ orderId }) => bookingsService.checkPayment(orderId),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
approveDelivery: endpoint<{ id: string }, ApproveDeliveryResponse>(
|
||||||
|
"bookings",
|
||||||
|
"approveDelivery",
|
||||||
|
({ id }) => bookingsService.approveDelivery(id),
|
||||||
|
),
|
||||||
|
|
||||||
getBookableSchedules: endpoint<
|
getBookableSchedules: endpoint<
|
||||||
{ originYardId?: string; destinationYardId?: string },
|
{ originYardId?: string; destinationYardId?: string },
|
||||||
Freight.BookableScheduleItem[]
|
Freight.BookableScheduleItem[]
|
||||||
|
|||||||
Reference in New Issue
Block a user