mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Merge branch 'dev' of https://github.com/Tria-plc/edr-platform into alpha
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -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()}`,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user