This commit is contained in:
Roba Boru
2026-06-27 07:16:31 +03:00
8 changed files with 932 additions and 70 deletions

View File

@@ -45,7 +45,9 @@ export class FirstMileService {
* unknown or the booking has not reached PAID status.
*/
async acceptBooking(bookingId: string): Promise<FirstMile | null> {
const booking = await this.bookingsRepository.findById(bookingId);
const booking = await this.bookingsRepository.findById(bookingId, {
relations: { serviceType: true },
});
if (!booking) {
return null;
@@ -55,6 +57,10 @@ export class FirstMileService {
return null;
}
if (!this.bookingRequestsFirstMile(booking)) {
return null;
}
return this.create({
bookingId: booking.id,
advancedPayment: 0,
@@ -62,7 +68,11 @@ export class FirstMileService {
}
async acceptBookingByReference(bookingReference: string): Promise<FirstMile | null> {
const booking = await this.bookingsRepository.findByReference(bookingReference);
const [booking] = await this.bookingsRepository.findAll({
where: { reference: bookingReference },
relations: { serviceType: true },
take: 1,
});
if (!booking) {
return null;
@@ -72,6 +82,10 @@ export class FirstMileService {
return null;
}
if (!this.bookingRequestsFirstMile(booking)) {
return null;
}
return this.create({
bookingId: booking.id,
advancedPayment: 0,
@@ -131,6 +145,11 @@ export class FirstMileService {
}
async create(dto: CreateFirstMileDto): Promise<FirstMile> {
const existing = await this.findByBookingId(dto.bookingId);
if (existing) {
return existing;
}
return this.firstMileRepository.create({
bookingId: dto.bookingId,
status: dto.status ?? 'READY_TO_TRANSIT',
@@ -142,6 +161,25 @@ export class FirstMileService {
});
}
private async findByBookingId(bookingId: string): Promise<FirstMile | null> {
const [records] = await this.firstMileRepository.findAndCount({
where: { bookingId },
relations: {
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true },
vehicle: true,
},
take: 1,
});
return records[0] ?? null;
}
private bookingRequestsFirstMile(booking: {
firstMilePickupAddress?: string | null;
serviceType?: { includesFirstMile?: boolean | null } | null;
}): boolean {
return Boolean(booking.firstMilePickupAddress?.trim() || booking.serviceType?.includesFirstMile);
}
async update(id: string, dto: UpdateFirstMileDto): Promise<FirstMile> {
const existing = await this.findById(id);

View File

@@ -344,10 +344,10 @@ export class PaymentService {
? { paymentStatus: "PAID", status: "CONTRACT_ACTIVE", expiresAt: contractExpiresAt }
: { paymentStatus: "PAID", status: "PAID" },
);
await this.firstMileService.acceptBooking(input.bookingId);
});
await this.firstMileService.acceptBooking(input.bookingId);
if (isGeneralContract) {
this.logger.log(
`General contract ${booking?.reference ?? input.bookingId} ACTIVE — ordering open until ${contractExpiresAt?.toISOString()}`,

View File

@@ -1,5 +1,154 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ArrayNotEmpty, IsArray, IsIn, IsOptional, IsString, IsUUID } from 'class-validator';
import { ArrayNotEmpty, IsArray, IsIn, IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator';
export class TruckEntranceDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
ownerName?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
consigneeDetails?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
edrDigitalBookingId?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
tin?: string;
@ApiProperty()
@IsString()
truckPlateNumber!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
trailerPlateNumber?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
assignedEquipmentNumber?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
customsSealNumber?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
declarationNumber?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
incoterms?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
hsCodes?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
itemCode?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
itemDescription?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
packagingType?: string;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(0)
unitCount?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(0)
grossWeightKg?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(0)
netWeightKg?: number;
@ApiPropertyOptional()
@IsOptional()
@IsString()
volumeDimensions?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
conditionAtReceipt?: string;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(0)
damagedRejectedQuantity?: number;
@ApiPropertyOptional()
@IsOptional()
@IsString()
warehouseCodeLocation?: string;
@ApiProperty()
@IsString()
driverName!: string;
@ApiProperty()
@IsString()
driverPhone!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
driverLicenseNumber?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
truckType?: string;
@ApiProperty()
@IsNumber()
@Min(0)
entranceTareWeightKg!: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(0)
exitTareWeightKg?: number;
@ApiPropertyOptional()
@IsOptional()
@IsString()
driverSignatoryName?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
warehouseManagerName?: string;
}
/** Bulk-receive eligible PAID bookings into a warehouse location, by trade direction. */
export class BulkReceiveDto {
@@ -25,6 +174,9 @@ export class BulkReceiveDto {
@IsUUID('all', { each: true })
bookingIds!: string[];
@ApiProperty({ type: TruckEntranceDto })
truckEntrance!: TruckEntranceDto;
@ApiPropertyOptional()
@IsOptional()
@IsString()

View File

@@ -1,5 +1,6 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator';
import { TruckEntranceDto } from './bulk-receive.dto';
export class ReceiveWarehouseInventoryDto {
@ApiProperty({ format: 'uuid' })
@@ -55,6 +56,9 @@ export class ReceiveWarehouseInventoryDto {
@IsString()
notes?: string;
@ApiProperty({ type: TruckEntranceDto })
truckEntrance!: TruckEntranceDto;
@ApiPropertyOptional()
@IsOptional()
@IsString()

View File

@@ -7,7 +7,7 @@ import { InterchangeDocumentsService } from '../interchange-documents/interchang
import type { InterchangeDocument } from '../interchange-documents/entities/interchange-document.entity';
import { LastMileService } from '../last-mile/last-mile.service';
import { BulkInspectDto } from './dto/bulk-inspect.dto';
import { BulkReceiveDto } from './dto/bulk-receive.dto';
import { BulkReceiveDto, TruckEntranceDto } from './dto/bulk-receive.dto';
import { DeliverInventoryDto } from './dto/deliver-inventory.dto';
import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto';
import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto';
@@ -199,12 +199,18 @@ export interface EligibleBookingRow {
weight: string | null;
paymentStatus: string;
status: string;
hasFirstMile: boolean;
firstMileRequestId: string | null;
firstMileStatus: string | null;
firstMileVehicleId: string | null;
firstMileTruckPlateNumber: string | null;
firstMileTrailerPlateNumber: string | null;
}
export interface BulkReceiveResult {
receivedCount: number;
skippedCount: number;
results: { bookingId: string; status: string; inventoryId?: string; reason?: string }[];
results: { bookingId: string; status: string; inventoryId?: string; grnNumber?: string; reason?: string }[];
}
export interface LoadPassedExportResult {
@@ -646,13 +652,29 @@ export class WarehouseInventoryService {
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS "cargo",
b.cargo_total_weight_vgm AS "weight",
b.payment_status AS "paymentStatus",
b.status AS "status"
b.status AS "status",
(NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL
OR COALESCE(st.includes_first_mile, false)) AS "hasFirstMile",
fm.id AS "firstMileRequestId",
fm.status AS "firstMileStatus",
fm.vehicle_id AS "firstMileVehicleId",
v.plate_number AS "firstMileTruckPlateNumber",
v.trailer_plate_no AS "firstMileTrailerPlateNumber"
FROM freight.bookings b
LEFT JOIN freight.companies company ON company.id = b.company_id
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
LEFT JOIN freight.service_types st ON st.id = b.service_type_id
LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
LEFT JOIN LATERAL (
SELECT first_mile.id, first_mile.status, first_mile.vehicle_id
FROM freight.first_mile first_mile
WHERE first_mile.booking_id = b.id AND first_mile.deleted_at IS NULL
ORDER BY first_mile.created_at DESC
LIMIT 1
) fm ON true
LEFT JOIN freight.vehicles v ON v.id = fm.vehicle_id
WHERE b.deleted_at IS NULL
AND b.payment_status = 'PAID'
AND inv.id IS NULL
@@ -673,6 +695,7 @@ export class WarehouseInventoryService {
/** Bulk-receive eligible PAID bookings into a location. Skips duplicates / wrong direction. */
async bulkReceive(dto: BulkReceiveDto): Promise<BulkReceiveResult> {
const result: BulkReceiveResult = { receivedCount: 0, skippedCount: 0, results: [] };
this.assertTruckEntrance(dto.truckEntrance);
await this.dataSource.transaction(async (manager) => {
await this.validateLocation(manager, {
@@ -689,10 +712,22 @@ export class WarehouseInventoryService {
const [booking] = await manager.query(
`SELECT b.payment_status AS "paymentStatus", b.cargo_total_weight_vgm AS "weight",
oy.country AS "originCountry", dy.country AS "destinationCountry"
oy.country AS "originCountry", dy.country AS "destinationCountry",
(NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL
OR COALESCE(st.includes_first_mile, false)) AS "hasFirstMile",
fm.id AS "firstMileRequestId",
fm.status AS "firstMileStatus"
FROM freight.bookings b
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
LEFT JOIN freight.service_types st ON st.id = b.service_type_id
LEFT JOIN LATERAL (
SELECT first_mile.id, first_mile.status
FROM freight.first_mile first_mile
WHERE first_mile.booking_id = b.id AND first_mile.deleted_at IS NULL
ORDER BY first_mile.created_at DESC
LIMIT 1
) fm ON true
WHERE b.id = $1 AND b.deleted_at IS NULL LIMIT 1`,
[bookingId],
);
@@ -707,10 +742,28 @@ export class WarehouseInventoryService {
skip(`Booking route is ${bookingDirection}, not ${dto.direction}`);
continue;
}
if (dto.direction === 'EXPORT' && booking.hasFirstMile) {
if (!booking.firstMileRequestId) {
skip('First-mile request not created');
continue;
}
if (booking.firstMileStatus !== 'RECEIVED_TO_PORT') {
skip('First-mile truck has not arrived');
continue;
}
}
const existing = await manager.getRepository(WarehouseInventory).findOne({ where: { bookingId } });
if (existing) { skip('Already received'); continue; }
const now = new Date();
const grnNumber = this.generateGrnNumber(dto.direction, bookingId, now);
const receiveNote = this.buildReceiveNote({
grnNumber,
direction: dto.direction,
notes: `Bulk received (${dto.direction})`,
truckEntrance: dto.truckEntrance,
});
const saved = await manager.getRepository(WarehouseInventory).save(
manager.getRepository(WarehouseInventory).create({
warehouseId: dto.warehouseId,
@@ -720,8 +773,8 @@ export class WarehouseInventoryService {
quantity: 1,
weight: Number(booking.weight) || 0,
status: 'RECEIVED',
arrivedAt: new Date(),
notes: `Bulk received (${dto.direction})`,
arrivedAt: now,
notes: receiveNote,
}),
);
@@ -730,14 +783,14 @@ export class WarehouseInventoryService {
activityType: 'INVENTORY_RECEIVED',
inventoryId: saved.id,
warehouseId: dto.warehouseId,
description: `Bulk received ${dto.direction} booking`,
description: `GRN ${grnNumber}: bulk received ${dto.direction} booking via truck ${dto.truckEntrance.truckPlateNumber}`,
performedBy: dto.performedBy,
},
manager,
);
result.receivedCount += 1;
result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id });
result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id, grnNumber });
}
});
@@ -1430,9 +1483,11 @@ export class WarehouseInventoryService {
}
async receive(dto: ReceiveWarehouseInventoryDto): Promise<WarehouseInventory> {
this.assertTruckEntrance(dto.truckEntrance);
const weight = Number(dto.weight) || 0;
const volume = Number(dto.volume) || 0;
const containerCount = dto.containerId ? Math.round(Number(dto.quantity) || 0) : 0;
const bookingDirection = dto.bookingId ? await this.getBookingDirection(dto.bookingId) : null;
const id = await this.dataSource.transaction(async (manager) => {
const { warehouse, yard, zone } = await this.validateLocation(manager, dto);
@@ -1446,6 +1501,12 @@ export class WarehouseInventoryService {
this.assertCapacity('Zone', zone, weight, volume, containerCount);
const now = new Date();
const grnNumber = this.generateGrnNumber(bookingDirection ?? 'WH', dto.bookingId ?? 'MANUAL', now);
const receiveNote = this.buildReceiveNote({
grnNumber,
notes: dto.notes?.trim() || 'Single booking received',
truckEntrance: dto.truckEntrance,
});
const saved = await manager.getRepository(WarehouseInventory).save(
manager.getRepository(WarehouseInventory).create({
warehouseId: dto.warehouseId,
@@ -1460,7 +1521,7 @@ export class WarehouseInventoryService {
volume: dto.volume ?? null,
status: 'RECEIVED',
arrivedAt: now,
notes: dto.notes?.trim() ?? null,
notes: receiveNote,
}),
);
@@ -1471,7 +1532,7 @@ export class WarehouseInventoryService {
activityType: 'INVENTORY_RECEIVED',
inventoryId: saved.id,
warehouseId: dto.warehouseId,
description: `Received ${weight}kg at warehouse location`,
description: `GRN ${grnNumber}: received ${weight}kg via truck ${dto.truckEntrance.truckPlateNumber}`,
performedBy: dto.performedBy,
},
manager,
@@ -2349,26 +2410,28 @@ export class WarehouseInventoryService {
<meta charset="utf-8" />
<title>Warehouse Gate Clearance / Release Order</title>
<style>
body { font-family: "Times New Roman", Georgia, serif; color: #111827; margin: 0; }
.doc { padding: 10px 8px 0; position: relative; }
.top { display: grid; grid-template-columns: 1fr auto; gap: 24px; border-bottom: 2px solid #14532d; padding-bottom: 14px; }
.brand { font-size: 13px; color: #14532d; text-transform: uppercase; letter-spacing: .1em; font-weight: 700; }
h1 { margin: 7px 0 0; font-size: 27px; line-height: 1.1; text-transform: uppercase; letter-spacing: .03em; }
.subtitle { margin-top: 6px; font-size: 12px; color: #475569; text-transform: uppercase; letter-spacing: .08em; }
.ref { text-align: right; font-size: 12px; color: #475569; min-width: 210px; }
.ref strong { display: block; color: #111827; font-size: 17px; margin: 4px 0 8px; }
.seal { position: absolute; right: 8px; top: -42px; width: 112px; height: 112px; border: 4px double #14532d; border-radius: 999px; color: #14532d; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 15px; line-height: 1.15; transform: rotate(-13deg); opacity: .86; text-transform: uppercase; }
.seal::before { content: ""; position: absolute; inset: 10px; border: 1px solid #14532d; border-radius: inherit; }
.notice { margin: 20px 154px 18px 0; padding: 13px 15px; background: #f0fdf4; border: 1px solid #86efac; border-left: 5px solid #14532d; font-size: 13px; line-height: 1.45; }
.section-title { margin: 18px 0 8px; font-size: 13px; font-weight: 800; color: #14532d; text-transform: uppercase; letter-spacing: .08em; }
* { 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; }
.doc { position: relative; padding: 0; }
.top { display: grid; grid-template-columns: 1fr 190px; gap: 26px; border-top: 5px solid #2a2a2a; padding-top: 18px; }
.brand { font-size: 12px; color: #064c27; text-transform: uppercase; letter-spacing: .13em; font-weight: 800; }
h1 { margin: 8px 0 0; max-width: 360px; font-size: 29px; line-height: .98; text-transform: uppercase; letter-spacing: .02em; }
.subtitle { margin-top: 12px; font-size: 11px; color: #3d516a; text-transform: uppercase; letter-spacing: .14em; }
.ref { text-align: right; font-size: 11px; color: #334155; padding-top: 8px; }
.ref strong { display: block; color: #061323; font-size: 18px; margin: 5px 0 8px; letter-spacing: .02em; }
.rule { height: 3px; background: #064c27; margin: 16px 0 22px; }
.notice { width: 74%; margin: 0 0 18px; padding: 13px 18px; background: #f3fff6; border: 1px solid #61d98b; border-left: 5px solid #16743d; font-size: 13px; line-height: 1.45; }
.section-title { margin: 18px 0 8px; font-size: 13px; font-weight: 800; color: #064c27; text-transform: uppercase; letter-spacing: .12em; }
table { width: 100%; border-collapse: collapse; }
th { width: 31%; text-align: left; color: #334155; background: #f8fafc; font-weight: 700; }
th, td { border: 1px solid #cbd5e1; padding: 8px 10px; font-size: 12.5px; vertical-align: top; }
.clause { margin-top: 16px; border: 1px solid #cbd5e1; padding: 12px 14px; font-size: 12.5px; line-height: 1.45; }
.signatures { display: grid; grid-template-columns: 1fr 1fr; gap: 30px; margin-top: 42px; }
.officer-signature { position: relative; min-height: 98px; padding-right: 132px; }
.line { border-top: 1px solid #111827; padding-top: 8px; font-size: 12px; color: #334155; }
.footer { margin-top: 22px; border-top: 1px solid #cbd5e1; padding-top: 9px; font-size: 10.5px; color: #475569; line-height: 1.45; }
th { width: 31%; text-align: left; color: #0f2744; background: #f8fafc; font-weight: 800; }
th, td { border: 1px solid #b9c7d8; padding: 8px 10px; font-size: 12.2px; vertical-align: top; }
.clause { margin-top: 14px; border: 1px solid #b9c7d8; padding: 12px 15px; font-size: 12.2px; line-height: 1.45; }
.signatures { display: grid; grid-template-columns: 1fr 96px 1.45fr; gap: 22px; align-items: start; margin-top: 42px; }
.line { border-top: 1.4px solid #061323; padding-top: 7px; font-size: 10.8px; color: #24384f; min-height: 42px; }
.seal { width: 96px; height: 96px; margin: -28px auto 0; border: 3px double #17633a; border-radius: 999px; color: #17633a; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 14px; line-height: 1.05; transform: rotate(-17deg); text-transform: uppercase; }
.seal::before { content: ""; position: absolute; width: 78px; height: 78px; border: 1px solid #17633a; border-radius: 999px; }
.seal span { position: relative; }
</style>
</head>
<body>
@@ -2385,6 +2448,7 @@ export class WarehouseInventoryService {
Issued: ${esc(issuedAt)}
</div>
</div>
<div class="rule"></div>
<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.
</div>
@@ -2400,15 +2464,10 @@ export class WarehouseInventoryService {
cargo details, clearance status, and payment records before permitting exit from the warehouse premises.
</div>
<div class="signatures">
<div class="officer-signature">
<div class="seal">EDR<br />Warehouse<br />Cleared</div>
<div class="line">Officer in charge name / signature / date</div>
</div>
<div class="line">Officer in charge name / signature / date</div>
<div class="seal"><span>EDR<br />Warehouse<br />Cleared</span></div>
<div class="line">Customer or driver name / signature / date</div>
</div>
<div class="footer">
Present this original release order at the warehouse gate. This document is valid only for the booking/goods stated above and must be retained or recorded by gate operations according to warehouse procedure.
</div>
</div>
</body>
</html>`;
@@ -2448,6 +2507,71 @@ export class WarehouseInventoryService {
return trimmed ? `${trimmed}\n${note}` : note;
}
private assertTruckEntrance(truckEntrance?: TruckEntranceDto): void {
if (!truckEntrance?.truckPlateNumber?.trim()) {
throw new BadRequestException('Truck plate number is required for entrance registration');
}
if (!truckEntrance.driverName?.trim()) {
throw new BadRequestException('Driver name is required for entrance registration');
}
if (!truckEntrance.driverPhone?.trim()) {
throw new BadRequestException('Driver phone is required for entrance registration');
}
if (truckEntrance.entranceTareWeightKg === undefined || Number(truckEntrance.entranceTareWeightKg) < 0) {
throw new BadRequestException('Entrance tare weight is required for entrance registration');
}
}
private generateGrnNumber(direction: string, referenceId: string, date: Date): string {
const stamp = date.toISOString().slice(0, 10).replace(/-/g, '');
const suffix = referenceId.replace(/-/g, '').slice(0, 8).toUpperCase();
return `GRN-${direction.toUpperCase()}-${stamp}-${suffix}`;
}
private buildReceiveNote(input: {
grnNumber: string;
direction?: string | null;
notes?: string | null;
truckEntrance: TruckEntranceDto;
}): string {
const truck = input.truckEntrance;
const rows = [
`GRN Number: ${input.grnNumber}`,
input.direction ? `Direction: ${input.direction}` : null,
truck.ownerName ? `Owner / Shipper: ${truck.ownerName}` : null,
truck.consigneeDetails ? `Consignee: ${truck.consigneeDetails}` : null,
truck.edrDigitalBookingId ? `EDR Digital Booking ID: ${truck.edrDigitalBookingId}` : null,
truck.tin ? `TIN: ${truck.tin}` : null,
`Truck Plate: ${truck.truckPlateNumber}`,
truck.trailerPlateNumber ? `Trailer Plate: ${truck.trailerPlateNumber}` : null,
truck.assignedEquipmentNumber ? `Assigned Wagon / Container: ${truck.assignedEquipmentNumber}` : null,
truck.customsSealNumber ? `Customs Seal Number: ${truck.customsSealNumber}` : null,
truck.truckType ? `Truck Type: ${truck.truckType}` : null,
`Driver: ${truck.driverName}`,
`Driver Phone: ${truck.driverPhone}`,
truck.driverLicenseNumber ? `Driver License: ${truck.driverLicenseNumber}` : null,
`Entrance Tare Weight: ${Number(truck.entranceTareWeightKg)} kg`,
truck.exitTareWeightKg !== undefined ? `Exit Tare Weight: ${Number(truck.exitTareWeightKg)} kg` : null,
truck.declarationNumber ? `Declaration / Bill of Entry: ${truck.declarationNumber}` : null,
truck.incoterms ? `Incoterms: ${truck.incoterms}` : null,
truck.hsCodes ? `HS Codes: ${truck.hsCodes}` : null,
truck.itemCode ? `Item Code: ${truck.itemCode}` : null,
truck.itemDescription ? `Item Description: ${truck.itemDescription}` : null,
truck.packagingType ? `Packaging Type: ${truck.packagingType}` : null,
truck.unitCount !== undefined ? `Unit Count: ${Number(truck.unitCount)}` : null,
truck.grossWeightKg !== undefined ? `Gross Weight: ${Number(truck.grossWeightKg)} kg` : null,
truck.netWeightKg !== undefined ? `Net Weight: ${Number(truck.netWeightKg)} kg` : null,
truck.volumeDimensions ? `Volume / Dimensions: ${truck.volumeDimensions}` : null,
truck.conditionAtReceipt ? `Condition at Receipt: ${truck.conditionAtReceipt}` : null,
truck.damagedRejectedQuantity !== undefined ? `Damaged / Rejected Quantity: ${Number(truck.damagedRejectedQuantity)}` : null,
truck.warehouseCodeLocation ? `Warehouse Code / Location: ${truck.warehouseCodeLocation}` : null,
truck.driverSignatoryName ? `Driver Signatory: ${truck.driverSignatoryName}` : null,
truck.warehouseManagerName ? `EDR Warehouse Manager: ${truck.warehouseManagerName}` : null,
input.notes?.trim() ? `Remarks: ${input.notes.trim()}` : null,
];
return rows.filter(Boolean).join('\n');
}
private async getInventoryAllocationCriteria(item: WarehouseInventory): Promise<InventoryAllocationCriteria> {
const fallbackFreightType = item.containerId ? 'CONTAINER' : item.cargoId ? 'BULK' : null;

View File

@@ -18,16 +18,20 @@ import {
} from '@mantine/core';
import { ChevronDown, ChevronRight, ClipboardCheck, Info, PackageSearch, Train, Truck } from 'lucide-react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { api } from '@/services/api';
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
import { useToast } from '@/hooks/use-toast';
import { firstMileService } from '@/services/first-mile.service';
import type {
EligibleBooking,
ImportTrain,
ImportTrainItem,
ImportUnloadedItem,
ReadyToLoadRow,
ReceiveInventoryPayload,
TruckEntrancePayload,
} from '@/types/warehouse';
import { BookingSelect } from './BookingSelect';
import { InspectionReportModal } from './InspectionReportModal';
@@ -49,6 +53,305 @@ interface Location {
zoneId: string;
}
interface TruckEntranceFormState {
ownerName: string;
consigneeDetails: string;
edrDigitalBookingId: string;
tin: string;
truckPlateNumber: string;
trailerPlateNumber: string;
assignedEquipmentNumber: string;
customsSealNumber: string;
declarationNumber: string;
incoterms: string;
hsCodes: string;
itemCode: string;
itemDescription: string;
packagingType: string;
unitCount: number | '';
grossWeightKg: number | '';
netWeightKg: number | '';
volumeDimensions: string;
conditionAtReceipt: string;
damagedRejectedQuantity: number | '';
warehouseCodeLocation: string;
driverName: string;
driverPhone: string;
driverLicenseNumber: string;
truckType: string;
entranceTareWeightKg: number | '';
exitTareWeightKg: number | '';
driverSignatoryName: string;
warehouseManagerName: string;
}
const emptyTruckEntrance = (): TruckEntranceFormState => ({
ownerName: '',
consigneeDetails: '',
edrDigitalBookingId: '',
tin: '',
truckPlateNumber: '',
trailerPlateNumber: '',
assignedEquipmentNumber: '',
customsSealNumber: '',
declarationNumber: '',
incoterms: '',
hsCodes: '',
itemCode: '',
itemDescription: '',
packagingType: '',
unitCount: '',
grossWeightKg: '',
netWeightKg: '',
volumeDimensions: '',
conditionAtReceipt: '',
damagedRejectedQuantity: '',
warehouseCodeLocation: '',
driverName: '',
driverPhone: '',
driverLicenseNumber: '',
truckType: '',
entranceTareWeightKg: '',
exitTareWeightKg: '',
driverSignatoryName: '',
warehouseManagerName: '',
});
const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayload => ({
ownerName: form.ownerName.trim() || undefined,
consigneeDetails: form.consigneeDetails.trim() || undefined,
edrDigitalBookingId: form.edrDigitalBookingId.trim() || undefined,
tin: form.tin.trim() || undefined,
truckPlateNumber: form.truckPlateNumber.trim(),
trailerPlateNumber: form.trailerPlateNumber.trim() || undefined,
assignedEquipmentNumber: form.assignedEquipmentNumber.trim() || undefined,
customsSealNumber: form.customsSealNumber.trim() || undefined,
declarationNumber: form.declarationNumber.trim() || undefined,
incoterms: form.incoterms.trim() || undefined,
hsCodes: form.hsCodes.trim() || undefined,
itemCode: form.itemCode.trim() || undefined,
itemDescription: form.itemDescription.trim() || undefined,
packagingType: form.packagingType.trim() || undefined,
unitCount: form.unitCount === '' ? undefined : Number(form.unitCount),
grossWeightKg: form.grossWeightKg === '' ? undefined : Number(form.grossWeightKg),
netWeightKg: form.netWeightKg === '' ? undefined : Number(form.netWeightKg),
volumeDimensions: form.volumeDimensions.trim() || undefined,
conditionAtReceipt: form.conditionAtReceipt.trim() || undefined,
damagedRejectedQuantity: form.damagedRejectedQuantity === '' ? undefined : Number(form.damagedRejectedQuantity),
warehouseCodeLocation: form.warehouseCodeLocation.trim() || undefined,
driverName: form.driverName.trim(),
driverPhone: form.driverPhone.trim(),
driverLicenseNumber: form.driverLicenseNumber.trim() || undefined,
truckType: form.truckType.trim() || undefined,
entranceTareWeightKg: Number(form.entranceTareWeightKg),
exitTareWeightKg: form.exitTareWeightKg === '' ? undefined : Number(form.exitTareWeightKg),
driverSignatoryName: form.driverSignatoryName.trim() || undefined,
warehouseManagerName: form.warehouseManagerName.trim() || undefined,
});
function TruckEntranceFields({
value,
onChange,
}: {
value: TruckEntranceFormState;
onChange: (next: TruckEntranceFormState) => void;
}) {
return (
<Stack gap="sm">
<Text size="sm" fw={600}>Customer and cargo ownership</Text>
<Group grow>
<TextInput
label="Owner's name"
value={value.ownerName}
onChange={(e) => onChange({ ...value, ownerName: e.currentTarget.value })}
/>
<TextInput
label="Consignee details"
value={value.consigneeDetails}
onChange={(e) => onChange({ ...value, consigneeDetails: e.currentTarget.value })}
/>
</Group>
<Group grow>
<TextInput
label="EDR digital booking ID"
value={value.edrDigitalBookingId}
onChange={(e) => onChange({ ...value, edrDigitalBookingId: e.currentTarget.value })}
/>
<TextInput
label="TIN"
value={value.tin}
onChange={(e) => onChange({ ...value, tin: e.currentTarget.value })}
/>
</Group>
<Text size="sm" fw={600} mt="xs">Transport and equipment tracking</Text>
<Group grow>
<TextInput
label="Truck plate number"
required
value={value.truckPlateNumber}
onChange={(e) => onChange({ ...value, truckPlateNumber: e.currentTarget.value })}
/>
<TextInput
label="Trailer plate number"
value={value.trailerPlateNumber}
onChange={(e) => onChange({ ...value, trailerPlateNumber: e.currentTarget.value })}
/>
</Group>
<Group grow>
<TextInput
label="Assigned wagon / container number"
value={value.assignedEquipmentNumber}
onChange={(e) => onChange({ ...value, assignedEquipmentNumber: e.currentTarget.value })}
/>
<TextInput
label="Customs seal number"
value={value.customsSealNumber}
onChange={(e) => onChange({ ...value, customsSealNumber: e.currentTarget.value })}
/>
</Group>
<Group grow>
<TextInput
label="Driver name"
required
value={value.driverName}
onChange={(e) => onChange({ ...value, driverName: e.currentTarget.value })}
/>
<TextInput
label="Driver phone"
required
value={value.driverPhone}
onChange={(e) => onChange({ ...value, driverPhone: e.currentTarget.value })}
/>
</Group>
<Group grow>
<TextInput
label="Driver license number"
value={value.driverLicenseNumber}
onChange={(e) => onChange({ ...value, driverLicenseNumber: e.currentTarget.value })}
/>
<TextInput
label="Truck type"
value={value.truckType}
onChange={(e) => onChange({ ...value, truckType: e.currentTarget.value })}
/>
</Group>
<Group grow>
<NumberInput
label="Entrance tare weight (kg)"
required
min={0}
value={value.entranceTareWeightKg}
onChange={(v) => onChange({ ...value, entranceTareWeightKg: v === '' ? '' : Number(v) })}
/>
<NumberInput
label="Exit tare weight (kg)"
min={0}
value={value.exitTareWeightKg}
onChange={(v) => onChange({ ...value, exitTareWeightKg: v === '' ? '' : Number(v) })}
/>
</Group>
<Text size="sm" fw={600} mt="xs">Customs and compliance</Text>
<Group grow>
<TextInput
label="Declaration / Bill of Entry number"
value={value.declarationNumber}
onChange={(e) => onChange({ ...value, declarationNumber: e.currentTarget.value })}
/>
<TextInput
label="Incoterms"
value={value.incoterms}
onChange={(e) => onChange({ ...value, incoterms: e.currentTarget.value })}
/>
</Group>
<TextInput
label="HS codes"
value={value.hsCodes}
onChange={(e) => onChange({ ...value, hsCodes: e.currentTarget.value })}
/>
<Text size="sm" fw={600} mt="xs">Physical cargo specifications</Text>
<Group grow>
<TextInput
label="Item code"
value={value.itemCode}
onChange={(e) => onChange({ ...value, itemCode: e.currentTarget.value })}
/>
<TextInput
label="Item description"
value={value.itemDescription}
onChange={(e) => onChange({ ...value, itemDescription: e.currentTarget.value })}
/>
</Group>
<Group grow>
<TextInput
label="Packaging type"
value={value.packagingType}
onChange={(e) => onChange({ ...value, packagingType: e.currentTarget.value })}
/>
<NumberInput
label="Unit count"
min={0}
value={value.unitCount}
onChange={(v) => onChange({ ...value, unitCount: v === '' ? '' : Number(v) })}
/>
</Group>
<Group grow>
<NumberInput
label="Gross weight (kg)"
min={0}
value={value.grossWeightKg}
onChange={(v) => onChange({ ...value, grossWeightKg: v === '' ? '' : Number(v) })}
/>
<NumberInput
label="Net weight (kg)"
min={0}
value={value.netWeightKg}
onChange={(v) => onChange({ ...value, netWeightKg: v === '' ? '' : Number(v) })}
/>
</Group>
<TextInput
label="Volume / dimensions"
value={value.volumeDimensions}
onChange={(e) => onChange({ ...value, volumeDimensions: e.currentTarget.value })}
/>
<Text size="sm" fw={600} mt="xs">Quality and inspection</Text>
<Group grow>
<TextInput
label="Condition at receipt"
value={value.conditionAtReceipt}
onChange={(e) => onChange({ ...value, conditionAtReceipt: e.currentTarget.value })}
/>
<NumberInput
label="Damaged / rejected quantity"
min={0}
value={value.damagedRejectedQuantity}
onChange={(v) => onChange({ ...value, damagedRejectedQuantity: v === '' ? '' : Number(v) })}
/>
</Group>
<TextInput
label="Warehouse code and location"
value={value.warehouseCodeLocation}
onChange={(e) => onChange({ ...value, warehouseCodeLocation: e.currentTarget.value })}
/>
<Group grow>
<TextInput
label="Driver signatory"
value={value.driverSignatoryName}
onChange={(e) => onChange({ ...value, driverSignatoryName: e.currentTarget.value })}
/>
<TextInput
label="EDR warehouse manager"
value={value.warehouseManagerName}
onChange={(e) => onChange({ ...value, warehouseManagerName: e.currentTarget.value })}
/>
</Group>
</Stack>
);
}
/** Cascading Warehouse → Yard → Zone selectors (ACTIVE only). */
function LocationSelects({
value,
@@ -140,20 +443,105 @@ function EligibleTab({
onChanged?: () => void;
}) {
const { toast } = useToast();
const qc = useQueryClient();
const { data: allRows = [], isLoading } = useQuery(
api.warehouses.eligibleBookings.queryOptions({ enabled }),
);
const rows = useMemo(() => allRows.filter((r) => r.direction === direction), [allRows, direction]);
const bulkReceive = useMutation(api.warehouses.bulkReceive.mutationOptions());
const loadPassed = useMutation(api.warehouses.loadPassedExport.mutationOptions());
const requestFirstMile = useMutation({
mutationFn: (reference: string) => firstMileService.accept(reference),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.ROOT });
toast({ title: 'First mile requested', description: 'Booking was added to the existing First Mile workflow.' });
},
onError: (error) => {
toast({ variant: 'destructive', title: 'First mile request failed', description: extractErrorMessage(error) });
},
});
const [selected, setSelected] = useState<Set<string>>(new Set());
const [statusTab, setStatusTab] = useState('ALL');
const [truckOpen, setTruckOpen] = useState(false);
const [pendingReceiveIds, setPendingReceiveIds] = useState<string[]>([]);
const [truckForm, setTruckForm] = useState<TruckEntranceFormState>(emptyTruckEntrance());
const locationReady = Boolean(location.warehouseId && location.yardId && location.zoneId);
const allSelected = rows.length > 0 && selected.size === rows.length;
const canReceiveBooking = (row: EligibleBooking) =>
!(direction === 'EXPORT' && row.hasFirstMile && row.firstMileStatus !== 'RECEIVED_TO_PORT');
const statusOptions = useMemo(() => {
const base = [{ value: 'ALL', label: 'All bookings' }];
if (direction === 'EXPORT') {
return [
...base,
{ value: 'DIRECT', label: 'Direct truck' },
{ value: 'FIRST_MILE', label: 'First mile' },
{ value: 'FIRST_MILE_READY', label: 'First mile arrived' },
{ value: 'AWAITING_FIRST_MILE', label: 'Awaiting first mile' },
];
}
return [
...base,
{ value: 'READY_TO_RECEIVE', label: 'Ready to receive' },
{ value: 'PAID', label: 'Paid' },
];
}, [direction]);
const statusFilteredRows = useMemo(
() =>
rows.filter((row) => {
switch (statusTab) {
case 'DIRECT':
return !row.hasFirstMile;
case 'FIRST_MILE':
return row.hasFirstMile;
case 'FIRST_MILE_READY':
return row.hasFirstMile && row.firstMileStatus === 'RECEIVED_TO_PORT';
case 'AWAITING_FIRST_MILE':
return row.hasFirstMile && row.firstMileStatus !== 'RECEIVED_TO_PORT';
case 'READY_TO_RECEIVE':
return canReceiveBooking(row);
case 'PAID':
return row.paymentStatus === 'PAID';
default:
return true;
}
}),
[rows, statusTab],
);
const statusCounts = useMemo(
() =>
Object.fromEntries(
statusOptions.map((option) => [
option.value,
rows.filter((row) => {
switch (option.value) {
case 'DIRECT':
return !row.hasFirstMile;
case 'FIRST_MILE':
return row.hasFirstMile;
case 'FIRST_MILE_READY':
return row.hasFirstMile && row.firstMileStatus === 'RECEIVED_TO_PORT';
case 'AWAITING_FIRST_MILE':
return row.hasFirstMile && row.firstMileStatus !== 'RECEIVED_TO_PORT';
case 'READY_TO_RECEIVE':
return canReceiveBooking(row);
case 'PAID':
return row.paymentStatus === 'PAID';
default:
return true;
}
}).length,
]),
),
[rows, statusOptions],
);
const selectableRows = statusFilteredRows.filter(canReceiveBooking);
const allSelected = selectableRows.length > 0 && selected.size === selectableRows.length;
const someSelected = selected.size > 0 && !allSelected;
const toggleAll = () =>
setSelected(allSelected ? new Set() : new Set(rows.map((r) => r.id)));
setSelected(allSelected ? new Set() : new Set(selectableRows.map((r) => r.id)));
const toggleOne = (id: string) =>
setSelected((prev) => {
const next = new Set(prev);
@@ -161,7 +549,7 @@ function EligibleTab({
return next;
});
const receive = async (bookingIds: string[]) => {
const openTruckReceive = (bookingIds: string[]) => {
if (!locationReady) {
toast({ variant: 'destructive', title: 'Select warehouse, yard and zone first' });
return;
@@ -170,13 +558,41 @@ function EligibleTab({
toast({ variant: 'destructive', title: 'Select at least one booking' });
return;
}
const allowedIds = new Set(selectableRows.map((row) => row.id));
const filteredIds = bookingIds.filter((id) => allowedIds.has(id));
if (filteredIds.length === 0) {
toast({ variant: 'destructive', title: 'No selected booking is ready to receive' });
return;
}
const row = filteredIds.length === 1 ? rows.find((item) => item.id === filteredIds[0]) : null;
setPendingReceiveIds(filteredIds);
setTruckForm({
...emptyTruckEntrance(),
truckPlateNumber: row?.firstMileTruckPlateNumber ?? '',
trailerPlateNumber: row?.firstMileTrailerPlateNumber ?? '',
});
setTruckOpen(true);
};
const receive = async () => {
if (!truckForm.truckPlateNumber.trim() || !truckForm.driverName.trim() || !truckForm.driverPhone.trim() || truckForm.entranceTareWeightKg === '') {
toast({ variant: 'destructive', title: 'Truck, driver and entrance tare weight are required' });
return;
}
try {
const r = await bulkReceive.mutateAsync({ direction, ...location, bookingIds });
const r = await bulkReceive.mutateAsync({
direction,
...location,
bookingIds: pendingReceiveIds,
truckEntrance: toTruckEntrancePayload(truckForm),
});
toast({
title: `${r.receivedCount} received at ${direction === 'EXPORT' ? 'facility' : 'warehouse'}`,
description: r.skippedCount ? `${r.skippedCount} skipped` : undefined,
description: r.results.find((item) => item.grnNumber)?.grnNumber ?? (r.skippedCount ? `${r.skippedCount} skipped` : undefined),
});
setSelected(new Set());
setTruckOpen(false);
setPendingReceiveIds([]);
onChanged?.();
} catch (error) {
toast({ variant: 'destructive', title: 'Receive failed', description: extractErrorMessage(error) });
@@ -198,9 +614,19 @@ function EligibleTab({
return (
<Stack gap="sm" mt="sm">
<Tabs value={statusTab} onChange={(v) => setStatusTab(v ?? 'ALL')}>
<Tabs.List>
{statusOptions.map((option) => (
<Tabs.Tab key={option.value} value={option.value}>
{option.label} ({statusCounts[option.value] ?? 0})
</Tabs.Tab>
))}
</Tabs.List>
</Tabs>
<Group justify="space-between">
<Text size="sm" c="dimmed">
Selected: <b>{selected.size}</b> / {rows.length} eligible
Selected: <b>{selected.size}</b> / {statusFilteredRows.length} eligible
</Text>
<Group gap="xs">
{direction === 'EXPORT' && (
@@ -218,9 +644,9 @@ function EligibleTab({
<Button
size="compact-sm"
variant="default"
disabled={!locationReady || rows.length === 0}
disabled={!locationReady || selectableRows.length === 0}
loading={bulkReceive.isPending}
onClick={() => receive(rows.map((r) => r.id))}
onClick={() => openTruckReceive(selectableRows.map((r) => r.id))}
>
Receive All Eligible
</Button>
@@ -228,7 +654,7 @@ function EligibleTab({
size="compact-sm"
disabled={!locationReady || selected.size === 0}
loading={bulkReceive.isPending}
onClick={() => receive([...selected])}
onClick={() => openTruckReceive([...selected])}
>
Receive Selected
</Button>
@@ -245,7 +671,7 @@ function EligibleTab({
<Group justify="center" py="lg">
<Loader size="sm" />
</Group>
) : rows.length === 0 ? (
) : statusFilteredRows.length === 0 ? (
<Text c="dimmed" ta="center" py="lg" size="sm">
No eligible PAID {direction.toLowerCase()} bookings to receive.
</Text>
@@ -275,16 +701,20 @@ function EligibleTab({
<Table.Th>Payment</Table.Th>
<Table.Th>Current Status</Table.Th>
<Table.Th>Inspection</Table.Th>
{direction === 'EXPORT' && <Table.Th>First Mile</Table.Th>}
<Table.Th ta="right">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((r) => (
{statusFilteredRows.map((r) => {
const canReceive = canReceiveBooking(r);
return (
<Table.Tr key={r.id}>
<Table.Td>
<Checkbox
aria-label={`Select ${r.reference}`}
checked={selected.has(r.id)}
disabled={!canReceive}
onChange={() => toggleOne(r.id)}
/>
</Table.Td>
@@ -319,23 +749,73 @@ function EligibleTab({
</Badge>
</Table.Td>
<Table.Td></Table.Td>
{direction === 'EXPORT' && (
<Table.Td>
{r.hasFirstMile ? (
<Stack gap={2}>
<Badge
color={r.firstMileStatus === 'RECEIVED_TO_PORT' ? 'green' : r.firstMileRequestId ? 'blue' : 'orange'}
variant="light"
size="sm"
>
{r.firstMileStatus ?? 'Request needed'}
</Badge>
<Text size="xs" c="dimmed">
{[r.firstMileTruckPlateNumber, r.firstMileTrailerPlateNumber].filter(Boolean).join(' / ') || 'Truck not assigned'}
</Text>
</Stack>
) : (
<Badge color="gray" variant="light" size="sm">Direct arrival</Badge>
)}
</Table.Td>
)}
<Table.Td ta="right">
<Button
size="compact-xs"
variant="light"
disabled={!locationReady}
loading={bulkReceive.isPending}
onClick={() => receive([r.id])}
>
Receive
</Button>
{direction === 'EXPORT' && r.hasFirstMile && !r.firstMileRequestId ? (
<Button
size="compact-xs"
variant="light"
color="orange"
loading={requestFirstMile.isPending}
onClick={() => requestFirstMile.mutate(r.reference)}
>
Request First Mile
</Button>
) : (
<Button
size="compact-xs"
variant="light"
disabled={!locationReady || !canReceive}
loading={bulkReceive.isPending}
onClick={() => openTruckReceive([r.id])}
>
{canReceive ? 'Receive' : 'Await First Mile'}
</Button>
)}
</Table.Td>
</Table.Tr>
))}
);
})}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
<Modal opened={truckOpen} onClose={() => setTruckOpen(false)} title="Truck Entrance Registration" centered size="lg">
<Stack gap="md">
<Text size="sm" c="dimmed">
Register the arriving truck and driver before receiving {pendingReceiveIds.length} booking{pendingReceiveIds.length === 1 ? '' : 's'}.
</Text>
<TruckEntranceFields value={truckForm} onChange={setTruckForm} />
<Group justify="flex-end">
<Button variant="default" onClick={() => setTruckOpen(false)} disabled={bulkReceive.isPending}>
Cancel
</Button>
<Button leftSection={<Truck size={16} />} loading={bulkReceive.isPending} onClick={receive}>
Receive and Generate GRN
</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
}
@@ -1203,11 +1683,13 @@ function SingleBookingReceiveModal({
volume: '',
notes: '',
});
const [truckForm, setTruckForm] = useState<TruckEntranceFormState>(emptyTruckEntrance());
useEffect(() => {
if (opened) {
setSelectedBooking(bookingId ?? '');
setForm({ warehouseId: '', yardId: '', zoneId: '', quantity: '', weight: '', volume: '', notes: '' });
setTruckForm(emptyTruckEntrance());
}
}, [opened, bookingId]);
@@ -1226,6 +1708,10 @@ function SingleBookingReceiveModal({
toast({ variant: 'destructive', title: 'Quantity and weight are required' });
return;
}
if (!truckForm.truckPlateNumber.trim() || !truckForm.driverName.trim() || !truckForm.driverPhone.trim() || truckForm.entranceTareWeightKg === '') {
toast({ variant: 'destructive', title: 'Truck, driver and entrance tare weight are required' });
return;
}
const payload: ReceiveInventoryPayload = {
bookingId: selectedBooking.trim(),
warehouseId: form.warehouseId,
@@ -1235,6 +1721,7 @@ function SingleBookingReceiveModal({
weight: Number(form.weight),
volume: form.volume === '' ? undefined : Number(form.volume),
notes: form.notes.trim() || undefined,
truckEntrance: toTruckEntrancePayload(truckForm),
};
try {
await receiveMutation.mutateAsync(payload);
@@ -1281,6 +1768,8 @@ function SingleBookingReceiveModal({
/>
</Group>
<TruckEntranceFields value={truckForm} onChange={setTruckForm} />
<Textarea
label="Notes"
placeholder="Optional notes"
@@ -1298,7 +1787,7 @@ function SingleBookingReceiveModal({
Cancel
</Button>
<Button onClick={handleSubmit} loading={receiveMutation.isPending}>
Receive inventory
Receive inventory and generate GRN
</Button>
</Group>
</Stack>

View File

@@ -89,7 +89,7 @@ const bookingRef = (r: FirstMileRecord) => r.booking?.reference ?? r.bookingId;
const customerName = (r: FirstMileRecord) => r.booking?.company?.name ?? "—";
const pickupLocation = (r: FirstMileRecord) => r.booking?.firstMilePickupAddress ?? "—";
const cargoDesc = (r: FirstMileRecord) => {
const parts = [r.booking?.cargoType?.name ?? r.booking?.cargoFreeText].filter(Boolean);
const parts = [r.booking?.cargoType?.label ?? r.booking?.cargoFreeText].filter(Boolean);
if (r.booking?.cargoTotalWeightVgm) parts.push(`${r.booking.cargoTotalWeightVgm} t`);
return parts.join(" · ") || "—";
};
@@ -347,6 +347,10 @@ const FirstMilePage = () => {
const paidBookings = paidBookingsData?.items ?? [];
const records = listData?.data ?? [];
const existingFirstMileBookingIds = useMemo(
() => new Set(records.map((record) => record.bookingId)),
[records],
);
const vehicleOptions = useMemo(
() =>
@@ -398,16 +402,27 @@ const FirstMilePage = () => {
[rowSelection],
);
const firstMileEligiblePaidBookings = useMemo(
() =>
paidBookings.filter(
(booking) =>
booking.tradeDirection === "EXPORT" &&
Boolean(booking.firstMilePickupAddress?.trim()) &&
!existingFirstMileBookingIds.has(booking.id),
),
[existingFirstMileBookingIds, paidBookings],
);
const filteredPaidBookings = useMemo(() => {
const term = bookingSearch.trim().toLowerCase();
if (!term) return paidBookings;
return paidBookings.filter((b) =>
if (!term) return firstMileEligiblePaidBookings;
return firstMileEligiblePaidBookings.filter((b) =>
[b.reference, b.company?.name, b.company?.companyName]
.join(" ")
.toLowerCase()
.includes(term),
);
}, [paidBookings, bookingSearch]);
}, [firstMileEligiblePaidBookings, bookingSearch]);
const openAccept = () => {
setAcceptOpen(true);
@@ -879,7 +894,7 @@ const FirstMilePage = () => {
{bookingsLoading ? (
<Text c="dimmed" size="sm" ta="center" py="md">Loading bookings</Text>
) : filteredPaidBookings.length === 0 ? (
<Text c="dimmed" size="sm" ta="center" py="md">No paid bookings found.</Text>
<Text c="dimmed" size="sm" ta="center" py="md">No paid export bookings need first mile.</Text>
) : (
filteredPaidBookings.map((b) => (
<UnstyledButton

View File

@@ -364,6 +364,12 @@ export interface EligibleBooking {
weight: string | null;
paymentStatus: string;
status: string;
hasFirstMile: boolean;
firstMileRequestId: string | null;
firstMileStatus: string | null;
firstMileVehicleId: string | null;
firstMileTruckPlateNumber: string | null;
firstMileTrailerPlateNumber: string | null;
}
export interface BulkReceivePayload {
@@ -372,12 +378,45 @@ export interface BulkReceivePayload {
yardId: string;
zoneId: string;
bookingIds: string[];
truckEntrance: TruckEntrancePayload;
}
export interface BulkReceiveResult {
receivedCount: number;
skippedCount: number;
results: { bookingId: string; status: string; inventoryId?: string; reason?: string }[];
results: { bookingId: string; status: string; inventoryId?: string; grnNumber?: string; reason?: string }[];
}
export interface TruckEntrancePayload {
ownerName?: string;
consigneeDetails?: string;
edrDigitalBookingId?: string;
tin?: string;
truckPlateNumber: string;
trailerPlateNumber?: string;
assignedEquipmentNumber?: string;
customsSealNumber?: string;
declarationNumber?: string;
incoterms?: string;
hsCodes?: string;
itemCode?: string;
itemDescription?: string;
packagingType?: string;
unitCount?: number;
grossWeightKg?: number;
netWeightKg?: number;
volumeDimensions?: string;
conditionAtReceipt?: string;
damagedRejectedQuantity?: number;
warehouseCodeLocation?: string;
driverName: string;
driverPhone: string;
driverLicenseNumber?: string;
truckType?: string;
entranceTareWeightKg: number;
exitTareWeightKg?: number;
driverSignatoryName?: string;
warehouseManagerName?: string;
}
export interface LoadPassedExportResult {
@@ -835,6 +874,7 @@ export interface ReceiveInventoryPayload {
weight: number;
volume?: number;
notes?: string;
truckEntrance: TruckEntrancePayload;
}
export interface MoveInventoryPayload {