mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight_feature/usermanagement
This commit is contained in:
61
apps/edr-freight-api/src/common/mile-financials.util.ts
Normal file
61
apps/edr-freight-api/src/common/mile-financials.util.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
type MileRecord = {
|
||||
bookingId?: string | null;
|
||||
advancedPayment?: number | string | null;
|
||||
booking?: {
|
||||
cargoTotalWeightVgm?: number | string | null;
|
||||
bookingContainers?: Array<{
|
||||
units?: Array<{ vgmTons?: number | string | null }> | null;
|
||||
}> | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Display enrichment for first/last-mile lists (Assign Vehicle modal etc.):
|
||||
* - Advance payment: mile records are created with advanced_payment 0 — the
|
||||
* real advance is the FIRST_MILE/LAST_MILE line the customer already paid
|
||||
* on the booking invoice.
|
||||
* - Cargo tons: container bookings often carry tonnage on the per-unit VGMs
|
||||
* while cargo_total_weight_vgm stays 0 — fall back to the summed units.
|
||||
* Fills both in-memory on the loaded records; nothing is persisted.
|
||||
*/
|
||||
export async function attachMileFinancials(
|
||||
dataSource: DataSource,
|
||||
records: MileRecord[],
|
||||
chargeType: 'FIRST_MILE' | 'LAST_MILE',
|
||||
): Promise<void> {
|
||||
for (const r of records) {
|
||||
const b = r.booking;
|
||||
if (!b || Number(b.cargoTotalWeightVgm) > 0) continue;
|
||||
const unitTons = (b.bookingContainers ?? []).reduce(
|
||||
(sum, bc) =>
|
||||
sum + (bc.units ?? []).reduce((s, u) => s + (Number(u.vgmTons) || 0), 0),
|
||||
0,
|
||||
);
|
||||
if (unitTons > 0) b.cargoTotalWeightVgm = Number(unitTons.toFixed(3));
|
||||
}
|
||||
|
||||
const needAdvance = records.filter(
|
||||
(r) => r.bookingId && !(Number(r.advancedPayment) > 0),
|
||||
);
|
||||
if (!needAdvance.length) return;
|
||||
|
||||
const rows: Array<{ bookingId: string; amount: string }> = await dataSource.query(
|
||||
`SELECT i.source_id AS "bookingId", SUM(il.amount) AS amount
|
||||
FROM freight.invoice_lines il
|
||||
JOIN freight.invoices i ON i.id = il.invoice_id AND i.deleted_at IS NULL
|
||||
WHERE i.source = 'booking'
|
||||
AND i.status = 'PAID'
|
||||
AND i.source_id = ANY($1::text[])
|
||||
AND il.charge_type = $2
|
||||
AND il.deleted_at IS NULL
|
||||
GROUP BY i.source_id`,
|
||||
[needAdvance.map((r) => r.bookingId), chargeType],
|
||||
);
|
||||
const byBooking = new Map(rows.map((r) => [r.bookingId, Number(r.amount)]));
|
||||
for (const r of needAdvance) {
|
||||
const paid = byBooking.get(r.bookingId as string);
|
||||
if (paid) r.advancedPayment = paid;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Per-truck EDR last-mile handovers. `truck_assignment_id` FKs
|
||||
* customer_truck_assignments (self-haul only), so EDR trucks need their own
|
||||
* link to the last-mile vehicle assignment that hauled the goods. Generated
|
||||
* when the EDR truck exits the warehouse (with its exit paper) and signed by
|
||||
* the customer in the portal — one per truck, or booking-level (both ids null)
|
||||
* when the truck cannot be resolved.
|
||||
*/
|
||||
export class AddHandoverEdrAssignment2440000000000 implements MigrationInterface {
|
||||
name = 'AddHandoverEdrAssignment2440000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_handovers
|
||||
ADD COLUMN IF NOT EXISTS edr_assignment_id uuid
|
||||
REFERENCES freight.last_mile_vehicle_assignments(id) ON DELETE SET NULL;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_booking_handovers_booking_edr_truck"
|
||||
ON freight.booking_handovers (booking_id, edr_assignment_id)
|
||||
WHERE deleted_at IS NULL AND edr_assignment_id IS NOT NULL;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS freight."UQ_booking_handovers_booking_edr_truck";`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.booking_handovers DROP COLUMN IF EXISTS edr_assignment_id;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Logger,
|
||||
Optional,
|
||||
} from "@nestjs/common";
|
||||
import { OnEvent } from "@nestjs/event-emitter";
|
||||
|
||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||
@@ -349,6 +350,22 @@ export class BookingTransitionService {
|
||||
return fresh;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import EDR last-mile: every handover signed + every truck departed ⇒ the
|
||||
* warehouses module delivered the goods and asks the booking to complete.
|
||||
* Best-effort — a booking already COMPLETED (or not yet in transit) just logs.
|
||||
*/
|
||||
@OnEvent('import.handover.completed')
|
||||
async onImportHandoverCompleted(payload: { bookingId: string }): Promise<void> {
|
||||
try {
|
||||
await this.complete(payload.bookingId);
|
||||
} catch (err) {
|
||||
this.logger.log(
|
||||
`Booking ${payload.bookingId} not auto-completed on handover sign: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async complete(bookingId: string): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ["IN_TRANSIT", "ARRIVED"]);
|
||||
|
||||
@@ -1228,9 +1228,9 @@ export class BookingsService {
|
||||
|
||||
/**
|
||||
* Batched version of the findById flag: marks each page item whose booking
|
||||
* has a generated-but-unsigned SELF_HAUL handover, so list rows (portal
|
||||
* dashboard) can show "Approve delivery" for exactly the generated→signed
|
||||
* window. One query for the whole page.
|
||||
* has a generated-but-unsigned handover (self-haul or EDR last-mile), so list
|
||||
* rows (portal dashboard) can show "Approve delivery" for exactly the
|
||||
* generated→signed window. One query for the whole page.
|
||||
*/
|
||||
private async attachHandoverFlags(bookings: Booking[]): Promise<void> {
|
||||
const ids = bookings.map((b) => b.id);
|
||||
@@ -1239,8 +1239,7 @@ export class BookingsService {
|
||||
`SELECT DISTINCT booking_id AS "bookingId"
|
||||
FROM freight.booking_handovers
|
||||
WHERE booking_id = ANY($1::uuid[])
|
||||
AND signed_at IS NULL AND deleted_at IS NULL
|
||||
AND mile_type = 'SELF_HAUL'`,
|
||||
AND signed_at IS NULL AND deleted_at IS NULL`,
|
||||
[ids],
|
||||
);
|
||||
const pending = new Set(rows.map((r) => r.bookingId));
|
||||
@@ -1583,14 +1582,12 @@ export class BookingsService {
|
||||
schedule?.status ?? null;
|
||||
}
|
||||
|
||||
// A generated-but-unsigned SELF_HAUL handover means the customer must approve
|
||||
// delivery from the portal (booking-based, one per booking). EDR last-mile
|
||||
// handovers are per delivering truck and signed by the receiver at the door,
|
||||
// so they never surface the portal "Approve delivery" action.
|
||||
// A generated-but-unsigned handover means the customer must approve delivery
|
||||
// from the portal. Self-haul: booking-based, one per booking. EDR last-mile:
|
||||
// per delivering truck (generated on truck exit), signed one by one.
|
||||
const [pendingHandover] = await this.dataSource.query(
|
||||
`SELECT 1 FROM freight.booking_handovers
|
||||
WHERE booking_id = $1 AND signed_at IS NULL AND deleted_at IS NULL
|
||||
AND mile_type = 'SELF_HAUL'
|
||||
LIMIT 1`,
|
||||
[id],
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { BadRequestException, Injectable, Logger, NotFoundException } from '@nes
|
||||
import { FindOptionsWhere, In, IsNull, Not } from 'typeorm';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { attachMileFinancials } from '../../common/mile-financials.util';
|
||||
import { VehicleAvailability } from '../vehicles/entities/vehicle.entity';
|
||||
import { BookingsRepository } from "../bookings/bookings.repository";
|
||||
import { DriversService } from "../drivers/drivers.service";
|
||||
@@ -66,6 +67,7 @@ export class FirstMileService {
|
||||
for (const r of records) {
|
||||
(r as FirstMile & { invoice?: unknown }).invoice = byId.get(r.id) ?? null;
|
||||
}
|
||||
await attachMileFinancials(this.dataSource, records, 'FIRST_MILE');
|
||||
}
|
||||
|
||||
/** Resolve a vehicle's driver + human labels, for stamping mile events onto
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
SELF_HAUL_CONFLICT_MESSAGE,
|
||||
usesEdrMileService,
|
||||
} from '../../common/mile-haulage.util';
|
||||
import { attachMileFinancials } from '../../common/mile-financials.util';
|
||||
import {
|
||||
assertBulkTonnageRemains,
|
||||
assertTruckCountWithinContainers,
|
||||
@@ -88,6 +89,7 @@ export class LastMileService {
|
||||
for (const r of records) {
|
||||
(r as LastMile & { invoice?: unknown }).invoice = byId.get(r.id) ?? null;
|
||||
}
|
||||
await attachMileFinancials(this.dataSource, records, 'LAST_MILE');
|
||||
}
|
||||
|
||||
/** Resolve a vehicle's driver + human labels, for stamping mile events onto
|
||||
@@ -333,6 +335,8 @@ export class LastMileService {
|
||||
driverPhone: string | null;
|
||||
truckType: string | null;
|
||||
containerNumber: string | null;
|
||||
arrivedAt: string | null;
|
||||
departedAt: string | null;
|
||||
}>
|
||||
> {
|
||||
const [lm] = await this.lastMileRepository.findAll({
|
||||
@@ -347,9 +351,11 @@ export class LastMileService {
|
||||
? lm.vehicleAssignments.map((va) => ({
|
||||
vehicle: va.vehicle,
|
||||
containerNumber: va.containerNumber ?? null,
|
||||
arrivedAt: va.arrivedAt ?? null,
|
||||
departedAt: va.departedAt ?? null,
|
||||
}))
|
||||
: lm.vehicle
|
||||
? [{ vehicle: lm.vehicle, containerNumber: null }]
|
||||
? [{ vehicle: lm.vehicle, containerNumber: null, arrivedAt: null, departedAt: null }]
|
||||
: [];
|
||||
|
||||
const out: Array<{
|
||||
@@ -361,8 +367,10 @@ export class LastMileService {
|
||||
driverPhone: string | null;
|
||||
truckType: string | null;
|
||||
containerNumber: string | null;
|
||||
arrivedAt: string | null;
|
||||
departedAt: string | null;
|
||||
}> = [];
|
||||
for (const { vehicle, containerNumber } of sources) {
|
||||
for (const { vehicle, containerNumber, arrivedAt, departedAt } of sources) {
|
||||
if (!vehicle) continue;
|
||||
let driverName = vehicle.assignedDriverName ?? null;
|
||||
let driverLicense: string | null = null;
|
||||
@@ -386,6 +394,8 @@ export class LastMileService {
|
||||
driverPhone,
|
||||
truckType: vehicle.vehicleType || null,
|
||||
containerNumber,
|
||||
arrivedAt: arrivedAt ? new Date(arrivedAt).toISOString() : null,
|
||||
departedAt: departedAt ? new Date(departedAt).toISOString() : null,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
|
||||
import { CreateVehicleDto } from './create-vehicle.dto';
|
||||
|
||||
const base = {
|
||||
vehicleType: 'TRUCK',
|
||||
manufacturer: 'IVECO',
|
||||
model: 'HYT',
|
||||
year: 2020,
|
||||
fuelType: 'DIESEL',
|
||||
capacity: 0,
|
||||
status: 'ACTIVE',
|
||||
};
|
||||
|
||||
const errorsFor = (over: Record<string, unknown>) =>
|
||||
validate(plainToInstance(CreateVehicleDto, { ...base, ...over }));
|
||||
|
||||
const plateErrors = (
|
||||
errors: Awaited<ReturnType<typeof errorsFor>>,
|
||||
property: string,
|
||||
) => errors.find((e) => e.property === property && e.constraints?.matches);
|
||||
|
||||
describe('CreateVehicleDto — plate format', () => {
|
||||
it('accepts a plate like ET-9875', async () => {
|
||||
expect(plateErrors(await errorsFor({ plateNumber: 'ET-9875' }), 'plateNumber')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('accepts a plate like AA-8642', async () => {
|
||||
expect(plateErrors(await errorsFor({ plateNumber: 'AA-8642' }), 'plateNumber')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('upper-cases a lower-case plate before validating', async () => {
|
||||
const dto = plainToInstance(CreateVehicleDto, { ...base, plateNumber: 'et-9875' });
|
||||
expect(dto.plateNumber).toBe('ET-9875');
|
||||
expect(plateErrors(await validate(dto), 'plateNumber')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects a free-text plate like assadasd', async () => {
|
||||
expect(plateErrors(await errorsFor({ plateNumber: 'assadasd' }), 'plateNumber')).toBeDefined();
|
||||
});
|
||||
|
||||
it('rejects a plate with no letters or no digits', async () => {
|
||||
expect(plateErrors(await errorsFor({ plateNumber: '1234' }), 'plateNumber')).toBeDefined();
|
||||
expect(plateErrors(await errorsFor({ plateNumber: 'ABCD' }), 'plateNumber')).toBeDefined();
|
||||
});
|
||||
|
||||
it('rejects a bad trailer plate but allows a valid one', async () => {
|
||||
expect(
|
||||
plateErrors(await errorsFor({ plateNumber: 'ET-1', trailerPlateNo: 'asdasdasda' }), 'trailerPlateNo'),
|
||||
).toBeDefined();
|
||||
expect(
|
||||
plateErrors(await errorsFor({ plateNumber: 'ET-1', trailerPlateNo: 'AA-8642' }), 'trailerPlateNo'),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('allows an empty trailer plate (optional)', async () => {
|
||||
const errors = await errorsFor({ plateNumber: 'ET-1', trailerPlateNo: '' });
|
||||
expect(plateErrors(errors, 'trailerPlateNo')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,30 @@
|
||||
import { IsString, IsEnum, IsNumber, IsOptional, IsUUID } from 'class-validator';
|
||||
import { IsString, IsEnum, IsNumber, IsOptional, IsUUID, Matches } from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { VehicleType, FuelType, VehicleStatus, VehicleAvailability } from '../entities/vehicle.entity';
|
||||
|
||||
/**
|
||||
* A vehicle plate is two or three letters, a hyphen, then two to six digits —
|
||||
* e.g. ET-9875 or AA-8642. Kept in one place so plate, power-plate and trailer
|
||||
* all match and the message stays consistent.
|
||||
*/
|
||||
export const VEHICLE_PLATE_REGEX = /^[A-Z]{2,3}-\d{2,6}$/;
|
||||
export const VEHICLE_PLATE_MESSAGE =
|
||||
'must be letters and numbers like ET-9875 or AA-8642';
|
||||
|
||||
/**
|
||||
* Trim and upper-case a plate before validating, so "et-9875" is accepted. An
|
||||
* empty optional plate (trailer/power) becomes undefined so @IsOptional skips it
|
||||
* rather than failing the pattern.
|
||||
*/
|
||||
const normalizePlate = ({ value }: { value: unknown }) => {
|
||||
if (typeof value !== 'string') return value;
|
||||
const trimmed = value.trim().toUpperCase();
|
||||
return trimmed === '' ? undefined : trimmed;
|
||||
};
|
||||
|
||||
export class CreateVehicleDto {
|
||||
@Transform(normalizePlate)
|
||||
@Matches(VEHICLE_PLATE_REGEX, { message: `Plate number ${VEHICLE_PLATE_MESSAGE}` })
|
||||
@IsString()
|
||||
plateNumber!: string;
|
||||
|
||||
@@ -47,10 +70,14 @@ export class CreateVehicleDto {
|
||||
code?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(normalizePlate)
|
||||
@Matches(VEHICLE_PLATE_REGEX, { message: `Power plate number ${VEHICLE_PLATE_MESSAGE}` })
|
||||
@IsString()
|
||||
powerPlateNo?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(normalizePlate)
|
||||
@Matches(VEHICLE_PLATE_REGEX, { message: `Trailer plate number ${VEHICLE_PLATE_MESSAGE}` })
|
||||
@IsString()
|
||||
trailerPlateNo?: string;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsBoolean, IsEnum, IsInt, IsOptional, IsString, IsUUID, Min } from 'class-validator';
|
||||
|
||||
import {
|
||||
WAREHOUSE_INVENTORY_STATUSES,
|
||||
@@ -71,4 +72,27 @@ export class FilterWarehouseInventoryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
dateTo?: string;
|
||||
|
||||
// ── KPI drill-down filters ──────────────────────────────────────────────
|
||||
// Each mirrors one opsStats() counter so a dashboard card's count always
|
||||
// equals the length of the list it opens.
|
||||
|
||||
@ApiPropertyOptional({ type: Boolean, description: 'Only items received (created) today' })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (value == null ? undefined : value === true || value === 'true' || value === '1'))
|
||||
@IsBoolean()
|
||||
receivedToday?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ type: Boolean, description: 'Only RECEIVED items with no inspection yet' })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (value == null ? undefined : value === true || value === 'true' || value === '1'))
|
||||
@IsBoolean()
|
||||
pendingInspection?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ type: Number, minimum: 1, description: 'Only in-warehouse items older than N days' })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
agingOverDays?: number;
|
||||
}
|
||||
|
||||
@@ -8,8 +8,9 @@ export type HandoverMileType = (typeof HANDOVER_MILE_TYPES)[number];
|
||||
* One import handover. A booking has a single handover when one truck takes the
|
||||
* whole booking (`truckAssignmentId` null = per-booking), or one per truck when
|
||||
* multiple trucks are used. Self-haul handovers are generated on truck arrival
|
||||
* and signed before the truck leaves; EDR last-mile handovers are generated at
|
||||
* delivery (after exit).
|
||||
* and signed before the truck leaves; EDR last-mile handovers are generated
|
||||
* when the EDR truck exits the warehouse (with its exit paper) and signed by
|
||||
* the customer in the portal on delivery — one signature per truck.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'booking_handovers' })
|
||||
@Index(['bookingId'])
|
||||
@@ -21,6 +22,10 @@ export class BookingHandover extends BaseEntity {
|
||||
@Column({ name: 'truck_assignment_id', type: 'uuid', nullable: true })
|
||||
truckAssignmentId?: string | null;
|
||||
|
||||
/** EDR last-mile vehicle assignment this handover belongs to; null = per-booking. */
|
||||
@Column({ name: 'edr_assignment_id', type: 'uuid', nullable: true })
|
||||
edrAssignmentId?: string | null;
|
||||
|
||||
/** Denormalised plate for display / EDR trucks (which aren't customer trucks). */
|
||||
@Column({ name: 'truck_plate', type: 'varchar', length: 32, nullable: true })
|
||||
truckPlate?: string | null;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { WarehouseInventoryService } from './warehouse-inventory.service';
|
||||
|
||||
// Exercises the per-truck [Exit Inspection] block helpers directly (no DI).
|
||||
const svc = Object.create(WarehouseInventoryService.prototype) as any;
|
||||
|
||||
const arrivalA =
|
||||
'[Exit Inspection]\nTruck Plate: 3-15288/56858\nDriver: Abebe Lemeno\nGate In Time: 2026-07-21T08:00:00.000Z\nTare Weight: 12 t';
|
||||
const arrivalB =
|
||||
'[Exit Inspection]\nTruck Plate: 3-85957/48562\nDriver: Suleman Tamrat\nGate In Time: 2026-07-21T09:00:00.000Z\nWeighing: SKIPPED';
|
||||
|
||||
describe('per-truck exit inspection blocks', () => {
|
||||
it('keeps truck A intact when truck B arrives', () => {
|
||||
const afterA = svc.replaceExitInspectionNote('Receive note', arrivalA, '3-15288/56858');
|
||||
const afterB = svc.replaceExitInspectionNote(afterA, arrivalB, '3-85957/48562');
|
||||
expect(afterB).toContain('Abebe Lemeno');
|
||||
expect(afterB).toContain('Suleman Tamrat');
|
||||
expect(afterB.match(/\[Exit Inspection\]/g)).toHaveLength(2);
|
||||
expect(afterB.startsWith('Receive note')).toBe(true);
|
||||
});
|
||||
|
||||
it("exit for truck A updates only A's block and preserves arrival data", () => {
|
||||
const notes = svc.replaceExitInspectionNote(
|
||||
svc.replaceExitInspectionNote(null, arrivalA, '3-15288/56858'),
|
||||
arrivalB,
|
||||
'3-85957/48562',
|
||||
);
|
||||
const dto = svc.preserveTruckArrivalForExit(
|
||||
{ truckPlateNumber: '3-15288/56858', grossWeight: 40, gateOutTime: '2026-07-21T12:00:00.000Z' },
|
||||
notes,
|
||||
);
|
||||
expect(dto.driverName).toBe('Abebe Lemeno');
|
||||
expect(dto.tareWeight).toBe(12);
|
||||
expect(dto.weighingSkipped).toBeUndefined();
|
||||
const exitNote = svc.buildExitInspectionNote(dto);
|
||||
const replaced = svc.replaceExitInspectionNote(notes, exitNote, dto.truckPlateNumber);
|
||||
expect(replaced).toContain('Gross Weight: 40 t');
|
||||
expect(replaced).toContain('Net Weight: 28 t');
|
||||
expect(replaced).toContain('Suleman Tamrat'); // B untouched
|
||||
expect(replaced.match(/\[Exit Inspection\]/g)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('skipped weighing records the container-derived net in the note', () => {
|
||||
const dto = {
|
||||
truckPlateNumber: '3-85957/48562',
|
||||
driverName: 'Suleman Tamrat',
|
||||
weighingSkipped: true,
|
||||
netWeight: 27.5,
|
||||
gateInTime: '2026-07-21T09:00:00.000Z',
|
||||
gateOutTime: '2026-07-21T13:00:00.000Z',
|
||||
};
|
||||
const note = svc.buildExitInspectionNote(dto);
|
||||
expect(note).toContain('Weighing: SKIPPED');
|
||||
expect(note).toContain('Net Weight: 27.5 t');
|
||||
});
|
||||
|
||||
it('matches a legacy comma-joined plate list and keeps foreign notes', () => {
|
||||
const legacy =
|
||||
'Receive note\n\n[Exit Inspection]\nTruck Plate: 3-15288/56858, 3-85957/48562\nDriver: Abebe Lemeno\nTare Weight: 12 t\nCUSTOMER_DELIVERY_APPROVAL:{"ok":true}';
|
||||
const block = svc.extractExitInspectionForPlate(legacy, '3-15288/56858');
|
||||
expect(block).toContain('Abebe Lemeno');
|
||||
const replaced = svc.replaceExitInspectionNote(legacy, arrivalA, '3-15288/56858');
|
||||
expect(replaced.match(/\[Exit Inspection\]/g)).toHaveLength(1);
|
||||
expect(replaced).toContain('CUSTOMER_DELIVERY_APPROVAL:{"ok":true}');
|
||||
expect(replaced).toContain('Receive note');
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { NotificationAudience, NotificationType } from '@edr/types';
|
||||
import { DataSource, EntityManager, IsNull } from 'typeorm';
|
||||
import { DataSource, EntityManager, IsNull, Repository } from 'typeorm';
|
||||
|
||||
import { BookingHandover } from './entities/booking-handover.entity';
|
||||
import { BookingHandover, HandoverMileType } from './entities/booking-handover.entity';
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { sendCompanyChannels } from '../notifications/notify-company.util';
|
||||
@@ -12,7 +12,10 @@ import { sendCompanyChannels } from '../notifications/notify-company.util';
|
||||
* Import handover records. A booking has one handover per truck (single truck ⇒
|
||||
* one, effectively per-booking; multiple trucks ⇒ one each). Timing by mile type:
|
||||
* - SELF_HAUL: generated when the customer truck arrives, signed before it leaves.
|
||||
* - EDR_LAST_MILE: generated at delivery (after exit).
|
||||
* - EDR_LAST_MILE: generated when the EDR truck exits the warehouse (with its
|
||||
* exit paper), signed by the customer in the portal per truck; once every
|
||||
* handover is signed the delivery auto-completes (inventory / cargo /
|
||||
* booking → delivered).
|
||||
*/
|
||||
@Injectable()
|
||||
export class HandoverService {
|
||||
@@ -25,14 +28,22 @@ export class HandoverService {
|
||||
) {}
|
||||
|
||||
/** Tell the customer a handover is ready and needs their signature. */
|
||||
private async notifySignNeeded(bookingId: string, reference: string): Promise<void> {
|
||||
private async notifySignNeeded(
|
||||
bookingId: string,
|
||||
reference: string,
|
||||
opts: { mileType?: HandoverMileType; truckPlate?: string | null } = {},
|
||||
): Promise<void> {
|
||||
try {
|
||||
const [b]: Array<{ companyId: string | null; reference: string }> = await this.dataSource.query(
|
||||
`SELECT company_id AS "companyId", reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[bookingId],
|
||||
);
|
||||
if (!b?.companyId) return;
|
||||
const body = `Your import handover ${reference} for booking ${b.reference} is ready. Please review and sign it from the portal before the truck leaves.`;
|
||||
const truck = opts.truckPlate ? ` (truck ${opts.truckPlate})` : '';
|
||||
const body =
|
||||
opts.mileType === 'EDR_LAST_MILE'
|
||||
? `Your goods for booking ${b.reference} are on their way${truck}. Please review and sign handover ${reference} from the portal to confirm receipt of the delivery.`
|
||||
: `Your import handover ${reference} for booking ${b.reference} is ready. Please review and sign it from the portal before the truck leaves.`;
|
||||
await this.inbox.notify({
|
||||
recipients: { companyId: b.companyId },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
@@ -117,31 +128,98 @@ export class HandoverService {
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* EDR last-mile: generate a handover at delivery (after exit). One per EDR
|
||||
* truck (by plate) or per booking. Idempotent by (booking, plate).
|
||||
*/
|
||||
async ensureAtDelivery(
|
||||
/** Find an existing EDR handover by assignment, else by plate, else booking-level. */
|
||||
private async findEdrHandover(
|
||||
repo: Repository<BookingHandover>,
|
||||
bookingId: string,
|
||||
opts: { truckPlate?: string | null; truckAssignmentId?: string | null },
|
||||
opts: { truckPlate?: string | null; edrAssignmentId?: string | null },
|
||||
): Promise<BookingHandover | null> {
|
||||
if (opts.edrAssignmentId) {
|
||||
const byAssignment = await repo.findOne({
|
||||
where: { bookingId, edrAssignmentId: opts.edrAssignmentId },
|
||||
});
|
||||
if (byAssignment) return byAssignment;
|
||||
}
|
||||
if (opts.truckPlate) {
|
||||
return repo.findOne({
|
||||
where: { bookingId, mileType: 'EDR_LAST_MILE', truckPlate: opts.truckPlate },
|
||||
});
|
||||
}
|
||||
return repo.findOne({
|
||||
where: {
|
||||
bookingId,
|
||||
mileType: 'EDR_LAST_MILE',
|
||||
truckPlate: IsNull(),
|
||||
edrAssignmentId: IsNull(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* EDR last-mile: generate the handover when the EDR truck exits the warehouse
|
||||
* (alongside its exit paper) and ask the customer to sign it from the portal.
|
||||
* One per truck (multiple trucks ⇒ one each) or booking-level when the truck
|
||||
* cannot be resolved. Idempotent by (booking, assignment) / (booking, plate).
|
||||
*/
|
||||
async ensureForDepartedEdrTruck(
|
||||
bookingId: string,
|
||||
opts: { truckPlate?: string | null; edrAssignmentId?: string | null },
|
||||
manager?: EntityManager,
|
||||
): Promise<BookingHandover> {
|
||||
const m = manager ?? this.dataSource.manager;
|
||||
const repo = m.getRepository(BookingHandover);
|
||||
const existing = await repo.findOne({
|
||||
where: {
|
||||
bookingId,
|
||||
truckPlate: opts.truckPlate ?? IsNull(),
|
||||
truckAssignmentId: opts.truckAssignmentId ?? IsNull(),
|
||||
},
|
||||
});
|
||||
const existing = await this.findEdrHandover(repo, bookingId, opts);
|
||||
if (existing) return existing;
|
||||
|
||||
const reference = await this.generateReference(bookingId, m);
|
||||
return repo.save(
|
||||
const saved = await repo.save(
|
||||
repo.create({
|
||||
bookingId,
|
||||
truckAssignmentId: opts.truckAssignmentId ?? null,
|
||||
edrAssignmentId: opts.edrAssignmentId ?? null,
|
||||
truckPlate: opts.truckPlate ?? null,
|
||||
mileType: 'EDR_LAST_MILE',
|
||||
reference,
|
||||
generatedAt: new Date(),
|
||||
}),
|
||||
);
|
||||
this.logger.log(
|
||||
`EDR handover ${reference} generated on truck exit for booking ${bookingId}` +
|
||||
(opts.truckPlate ? ` (truck ${opts.truckPlate})` : ''),
|
||||
);
|
||||
void this.notifySignNeeded(bookingId, reference, {
|
||||
mileType: 'EDR_LAST_MILE',
|
||||
truckPlate: opts.truckPlate,
|
||||
});
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* EDR last-mile: ensure a handover exists at delivery and stamp delivered_at.
|
||||
* Normally the handover was already generated on truck exit — this only fills
|
||||
* the delivery timestamp; a handover is created here only for legacy flows
|
||||
* where the exit was recorded before this feature existed.
|
||||
*/
|
||||
async ensureAtDelivery(
|
||||
bookingId: string,
|
||||
opts: { truckPlate?: string | null; edrAssignmentId?: string | null },
|
||||
manager?: EntityManager,
|
||||
): Promise<BookingHandover> {
|
||||
const m = manager ?? this.dataSource.manager;
|
||||
const repo = m.getRepository(BookingHandover);
|
||||
const existing = await this.findEdrHandover(repo, bookingId, opts);
|
||||
if (existing) {
|
||||
if (!existing.deliveredAt) {
|
||||
existing.deliveredAt = new Date();
|
||||
await repo.save(existing);
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
const reference = await this.generateReference(bookingId, m);
|
||||
const saved = await repo.save(
|
||||
repo.create({
|
||||
bookingId,
|
||||
edrAssignmentId: opts.edrAssignmentId ?? null,
|
||||
truckPlate: opts.truckPlate ?? null,
|
||||
mileType: 'EDR_LAST_MILE',
|
||||
reference,
|
||||
@@ -149,6 +227,25 @@ export class HandoverService {
|
||||
deliveredAt: new Date(),
|
||||
}),
|
||||
);
|
||||
void this.notifySignNeeded(bookingId, reference, {
|
||||
mileType: 'EDR_LAST_MILE',
|
||||
truckPlate: opts.truckPlate,
|
||||
});
|
||||
return saved;
|
||||
}
|
||||
|
||||
/** Re-send the sign notification for every unsigned handover on the booking. */
|
||||
async notifyUnsignedForBooking(bookingId: string): Promise<void> {
|
||||
const unsigned = await this.dataSource.getRepository(BookingHandover).find({
|
||||
where: { bookingId, signedAt: IsNull() },
|
||||
order: { generatedAt: 'ASC' },
|
||||
});
|
||||
for (const h of unsigned) {
|
||||
await this.notifySignNeeded(bookingId, h.reference, {
|
||||
mileType: h.mileType,
|
||||
truckPlate: h.truckPlate,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -182,6 +279,27 @@ export class HandoverService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign one handover (EDR last-mile: the customer signs per truck). Returns the
|
||||
* fresh handover; idempotent — an already-signed handover is returned as-is.
|
||||
*/
|
||||
async sign(
|
||||
handoverId: string,
|
||||
userId?: string | null,
|
||||
signerName?: string | null,
|
||||
): Promise<BookingHandover> {
|
||||
const repo = this.dataSource.getRepository(BookingHandover);
|
||||
const handover = await repo.findOne({ where: { id: handoverId } });
|
||||
if (!handover) {
|
||||
throw new NotFoundException(`Handover ${handoverId} not found`);
|
||||
}
|
||||
if (handover.signedAt) return handover;
|
||||
handover.signedAt = new Date();
|
||||
handover.signedByUserId = userId ?? null;
|
||||
handover.signerName = signerName?.trim() || null;
|
||||
return repo.save(handover);
|
||||
}
|
||||
|
||||
/** Sign all unsigned handovers on a booking (self-haul: before the truck leaves). */
|
||||
async signForBooking(
|
||||
bookingId: string,
|
||||
|
||||
@@ -486,6 +486,21 @@ export class WarehouseInventoryController {
|
||||
return this.handoverService.list(bookingId);
|
||||
}
|
||||
|
||||
@Post('handovers/:handoverId/sign')
|
||||
@ApiOperation({ summary: 'Customer signs one handover (EDR last-mile: one signature per truck)' })
|
||||
signHandover(
|
||||
@Param('handoverId', ParseUUIDPipe) handoverId: string,
|
||||
@Body() dto: ApproveDeliveryDto,
|
||||
@Request() req: { user?: { id?: string; sub?: string } },
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.inventoryService.signHandover(
|
||||
handoverId,
|
||||
user?.id ?? req.user?.id ?? req.user?.sub,
|
||||
dto.signerName,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('bookings/:bookingId/request-handover-signature')
|
||||
@ApiOperation({ summary: 'Ask the customer to sign the handover (creates one if none, then notifies)' })
|
||||
requestHandoverSignature(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||
@@ -513,9 +528,16 @@ export class WarehouseInventoryController {
|
||||
}
|
||||
|
||||
@Get('bookings/:bookingId/handover-document')
|
||||
@ApiOperation({ summary: 'View import goods handover document PDF (resolved by booking)' })
|
||||
async bookingHandoverDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) {
|
||||
const { filename, buffer } = await this.inventoryService.handoverDocumentForBooking(bookingId);
|
||||
@ApiOperation({ summary: 'View import goods handover document PDF (resolved by booking; ?handoverId= for the per-truck variant)' })
|
||||
async bookingHandoverDocument(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@Res() res: Response,
|
||||
@Query('handoverId') handoverId?: string,
|
||||
) {
|
||||
const { filename, buffer } = await this.inventoryService.handoverDocumentForBooking(
|
||||
bookingId,
|
||||
handoverId || undefined,
|
||||
);
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
|
||||
res.setHeader('Content-Length', buffer.length);
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
|
||||
import {
|
||||
Between,
|
||||
DataSource,
|
||||
EntityManager,
|
||||
FindManyOptions,
|
||||
FindOptionsWhere,
|
||||
ILike,
|
||||
In,
|
||||
IsNull,
|
||||
LessThanOrEqual,
|
||||
MoreThanOrEqual,
|
||||
Raw,
|
||||
} from 'typeorm';
|
||||
|
||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||
import { generateGrnNumber } from '../../common/grn.util';
|
||||
@@ -13,6 +26,7 @@ import { CargoType } from '../rule-engine/entities/cargo-type.entity';
|
||||
import { InterchangeDocumentsService } from '../interchange-documents/interchange-documents.service';
|
||||
import type { InterchangeDocument } from '../interchange-documents/entities/interchange-document.entity';
|
||||
import { LastMileService } from '../last-mile/last-mile.service';
|
||||
import { UpdateLastMileDto } from '../last-mile/dto/update-last-mile.dto';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { sendCompanyChannels } from '../notifications/notify-company.util';
|
||||
import {
|
||||
@@ -68,6 +82,7 @@ const isLoadableWagonStatus = (status: string | null | undefined) =>
|
||||
|
||||
const CUSTOMER_DELIVERY_APPROVAL_PREFIX = 'CUSTOMER_DELIVERY_APPROVAL:';
|
||||
const HANDOVER_DOCUMENT_MARKER = '[Handover Document]';
|
||||
const EXIT_INSPECTION_MARKER = '[Exit Inspection]';
|
||||
|
||||
export interface InventoryInquiryResult {
|
||||
id: string;
|
||||
@@ -396,6 +411,7 @@ export class WarehouseInventoryService {
|
||||
private readonly signatures: SignaturesService,
|
||||
private readonly handover: HandoverService,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
private readonly events: EventEmitter2,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -508,12 +524,20 @@ export class WarehouseInventoryService {
|
||||
WHERE deleted_at IS NULL AND created_at::date = CURRENT_DATE - 1) AS "receivedYesterday",
|
||||
(SELECT count(*)::int FROM freight.warehouse_inventory
|
||||
WHERE deleted_at IS NULL AND status = 'RECEIVED' AND inspection_status IS NULL) AS "pendingInspection",
|
||||
(SELECT count(*)::int FROM freight.customer_truck_assignments
|
||||
WHERE deleted_at IS NULL AND arrived_at IS NOT NULL AND departed_at IS NULL) AS "trucksOnSite",
|
||||
-- Both haulage paths, mirroring the ON_SITE rows of trucksOnSite()
|
||||
((SELECT count(*)::int FROM freight.customer_truck_assignments a
|
||||
JOIN freight.bookings b ON b.id = a.booking_id AND b.deleted_at IS NULL
|
||||
WHERE a.deleted_at IS NULL AND a.arrived_at IS NOT NULL AND a.departed_at IS NULL)
|
||||
+
|
||||
(SELECT count(*)::int FROM freight.last_mile_vehicle_assignments va
|
||||
JOIN freight.last_mile lm ON lm.id = va.last_mile_id AND lm.deleted_at IS NULL
|
||||
JOIN freight.bookings b ON b.id = lm.booking_id AND b.deleted_at IS NULL
|
||||
WHERE va.deleted_at IS NULL AND va.arrived_at IS NOT NULL AND va.departed_at IS NULL)) AS "trucksOnSite",
|
||||
(SELECT count(*)::int FROM freight.warehouse_inventory
|
||||
WHERE deleted_at IS NULL
|
||||
AND status IN ('RECEIVED', 'STORED', 'READY_FOR_LOADING', 'READY_FOR_PICKUP', 'RESERVED')
|
||||
AND status = ANY($1)
|
||||
AND created_at < now() - interval '7 days') AS "itemsAging"`,
|
||||
[this.IN_WAREHOUSE_STATUSES],
|
||||
);
|
||||
return {
|
||||
receivedToday: row?.receivedToday ?? 0,
|
||||
@@ -525,7 +549,7 @@ export class WarehouseInventoryService {
|
||||
}
|
||||
|
||||
/** In-warehouse statuses used by the dwell / aging metrics. */
|
||||
private readonly IN_WAREHOUSE_STATUSES = [
|
||||
private readonly IN_WAREHOUSE_STATUSES: WarehouseInventoryStatus[] = [
|
||||
'RECEIVED',
|
||||
'UNLOADED',
|
||||
'STORED',
|
||||
@@ -953,7 +977,7 @@ export class WarehouseInventoryService {
|
||||
? LessThanOrEqual(new Date(filter.dateTo))
|
||||
: undefined;
|
||||
|
||||
const base = {
|
||||
const base: FindOptionsWhere<WarehouseInventory> = {
|
||||
...(filter.warehouseId ? { warehouseId: filter.warehouseId } : {}),
|
||||
...(filter.yardId ? { yardId: filter.yardId } : {}),
|
||||
...(filter.zoneId ? { zoneId: filter.zoneId } : {}),
|
||||
@@ -967,6 +991,24 @@ export class WarehouseInventoryService {
|
||||
...(filter.direction ? { booking: { tradeDirection: filter.direction } } : {}),
|
||||
};
|
||||
|
||||
// KPI drill-down filters — predicates mirror opsStats() exactly so the
|
||||
// dashboard card's count equals the length of the list it opens.
|
||||
if (filter.receivedToday) {
|
||||
base.createdAt = Raw((alias) => `${alias}::date = CURRENT_DATE`);
|
||||
}
|
||||
if (filter.pendingInspection) {
|
||||
base.status = 'RECEIVED';
|
||||
base.inspectionStatus = IsNull();
|
||||
}
|
||||
if (filter.agingOverDays) {
|
||||
if (!filter.status && !filter.pendingInspection) {
|
||||
base.status = In(this.IN_WAREHOUSE_STATUSES);
|
||||
}
|
||||
base.createdAt = Raw((alias) => `${alias} < now() - make_interval(days => :days)`, {
|
||||
days: filter.agingOverDays,
|
||||
});
|
||||
}
|
||||
|
||||
const search = filter.search?.trim();
|
||||
const where: FindManyOptions<WarehouseInventory>['where'] = search
|
||||
? [
|
||||
@@ -2972,12 +3014,29 @@ export class WarehouseInventoryService {
|
||||
const releaseDate = isTruckLeaving
|
||||
? dto.releaseDate ? new Date(dto.releaseDate) : new Date()
|
||||
: item.releaseDate ?? null;
|
||||
const reference = isTruckLeaving
|
||||
? item.releaseOrderReference || dto.reference?.trim() || (await this.generateReleaseReference(item))
|
||||
: dto.reference?.trim() || (await this.generateReleaseReference(item));
|
||||
const exitInspectionDto = isTruckLeaving
|
||||
? this.preserveTruckArrivalForExit(dto, item.notes)
|
||||
: dto;
|
||||
// One reference per item — the first truck's arrival mints it, later trucks
|
||||
// (arrival or exit) reuse it so all exit papers share the release order.
|
||||
const reference =
|
||||
item.releaseOrderReference || dto.reference?.trim() || (await this.generateReleaseReference(item));
|
||||
const exitInspectionDto = {
|
||||
...(isTruckLeaving ? this.preserveTruckArrivalForExit(dto, item.notes) : dto),
|
||||
};
|
||||
// Weighbridge skipped on exit: the recorded net still comes from what the
|
||||
// truck is holding — the summed cargo weight of its selected containers.
|
||||
if (isTruckLeaving && exitInspectionDto.weighingSkipped && item.bookingId) {
|
||||
const selected = (exitInspectionDto.containerNumber ?? '')
|
||||
.split(/[,;\n]+/)
|
||||
.map((n) => n.trim())
|
||||
.filter(Boolean);
|
||||
if (selected.length) {
|
||||
const weights = await this.bookingContainerWeights(item.bookingId);
|
||||
const byNumber = new Map(weights.map((w) => [w.containerNumber.toUpperCase(), w.weightTons]));
|
||||
const heldTons = Number(
|
||||
selected.reduce((sum, n) => sum + (byNumber.get(n.toUpperCase()) ?? 0), 0).toFixed(3),
|
||||
);
|
||||
if (heldTons > 0) exitInspectionDto.netWeight = heldTons;
|
||||
}
|
||||
}
|
||||
const exitInspectionNote = this.buildExitInspectionNote(exitInspectionDto);
|
||||
|
||||
// The load actually leaving on this truck, in TONNES (the weighing UI is in
|
||||
@@ -2990,12 +3049,46 @@ export class WarehouseInventoryService {
|
||||
? Math.round((grossTons - tareTons) * 1000) / 1000
|
||||
: (exitInspectionDto.netWeight ?? null);
|
||||
|
||||
// The weight to record on the inventory when this truck leaves: prefer the
|
||||
// item's own container cargo weight (a truck may carry other items too);
|
||||
// fall back to the truck's recorded net. Fills an empty weight only.
|
||||
let recordedItemTons: number | null = null;
|
||||
if (isTruckLeaving && netTons != null) {
|
||||
recordedItemTons = netTons;
|
||||
if (item.containerId && item.bookingId) {
|
||||
const [cont]: Array<{ containerNumber: string | null }> = await this.dataSource.query(
|
||||
`SELECT container_number AS "containerNumber" FROM freight.containers WHERE id = $1`,
|
||||
[item.containerId],
|
||||
);
|
||||
const ownNumber = cont?.containerNumber?.trim().toUpperCase();
|
||||
if (ownNumber) {
|
||||
const weights = await this.bookingContainerWeights(item.bookingId);
|
||||
const own = weights.find((w) => w.containerNumber.toUpperCase() === ownNumber);
|
||||
if (own && own.weightTons > 0) recordedItemTons = own.weightTons;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(WarehouseInventory).update(id, {
|
||||
releaseDate,
|
||||
releaseOrderReference: reference,
|
||||
notes: this.replaceExitInspectionNote(item.notes, exitInspectionNote),
|
||||
notes: this.replaceExitInspectionNote(
|
||||
item.notes,
|
||||
exitInspectionNote,
|
||||
exitInspectionDto.truckPlateNumber,
|
||||
),
|
||||
});
|
||||
// Even an unweighed truck records the inventory weight it is holding —
|
||||
// without this the handover/exit papers print "0 t" for skipped weighings.
|
||||
if (recordedItemTons != null) {
|
||||
await manager.query(
|
||||
`UPDATE freight.warehouse_inventory
|
||||
SET weight = $2, updated_at = NOW()
|
||||
WHERE id = $1 AND COALESCE(weight, 0) = 0`,
|
||||
[id, recordedItemTons],
|
||||
);
|
||||
}
|
||||
if (!isTruckLeaving && item.bookingId) {
|
||||
// Per-truck arrival: mark the customer truck carrying THIS item's
|
||||
// container as arrived (matched via the physical container number).
|
||||
@@ -3058,7 +3151,7 @@ export class WarehouseInventoryService {
|
||||
// EDR last-mile: this truck is leaving — record its exit and the load it
|
||||
// actually took. net_weight_tons drives the bulk drawdown (booking VGM
|
||||
// minus everything already hauled away).
|
||||
await manager.query(
|
||||
const [edrDeparted] = (await manager.query(
|
||||
`UPDATE freight.last_mile_vehicle_assignments va
|
||||
SET departed_at = COALESCE($3::timestamptz, NOW()),
|
||||
arrived_at = COALESCE(va.arrived_at, NOW()),
|
||||
@@ -3072,7 +3165,8 @@ export class WarehouseInventoryService {
|
||||
AND v.id = va.vehicle_id
|
||||
AND (UPPER(v.power_plate_no) = UPPER($2) OR UPPER(v.plate_number) = UPPER($2))
|
||||
AND va.departed_at IS NULL
|
||||
AND va.deleted_at IS NULL`,
|
||||
AND va.deleted_at IS NULL
|
||||
RETURNING va.id`,
|
||||
[
|
||||
item.bookingId,
|
||||
dto.truckPlateNumber.trim(),
|
||||
@@ -3080,7 +3174,31 @@ export class WarehouseInventoryService {
|
||||
grossTons,
|
||||
netTons,
|
||||
],
|
||||
);
|
||||
)) as [Array<{ id: string }>, unknown];
|
||||
// EDR last-mile: the handover is generated the moment the truck exits
|
||||
// (with its exit paper) — one per truck — and the customer is asked to
|
||||
// sign it from the portal. Booking-level fallback when the plate matched
|
||||
// no live assignment (e.g. exit re-recorded) but the booking is EDR-hauled.
|
||||
for (const row of edrDeparted) {
|
||||
await this.handover.ensureForDepartedEdrTruck(
|
||||
item.bookingId,
|
||||
{ truckPlate: dto.truckPlateNumber.trim(), edrAssignmentId: row.id },
|
||||
manager,
|
||||
);
|
||||
}
|
||||
if (!edrDeparted.length) {
|
||||
const [lm]: Array<{ id: string }> = await manager.query(
|
||||
`SELECT id FROM freight.last_mile WHERE booking_id = $1 AND deleted_at IS NULL LIMIT 1`,
|
||||
[item.bookingId],
|
||||
);
|
||||
if (lm) {
|
||||
await this.handover.ensureForDepartedEdrTruck(
|
||||
item.bookingId,
|
||||
{ truckPlate: dto.truckPlateNumber.trim() },
|
||||
manager,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Customer self-haul: the same exit record on the customer's own truck.
|
||||
// Without it a self-haul bulk booking never draws down — hauled tonnage
|
||||
// summed to zero and the booking could take unlimited trucks. Matched by
|
||||
@@ -3130,11 +3248,43 @@ export class WarehouseInventoryService {
|
||||
// the transaction and fire-and-forget: notifying must never fail the exit.
|
||||
if (isTruckLeaving && item.bookingId) {
|
||||
void this.notifyTruckDeparture(item.bookingId, dto.truckPlateNumber?.trim() ?? null, netTons);
|
||||
} else if (item.bookingId) {
|
||||
// Gate-in: same single-path hook for the arrival side (self-haul + EDR).
|
||||
void this.notifyTruckArrival(item.bookingId, dto.truckPlateNumber?.trim() ?? null);
|
||||
}
|
||||
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
/** Best-effort truck-arrival notification (gate-in), mirror of the departure one. */
|
||||
private async notifyTruckArrival(bookingId: string, plateNumber: string | null): Promise<void> {
|
||||
try {
|
||||
const [booking]: Array<{ companyId: string | null; reference: string | null }> =
|
||||
await this.dataSource.query(
|
||||
`SELECT company_id AS "companyId", reference
|
||||
FROM freight.bookings
|
||||
WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[bookingId],
|
||||
);
|
||||
if (!booking?.companyId) return;
|
||||
const ref = booking.reference ?? bookingId;
|
||||
const truck = plateNumber ? `Truck ${plateNumber}` : 'A truck';
|
||||
const body = `${truck} has arrived at the warehouse for booking ${ref}.`;
|
||||
await this.inbox.notify({
|
||||
recipients: { companyId: booking.companyId },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.BOOKING_STATUS,
|
||||
title: 'Truck arrived at the warehouse',
|
||||
body,
|
||||
link: `/bookings/${bookingId}`,
|
||||
data: { bookingId, plateNumber, action: 'TRUCK_ARRIVED' },
|
||||
});
|
||||
await sendCompanyChannels(this.dataSource, this.notifications, booking.companyId, body);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Truck-arrival notify failed for ${bookingId}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort truck-departure notification to the booking's company across
|
||||
* every channel: in-app (portal inbox) + SMS + email. Never throws — a missing
|
||||
@@ -3816,8 +3966,213 @@ export class WarehouseInventoryService {
|
||||
};
|
||||
}
|
||||
|
||||
/** Handover PDF resolved by booking (for the portal, which only has bookingId). */
|
||||
async handoverDocumentForBooking(bookingId: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
/**
|
||||
* Customer signs ONE handover from the portal (EDR last-mile: one per truck).
|
||||
* When the last one is signed — and every EDR truck has left the warehouse —
|
||||
* the delivery completes automatically: inventory + cargo delivered, last-mile
|
||||
* leg DELIVERED (trucks freed), booking completed ("shipment delivered").
|
||||
*/
|
||||
async signHandover(
|
||||
handoverId: string,
|
||||
userId?: string,
|
||||
signerName?: string,
|
||||
): Promise<{
|
||||
handoverId: string;
|
||||
bookingId: string;
|
||||
signedAt: string | null;
|
||||
signerDisplayName: string;
|
||||
allSigned: boolean;
|
||||
}> {
|
||||
if (!userId) {
|
||||
throw new BadRequestException('Authentication is required to sign the handover');
|
||||
}
|
||||
const name = signerName?.trim();
|
||||
if (!name) {
|
||||
throw new BadRequestException('Please enter your full name to sign the handover');
|
||||
}
|
||||
|
||||
const [h]: Array<{
|
||||
bookingId: string;
|
||||
reference: string;
|
||||
truckPlate: string | null;
|
||||
mileType: string;
|
||||
edrAssignmentId: string | null;
|
||||
}> = await this.dataSource.query(
|
||||
`SELECT booking_id AS "bookingId", reference, truck_plate AS "truckPlate",
|
||||
mile_type AS "mileType", edr_assignment_id AS "edrAssignmentId"
|
||||
FROM freight.booking_handovers
|
||||
WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[handoverId],
|
||||
);
|
||||
if (!h) throw new NotFoundException(`Handover ${handoverId} not found`);
|
||||
// Self-haul stays a single booking-level signature via approve-delivery,
|
||||
// which also enforces inspection-passed + truck-arrived. Per-truck signing
|
||||
// is an EDR last-mile flow only.
|
||||
if (h.mileType !== 'EDR_LAST_MILE') {
|
||||
throw new BadRequestException(
|
||||
'This handover is signed through Approve delivery, not per truck',
|
||||
);
|
||||
}
|
||||
|
||||
// Same gate as approve-delivery: storage/demurrage must be settled first.
|
||||
const [inv]: Array<{ id: string; warehouseId: string | null }> = await this.dataSource.query(
|
||||
`SELECT id, warehouse_id AS "warehouseId" FROM freight.warehouse_inventory
|
||||
WHERE booking_id = $1 AND deleted_at IS NULL
|
||||
ORDER BY updated_at DESC NULLS LAST, created_at DESC LIMIT 1`,
|
||||
[h.bookingId],
|
||||
);
|
||||
if (inv) await this.invoices.assertClearanceAllowed(inv.id);
|
||||
|
||||
const signed = await this.handover.sign(handoverId, userId, name);
|
||||
const allSigned = await this.handover.isFullySigned(h.bookingId);
|
||||
|
||||
if (inv) {
|
||||
await this.activityLog.record({
|
||||
activityType: 'INVENTORY_RELEASED',
|
||||
inventoryId: inv.id,
|
||||
warehouseId: inv.warehouseId,
|
||||
description: `Customer signed handover ${h.reference}${h.truckPlate ? ` (truck ${h.truckPlate})` : ''} as ${name}`,
|
||||
performedBy: name,
|
||||
});
|
||||
}
|
||||
|
||||
// EDR last-mile delivers PER TRUCK: this signature confirms receipt of the
|
||||
// goods THIS truck carried, so only its containers become DELIVERED now.
|
||||
// (Self-haul keeps the single booking-level handover + manual Deliver.)
|
||||
if (h.mileType === 'EDR_LAST_MILE') {
|
||||
try {
|
||||
await this.deliverEdrTruckContainers(h, name);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Per-truck auto-deliver after handover sign failed for ${h.bookingId}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (allSigned) {
|
||||
void this.completeEdrDeliveryIfReady(h.bookingId, name).catch((err: Error) =>
|
||||
this.logger.warn(`Auto-complete after handover sign failed for ${h.bookingId}: ${err.message}`),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
handoverId,
|
||||
bookingId: h.bookingId,
|
||||
signedAt: signed.signedAt ? new Date(signed.signedAt).toISOString() : null,
|
||||
signerDisplayName: name,
|
||||
allSigned,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* EDR last-mile auto-completion: once every handover is signed and every EDR
|
||||
* truck has departed, deliver the remaining inventory, mark the last-mile leg
|
||||
* DELIVERED and complete the booking. Self-haul bookings keep their manual
|
||||
* Deliver flow (no last_mile record ⇒ no-op).
|
||||
*/
|
||||
private async completeEdrDeliveryIfReady(bookingId: string, signerName: string): Promise<void> {
|
||||
const [lm]: Array<{ id: string; status: string }> = await this.dataSource.query(
|
||||
`SELECT id, status FROM freight.last_mile
|
||||
WHERE booking_id = $1 AND deleted_at IS NULL LIMIT 1`,
|
||||
[bookingId],
|
||||
);
|
||||
if (!lm) return;
|
||||
|
||||
const [pending]: Array<{ notDeparted: string }> = await this.dataSource.query(
|
||||
`SELECT COUNT(*) FILTER (WHERE va.departed_at IS NULL) AS "notDeparted"
|
||||
FROM freight.last_mile_vehicle_assignments va
|
||||
JOIN freight.last_mile l ON l.id = va.last_mile_id AND l.deleted_at IS NULL
|
||||
WHERE l.booking_id = $1 AND va.deleted_at IS NULL`,
|
||||
[bookingId],
|
||||
);
|
||||
if (Number(pending?.notDeparted ?? 0) > 0) return;
|
||||
if (!(await this.handover.isFullySigned(bookingId))) return;
|
||||
|
||||
const items: Array<{ id: string }> = await this.dataSource.query(
|
||||
`SELECT id FROM freight.warehouse_inventory
|
||||
WHERE booking_id = $1 AND status = 'READY_FOR_PICKUP' AND deleted_at IS NULL`,
|
||||
[bookingId],
|
||||
);
|
||||
for (const it of items) {
|
||||
try {
|
||||
await this.deliver(it.id, {
|
||||
receiverName: signerName,
|
||||
remarks: 'Auto-delivered on customer handover signature',
|
||||
performedBy: signerName,
|
||||
} as DeliverInventoryDto);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Auto-deliver of inventory ${it.id} failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (lm.status !== 'DELIVERED') {
|
||||
try {
|
||||
await this.lastMileService.update(lm.id, { status: 'DELIVERED' } as UpdateLastMileDto);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Auto-deliver of last-mile ${lm.id} failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Booking → COMPLETED ("shipment delivered" notification) — owned by the
|
||||
// bookings module; evented to avoid a warehouses→bookings service dependency.
|
||||
this.events.emit('import.handover.completed', { bookingId });
|
||||
}
|
||||
|
||||
/**
|
||||
* EDR last-mile per-truck delivery: the customer signed THIS truck's handover,
|
||||
* so only the container items that truck carried become DELIVERED. Bulk cargo
|
||||
* (no container rows) is delivered by completeEdrDeliveryIfReady once every
|
||||
* truck is signed off.
|
||||
*/
|
||||
private async deliverEdrTruckContainers(
|
||||
h: { bookingId: string; edrAssignmentId: string | null; truckPlate: string | null },
|
||||
signerName: string,
|
||||
): Promise<void> {
|
||||
if (!h.edrAssignmentId && !h.truckPlate) return;
|
||||
const items: Array<{ id: string }> = await this.dataSource.query(
|
||||
`SELECT DISTINCT inv.id
|
||||
FROM freight.last_mile_vehicle_assignments va
|
||||
JOIN freight.last_mile l ON l.id = va.last_mile_id AND l.deleted_at IS NULL
|
||||
LEFT JOIN freight.last_mile_vehicle_containers vc
|
||||
ON vc.assignment_id = va.id AND vc.deleted_at IS NULL
|
||||
LEFT JOIN freight.vehicles v ON v.id = va.vehicle_id
|
||||
JOIN freight.containers c
|
||||
ON c.container_number = COALESCE(vc.container_number, va.container_number)
|
||||
AND c.deleted_at IS NULL
|
||||
JOIN freight.warehouse_inventory inv
|
||||
ON inv.container_id = c.id AND inv.booking_id = l.booking_id AND inv.deleted_at IS NULL
|
||||
WHERE l.booking_id = $1
|
||||
AND va.deleted_at IS NULL
|
||||
AND inv.status = 'READY_FOR_PICKUP'
|
||||
AND (va.id = $2::uuid
|
||||
OR ($2::uuid IS NULL
|
||||
AND (UPPER(v.power_plate_no) = UPPER($3) OR UPPER(v.plate_number) = UPPER($3))))`,
|
||||
[h.bookingId, h.edrAssignmentId, h.truckPlate ?? ''],
|
||||
);
|
||||
for (const it of items) {
|
||||
try {
|
||||
await this.deliver(it.id, {
|
||||
receiverName: signerName,
|
||||
remarks: `Auto-delivered on customer handover signature${h.truckPlate ? ` (truck ${h.truckPlate})` : ''}`,
|
||||
performedBy: signerName,
|
||||
} as DeliverInventoryDto);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Per-truck auto-deliver of inventory ${it.id} failed: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handover PDF resolved by booking (for the portal, which only has bookingId).
|
||||
* With `handoverId` the document is rendered for that specific handover — the
|
||||
* per-truck EDR last-mile variant (truck plate + that truck's signature state).
|
||||
*/
|
||||
async handoverDocumentForBooking(
|
||||
bookingId: string,
|
||||
handoverId?: string,
|
||||
): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const [inv]: Array<{ id: string }> = await this.dataSource.query(
|
||||
`SELECT id FROM freight.warehouse_inventory
|
||||
WHERE booking_id = $1 AND deleted_at IS NULL
|
||||
@@ -3828,7 +4183,29 @@ export class WarehouseInventoryService {
|
||||
if (!inv) {
|
||||
throw new NotFoundException(`No warehouse inventory found for booking ${bookingId}`);
|
||||
}
|
||||
return this.handoverDocument(inv.id);
|
||||
if (!handoverId) return this.handoverDocument(inv.id);
|
||||
|
||||
const [h]: Array<{
|
||||
reference: string;
|
||||
truckPlate: string | null;
|
||||
signedAt: string | null;
|
||||
signerName: string | null;
|
||||
}> = await this.dataSource.query(
|
||||
`SELECT reference, truck_plate AS "truckPlate",
|
||||
signed_at AS "signedAt", signer_name AS "signerName"
|
||||
FROM freight.booking_handovers
|
||||
WHERE id = $1 AND booking_id = $2 AND deleted_at IS NULL`,
|
||||
[handoverId, bookingId],
|
||||
);
|
||||
if (!h) {
|
||||
throw new NotFoundException(`Handover ${handoverId} not found for booking ${bookingId}`);
|
||||
}
|
||||
return this.handoverDocument(inv.id, {
|
||||
reference: h.reference,
|
||||
truckPlate: h.truckPlate,
|
||||
signedAt: h.signedAt ? new Date(h.signedAt) : null,
|
||||
signerName: h.signerName,
|
||||
});
|
||||
}
|
||||
|
||||
/** Resolve the primary warehouse-inventory item for a booking (most recent). */
|
||||
@@ -3856,12 +4233,22 @@ export class WarehouseInventoryService {
|
||||
return this.releaseDocument(await this.primaryInventoryIdForBooking(bookingId));
|
||||
}
|
||||
|
||||
async handoverDocument(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
async handoverDocument(
|
||||
id: string,
|
||||
perTruck?: {
|
||||
reference: string;
|
||||
truckPlate: string | null;
|
||||
signedAt: Date | null;
|
||||
signerName: string | null;
|
||||
},
|
||||
): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const [row] = await this.dataSource.query(
|
||||
`SELECT inv.id,
|
||||
inv.booking_id AS "bookingId",
|
||||
inv.quantity,
|
||||
inv.weight,
|
||||
-- An unweighed item still reports the cargo weight it holds: fall
|
||||
-- back to the item's container VGM when no weight was recorded.
|
||||
COALESCE(NULLIF(inv.weight, 0), item_vgm.tons, 0) AS weight,
|
||||
inv.status,
|
||||
inv.notes,
|
||||
inv.inspection_status AS "inspectionStatus",
|
||||
@@ -3913,6 +4300,16 @@ export class WarehouseInventoryService {
|
||||
WHERE bc.booking_id = b.id
|
||||
AND bc.deleted_at IS NULL
|
||||
) booking_container ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT SUM(bcu.vgm_tons) AS tons
|
||||
FROM freight.booking_container_units bcu
|
||||
JOIN freight.booking_container bc2
|
||||
ON bc2.id = bcu.booking_container_id AND bc2.deleted_at IS NULL
|
||||
WHERE bc2.booking_id = b.id
|
||||
AND bcu.deleted_at IS NULL
|
||||
AND (container.container_number IS NULL
|
||||
OR bcu.container_number = container.container_number)
|
||||
) item_vgm ON true
|
||||
LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL
|
||||
LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id)
|
||||
LEFT JOIN freight.train_schedule_bookings tsb ON tsb.booking_id = b.id AND tsb.deleted_at IS NULL
|
||||
@@ -3931,12 +4328,14 @@ export class WarehouseInventoryService {
|
||||
|
||||
const bookingReference = row.bookingReference || row.bookingId || 'N/A';
|
||||
const reference =
|
||||
perTruck?.reference ||
|
||||
this.extractHandoverDocumentLine(row.notes, 'Handover Reference') ||
|
||||
`HND-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}`;
|
||||
const generatedAtValue = this.extractHandoverDocumentLine(row.notes, 'Generated At');
|
||||
const generatedAt = generatedAtValue ? new Date(generatedAtValue) : new Date();
|
||||
const handedOverAt = Number.isNaN(generatedAt.getTime()) ? new Date() : generatedAt;
|
||||
if (!generatedAtValue) {
|
||||
// Per-truck renders must not stamp their reference into the shared item notes.
|
||||
if (!generatedAtValue && !perTruck) {
|
||||
await this.inventoryRepository.update(id, {
|
||||
notes: this.replaceHandoverDocumentNote(row.notes, this.buildHandoverDocumentNote(reference, handedOverAt)),
|
||||
});
|
||||
@@ -3970,7 +4369,16 @@ export class WarehouseInventoryService {
|
||||
releaseDate: row.releaseDate ? new Date(row.releaseDate) : null,
|
||||
trainSchedule: row.trainSchedule ?? null,
|
||||
lastMileDeliveryAddress: row.lastMileDeliveryAddress ?? null,
|
||||
customerApproval: this.extractCustomerDeliveryApproval(row.notes),
|
||||
truckPlate: perTruck?.truckPlate ?? null,
|
||||
customerApproval: perTruck
|
||||
? perTruck.signedAt
|
||||
? {
|
||||
approvedAt: perTruck.signedAt.toISOString(),
|
||||
signerDisplayName: perTruck.signerName ?? '-',
|
||||
signatureImageUrl: null,
|
||||
}
|
||||
: null
|
||||
: this.extractCustomerDeliveryApproval(row.notes),
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -4009,14 +4417,61 @@ export class WarehouseInventoryService {
|
||||
if (!(await this.handover.isFullySigned(item.bookingId))) {
|
||||
throw new BadRequestException('Handover must be signed before delivery');
|
||||
}
|
||||
const [left]: Array<{ n: string }> = await this.dataSource.query(
|
||||
`SELECT COUNT(*) AS n FROM freight.customer_truck_assignments
|
||||
WHERE booking_id = $1 AND departed_at IS NOT NULL AND deleted_at IS NULL`,
|
||||
const [trucks]: Array<{ total: string; left: string }> = await this.dataSource.query(
|
||||
`SELECT COUNT(*) AS total,
|
||||
COUNT(*) FILTER (WHERE departed_at IS NOT NULL) AS "left"
|
||||
FROM freight.customer_truck_assignments
|
||||
WHERE booking_id = $1 AND deleted_at IS NULL`,
|
||||
[item.bookingId],
|
||||
);
|
||||
if (Number(left?.n ?? 0) === 0) {
|
||||
const totalTrucks = Number(trucks?.total ?? 0);
|
||||
const leftTrucks = Number(trucks?.left ?? 0);
|
||||
if (leftTrucks === 0) {
|
||||
throw new BadRequestException('Deliver is available only after the customer truck has left');
|
||||
}
|
||||
// Multi-truck booking: every assigned truck must arrive and leave —
|
||||
// each is weighed out separately before the goods count as delivered.
|
||||
if (leftTrucks < totalTrucks) {
|
||||
throw new BadRequestException(
|
||||
`Deliver is available only after every assigned truck has left (${leftTrucks} of ${totalTrucks} so far)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// EDR last-mile delivers per truck: a container item only needs the truck
|
||||
// CARRYING IT to have left; bulk (no container) waits for every truck.
|
||||
if (item.containerId) {
|
||||
const [own]: Array<{ pending: string }> = await this.dataSource.query(
|
||||
`SELECT COUNT(*) FILTER (WHERE va.departed_at IS NULL) AS pending
|
||||
FROM freight.last_mile_vehicle_assignments va
|
||||
JOIN freight.last_mile l ON l.id = va.last_mile_id AND l.deleted_at IS NULL
|
||||
LEFT JOIN freight.last_mile_vehicle_containers vc
|
||||
ON vc.assignment_id = va.id AND vc.deleted_at IS NULL
|
||||
JOIN freight.containers c ON c.id = $2 AND c.deleted_at IS NULL
|
||||
WHERE l.booking_id = $1 AND va.deleted_at IS NULL
|
||||
AND COALESCE(vc.container_number, va.container_number) = c.container_number`,
|
||||
[item.bookingId, item.containerId],
|
||||
);
|
||||
if (Number(own?.pending ?? 0) > 0) {
|
||||
throw new BadRequestException(
|
||||
'Deliver is available only after the EDR truck carrying this container has left',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const [lm]: Array<{ total: string; left: string }> = await this.dataSource.query(
|
||||
`SELECT COUNT(*) AS total,
|
||||
COUNT(*) FILTER (WHERE va.departed_at IS NOT NULL) AS "left"
|
||||
FROM freight.last_mile_vehicle_assignments va
|
||||
JOIN freight.last_mile l ON l.id = va.last_mile_id AND l.deleted_at IS NULL
|
||||
WHERE l.booking_id = $1 AND va.deleted_at IS NULL`,
|
||||
[item.bookingId],
|
||||
);
|
||||
const lmTotal = Number(lm?.total ?? 0);
|
||||
const lmLeft = Number(lm?.left ?? 0);
|
||||
if (lmTotal > 0 && lmLeft < lmTotal) {
|
||||
throw new BadRequestException(
|
||||
`Deliver is available only after every assigned EDR truck has left (${lmLeft} of ${lmTotal} so far)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4112,6 +4567,12 @@ export class WarehouseInventoryService {
|
||||
}
|
||||
});
|
||||
|
||||
// "Approve delivery" nudge: on Deliver the customer is reminded to sign any
|
||||
// handover still unsigned (per truck for EDR last-mile). Fire-and-forget.
|
||||
if (item.bookingId) {
|
||||
void this.handover.notifyUnsignedForBooking(item.bookingId).catch(() => undefined);
|
||||
}
|
||||
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
@@ -4839,10 +5300,11 @@ export class WarehouseInventoryService {
|
||||
releaseDate: Date | null;
|
||||
trainSchedule: string | null;
|
||||
lastMileDeliveryAddress: string | null;
|
||||
truckPlate?: string | null;
|
||||
customerApproval: {
|
||||
approvedAt: string;
|
||||
signerDisplayName: string;
|
||||
signatureImageUrl: string;
|
||||
signatureImageUrl: string | null;
|
||||
} | null;
|
||||
}): string {
|
||||
const esc = (value: unknown) =>
|
||||
@@ -4888,6 +5350,7 @@ export class WarehouseInventoryService {
|
||||
['Release Order', data.releaseOrderReference],
|
||||
['Release Date', fmt(data.releaseDate)],
|
||||
['Last-mile Delivery Address', data.lastMileDeliveryAddress],
|
||||
...(data.truckPlate ? [['Delivering Truck Plate', data.truckPlate]] : []),
|
||||
];
|
||||
const approval = data.customerApproval;
|
||||
|
||||
@@ -5297,7 +5760,11 @@ export class WarehouseInventoryService {
|
||||
weighingSkipped ? 'Weighing: SKIPPED' : null,
|
||||
tareWeight == null ? null : `Tare Weight: ${tareWeight} t`,
|
||||
grossWeight == null ? null : `Gross Weight: ${grossWeight} t`,
|
||||
computedNetWeight == null ? null : `Net Weight: ${computedNetWeight} t`,
|
||||
// Skipped weighing still records a net — the cargo weight of the
|
||||
// containers the truck is holding, resolved by the caller.
|
||||
(computedNetWeight ?? (weighingSkipped ? dto.netWeight : null)) == null
|
||||
? null
|
||||
: `Net Weight: ${computedNetWeight ?? Number(dto.netWeight)} t`,
|
||||
dto.gateOutTime ? `Gate Out Time: ${dto.gateOutTime}` : null,
|
||||
];
|
||||
|
||||
@@ -5305,12 +5772,14 @@ export class WarehouseInventoryService {
|
||||
}
|
||||
|
||||
private preserveTruckArrivalForExit(dto: ReleaseOrderDto, notes: string | null | undefined): ReleaseOrderDto {
|
||||
const inspection = this.extractExitInspectionNote(notes);
|
||||
const inspection = this.extractExitInspectionForPlate(notes, dto.truckPlateNumber);
|
||||
if (!inspection) return dto;
|
||||
|
||||
return {
|
||||
...dto,
|
||||
truckPlateNumber: this.extractExitInspectionLine(inspection, 'Truck Plate') || dto.truckPlateNumber,
|
||||
// The submitted plate wins: a legacy block may store a comma-joined list
|
||||
// of plates, and the exit must be recorded against the ONE truck leaving.
|
||||
truckPlateNumber: dto.truckPlateNumber?.trim() || this.extractExitInspectionLine(inspection, 'Truck Plate') || dto.truckPlateNumber,
|
||||
trailerPlateNumber: this.extractExitInspectionLine(inspection, 'Trailer Plate') || dto.trailerPlateNumber,
|
||||
driverName: this.extractExitInspectionLine(inspection, 'Driver') || dto.driverName,
|
||||
driverLicense: this.extractExitInspectionLine(inspection, 'Driver License') || dto.driverLicense,
|
||||
@@ -5324,25 +5793,94 @@ export class WarehouseInventoryService {
|
||||
};
|
||||
}
|
||||
|
||||
private replaceExitInspectionNote(notes: string | null | undefined, exitInspectionNote: string | null): string | null {
|
||||
/**
|
||||
* Split notes into exit-inspection blocks (one per truck, in order) and
|
||||
* everything else. A block ends at the first line that isn't one of the
|
||||
* known inspection labels, so appended notes (delivery approval, handover
|
||||
* marker) are preserved as "other" content instead of being swallowed by
|
||||
* the block they happen to follow.
|
||||
*/
|
||||
private splitExitInspectionSections(notes?: string | null): { others: string[]; blocks: string[] } {
|
||||
const trimmed = notes?.trim();
|
||||
if (!exitInspectionNote) return trimmed || null;
|
||||
if (!trimmed) return exitInspectionNote;
|
||||
|
||||
const marker = '[Exit Inspection]';
|
||||
const index = trimmed.lastIndexOf(marker);
|
||||
if (index < 0) {
|
||||
return `${trimmed}\n\n${exitInspectionNote}`;
|
||||
if (!trimmed) return { others: [], blocks: [] };
|
||||
const labelPattern =
|
||||
/^(Booking ID|Customer ID|Truck Plate|Trailer Plate|Driver|Driver License|Driver Phone|Truck Type|Container Number|Gate In Time|Weighing|Tare Weight|Gross Weight|Net Weight|Gate Out Time):/i;
|
||||
const parts = trimmed.split(EXIT_INSPECTION_MARKER);
|
||||
const others: string[] = [];
|
||||
const blocks: string[] = [];
|
||||
if (parts[0]?.trim()) others.push(parts[0].trim());
|
||||
for (const part of parts.slice(1)) {
|
||||
const lines = part.split('\n');
|
||||
const kept: string[] = [];
|
||||
let i = 0;
|
||||
while (i < lines.length && !lines[i].trim()) i += 1;
|
||||
for (; i < lines.length; i += 1) {
|
||||
const line = lines[i].trim();
|
||||
if (!line || !labelPattern.test(line)) break;
|
||||
kept.push(line);
|
||||
}
|
||||
if (kept.length) blocks.push(kept.join('\n'));
|
||||
const tail = lines.slice(i).join('\n').trim();
|
||||
if (tail) others.push(tail);
|
||||
}
|
||||
return [trimmed.slice(0, index).trim(), exitInspectionNote].filter(Boolean).join('\n\n');
|
||||
return { others, blocks };
|
||||
}
|
||||
|
||||
/**
|
||||
* A block belongs to a plate when its stored `Truck Plate` equals it, or is a
|
||||
* legacy comma-joined list ("P1, P2") containing it.
|
||||
*/
|
||||
private blockMatchesPlate(block: string, plateNumber?: string | null): boolean {
|
||||
const plate = plateNumber?.trim().toUpperCase();
|
||||
if (!plate) return false;
|
||||
const stored = this.extractExitInspectionLine(block, 'Truck Plate')?.toUpperCase();
|
||||
if (!stored) return false;
|
||||
if (stored === plate) return true;
|
||||
return stored.split(/[,;]+/).map((p) => p.trim()).includes(plate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace THIS truck's inspection block (matched by plate), keeping every
|
||||
* other truck's block untouched; append when the plate has no block yet.
|
||||
* A single legacy block (comma-joined plates or plate-less caller) is
|
||||
* replaced in place so old single-truck items keep their behaviour.
|
||||
*/
|
||||
private replaceExitInspectionNote(
|
||||
notes: string | null | undefined,
|
||||
exitInspectionNote: string | null,
|
||||
plateNumber?: string | null,
|
||||
): string | null {
|
||||
const { others, blocks } = this.splitExitInspectionSections(notes);
|
||||
if (exitInspectionNote) {
|
||||
const content = exitInspectionNote.replace(EXIT_INSPECTION_MARKER, '').trim();
|
||||
const index = plateNumber
|
||||
? blocks.findIndex((b) => this.blockMatchesPlate(b, plateNumber))
|
||||
: blocks.length - 1;
|
||||
if (index >= 0) blocks[index] = content;
|
||||
else blocks.push(content);
|
||||
}
|
||||
const sections = [...others, ...blocks.map((b) => `${EXIT_INSPECTION_MARKER}\n${b}`)];
|
||||
return sections.join('\n\n') || null;
|
||||
}
|
||||
|
||||
/** Latest truck's inspection block — legacy summary for documents. */
|
||||
private extractExitInspectionNote(notes?: string | null): string | null {
|
||||
if (!notes) return null;
|
||||
const marker = '[Exit Inspection]';
|
||||
const index = notes.lastIndexOf(marker);
|
||||
if (index < 0) return null;
|
||||
return notes.slice(index + marker.length).trim() || null;
|
||||
const { blocks } = this.splitExitInspectionSections(notes);
|
||||
return blocks.length ? blocks[blocks.length - 1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The inspection block for one truck. Falls back to a lone existing block so
|
||||
* legacy single-truck items (saved before per-plate blocks) keep working.
|
||||
*/
|
||||
private extractExitInspectionForPlate(
|
||||
notes: string | null | undefined,
|
||||
plateNumber?: string | null,
|
||||
): string | null {
|
||||
const { blocks } = this.splitExitInspectionSections(notes);
|
||||
const match = blocks.find((b) => this.blockMatchesPlate(b, plateNumber));
|
||||
if (match) return match;
|
||||
return blocks.length === 1 ? blocks[0] : null;
|
||||
}
|
||||
|
||||
private extractExitInspectionLine(note: string | null | undefined, label: string): string | null {
|
||||
|
||||
@@ -249,6 +249,16 @@ const FleetFormDialog = ({
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Format check (e.g. plate numbers). Skipped for an empty optional field —
|
||||
// "required" above already owns the empty case. Upper-cased to match the
|
||||
// server, which stores plates upper-case.
|
||||
if (field.pattern && stringValue && stringValue !== FLEET_SELECT_NONE) {
|
||||
const candidate = field.pattern.uppercase === false ? stringValue : stringValue.toUpperCase();
|
||||
if (!field.pattern.regex.test(candidate)) {
|
||||
next[field.name] = field.pattern.message;
|
||||
}
|
||||
}
|
||||
});
|
||||
setErrors(next);
|
||||
return Object.keys(next).length === 0;
|
||||
|
||||
@@ -2647,7 +2647,9 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
{/* Primary stage action stays visible; the rest live under the kebab. */}
|
||||
{r.currentStatus === 'READY_FOR_PICKUP' && !r.releaseDate && r.hasAssignedTruck && (
|
||||
{/* Stays visible after the first exit — multi-truck bookings
|
||||
weigh each truck in and out until all have left. */}
|
||||
{r.currentStatus === 'READY_FOR_PICKUP' && r.hasAssignedTruck && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
@@ -2655,7 +2657,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
leftSection={<Truck size={14} />}
|
||||
onClick={() => setReleaseItem(toInventoryItem(r))}
|
||||
>
|
||||
{r.releaseOrderReference ? 'Truck Leaving' : 'Truck Arrival'}
|
||||
{r.releaseOrderReference ? 'Truck Arrival / Leaving' : 'Truck Arrival'}
|
||||
</Button>
|
||||
)}
|
||||
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
|
||||
@@ -2692,7 +2694,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
Ready for pickup
|
||||
</Menu.Item>
|
||||
)}
|
||||
{r.currentStatus === 'READY_FOR_PICKUP' && !r.releaseDate && (
|
||||
{r.currentStatus === 'READY_FOR_PICKUP' && (
|
||||
<Menu.Item
|
||||
leftSection={<Truck size={14} />}
|
||||
disabled={!r.hasAssignedTruck}
|
||||
@@ -2700,7 +2702,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
>
|
||||
{r.hasAssignedTruck
|
||||
? r.releaseOrderReference
|
||||
? 'Truck leaving'
|
||||
? 'Truck arrival / leaving'
|
||||
: 'Truck arrival'
|
||||
: 'Truck arrival — assign a truck first'}
|
||||
</Menu.Item>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Alert, Button, Group, Modal, MultiSelect, NumberInput, SegmentedControl, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
|
||||
import { Info, Scale } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Alert, Badge, Button, Group, Modal, MultiSelect, NumberInput, SegmentedControl, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
|
||||
import { Info, Scale, 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 { useToast } from '@/hooks/use-toast';
|
||||
@@ -28,6 +28,8 @@ export interface ReleaseOrderTruckPrefill {
|
||||
containerNumber?: string | null;
|
||||
}
|
||||
|
||||
const EXIT_INSPECTION_MARKER = '[Exit Inspection]';
|
||||
|
||||
const toIsoDateTime = (value: string) => {
|
||||
if (!value) return undefined;
|
||||
const date = new Date(value);
|
||||
@@ -70,9 +72,6 @@ const splitContainerNumbers = (value: string | null | undefined) =>
|
||||
const getItemContainerNumber = (item: WarehouseInventoryItem | null) =>
|
||||
(item as (WarehouseInventoryItem & { containerNumber?: string | null }) | null)?.containerNumber ?? '';
|
||||
|
||||
const assignedTruckValue = (item: WarehouseInventoryItem | null, key: keyof NonNullable<WarehouseInventoryItem['booking']>) =>
|
||||
item?.booking?.[key] == null ? '' : String(item.booking[key]);
|
||||
|
||||
const isContainerInventory = (item: WarehouseInventoryItem | null, containerCount: number) => {
|
||||
const freightType = (item as (WarehouseInventoryItem & { booking?: { freightType?: string | null } | null }) | null)
|
||||
?.booking?.freightType;
|
||||
@@ -88,29 +87,66 @@ const initialContainerNumbers = (item: WarehouseInventoryItem | null, savedConta
|
||||
return Array.from({ length: expectedCount }, (_, index) => sourceNumbers[index] ?? '');
|
||||
};
|
||||
|
||||
const parseInspectionNote = (notes: string | null | undefined) => {
|
||||
const marker = '[Exit Inspection]';
|
||||
const index = notes?.lastIndexOf(marker) ?? -1;
|
||||
const note = index >= 0 ? notes?.slice(index + marker.length) : notes;
|
||||
return {
|
||||
truckPlateNumber: lineValue(note, 'Truck Plate'),
|
||||
trailerPlateNumber: lineValue(note, 'Trailer Plate'),
|
||||
driverName: lineValue(note, 'Driver'),
|
||||
driverLicense: lineValue(note, 'Driver License'),
|
||||
driverPhone: lineValue(note, 'Driver Phone'),
|
||||
truckType: lineValue(note, 'Truck Type'),
|
||||
containerNumber: lineValue(note, 'Container Number'),
|
||||
gateInTime: toLocalDateTimeInput(lineValue(note, 'Gate In Time')),
|
||||
tareWeight: lineNumber(note, 'Tare Weight'),
|
||||
grossWeight: lineNumber(note, 'Gross Weight'),
|
||||
netWeight: lineNumber(note, 'Net Weight'),
|
||||
gateOutTime: toLocalDateTimeInput(lineValue(note, 'Gate Out Time')),
|
||||
weighingSkipped: /^Weighing:\s*SKIPPED/im.test(note ?? ''),
|
||||
};
|
||||
/** One truck's saved arrival/exit weighing, parsed from its inspection block. */
|
||||
interface InspectionBlock {
|
||||
truckPlateNumber: string;
|
||||
trailerPlateNumber: string;
|
||||
driverName: string;
|
||||
driverLicense: string;
|
||||
driverPhone: string;
|
||||
truckType: string;
|
||||
containerNumber: string;
|
||||
gateInTime: string;
|
||||
tareWeight: number | '';
|
||||
grossWeight: number | '';
|
||||
netWeight: number | '';
|
||||
gateOutTime: string;
|
||||
weighingSkipped: boolean;
|
||||
}
|
||||
|
||||
const parseInspectionSection = (note: string): InspectionBlock => ({
|
||||
truckPlateNumber: lineValue(note, 'Truck Plate'),
|
||||
trailerPlateNumber: lineValue(note, 'Trailer Plate'),
|
||||
driverName: lineValue(note, 'Driver'),
|
||||
driverLicense: lineValue(note, 'Driver License'),
|
||||
driverPhone: lineValue(note, 'Driver Phone'),
|
||||
truckType: lineValue(note, 'Truck Type'),
|
||||
containerNumber: lineValue(note, 'Container Number'),
|
||||
gateInTime: toLocalDateTimeInput(lineValue(note, 'Gate In Time')),
|
||||
tareWeight: lineNumber(note, 'Tare Weight'),
|
||||
grossWeight: lineNumber(note, 'Gross Weight'),
|
||||
netWeight: lineNumber(note, 'Net Weight'),
|
||||
gateOutTime: toLocalDateTimeInput(lineValue(note, 'Gate Out Time')),
|
||||
weighingSkipped: /^Weighing:\s*SKIPPED/im.test(note),
|
||||
});
|
||||
|
||||
/** Every truck's saved block — multi-truck bookings weigh each truck separately. */
|
||||
const parseInspectionBlocks = (notes: string | null | undefined): InspectionBlock[] =>
|
||||
(notes ?? '')
|
||||
.split(EXIT_INSPECTION_MARKER)
|
||||
.slice(1)
|
||||
.map(parseInspectionSection)
|
||||
.filter((block) => block.truckPlateNumber);
|
||||
|
||||
/** Match by plate; a legacy block may hold a comma-joined plate list. */
|
||||
const blockForPlate = (blocks: InspectionBlock[], plate: string): InspectionBlock | undefined => {
|
||||
const key = plate.trim().toUpperCase();
|
||||
if (!key) return undefined;
|
||||
return blocks.find((block) => {
|
||||
const stored = block.truckPlateNumber.toUpperCase();
|
||||
return stored === key || stored.split(/[,;]+/).map((p) => p.trim()).includes(key);
|
||||
});
|
||||
};
|
||||
|
||||
const blockArrived = (block: InspectionBlock | undefined) =>
|
||||
Boolean(block && (block.tareWeight !== '' || block.weighingSkipped));
|
||||
|
||||
const blockLeft = (block: InspectionBlock | undefined) =>
|
||||
Boolean(block?.gateOutTime && (block.grossWeight !== '' || block.weighingSkipped));
|
||||
|
||||
export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: ReleaseOrderModalProps) {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const releaseMutation = useMutation(api.warehouses.release.mutationOptions());
|
||||
// Some openers (inventory workbench) supply bookingId without the booking
|
||||
// relation — fall back to it, or the truck/container-weight queries never run.
|
||||
@@ -152,78 +188,16 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
const [netWeight, setNetWeight] = useState<number | ''>('');
|
||||
const [gateOutTime, setGateOutTime] = useState('');
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
// Plate whose saved block was last loaded into the form — stops the
|
||||
// per-plate loader effect from clobbering operator edits in a loop.
|
||||
const loadedPlateRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
const inspection = parseInspectionNote(item?.notes);
|
||||
const assignedTruckPlate = assignedTruckValue(item, 'customerTruckPlateNumber');
|
||||
const assignedDriverName = assignedTruckValue(item, 'customerTruckDriverName');
|
||||
const assignedTruckType = assignedTruckValue(item, 'customerTruckType');
|
||||
const assignedContainerNumber = assignedTruckValue(item, 'customerTruckContainerNumber');
|
||||
const prefillContainerNumber = truckPrefill?.containerNumber ?? '';
|
||||
setReference(item?.releaseOrderReference ?? generateReleaseReference(item));
|
||||
setTruckPlateNumber(inspection.truckPlateNumber || truckPrefill?.truckPlateNumber || assignedTruckPlate || '');
|
||||
setTrailerPlateNumber(inspection.trailerPlateNumber || truckPrefill?.trailerPlateNumber || '');
|
||||
setDriverName(inspection.driverName || truckPrefill?.driverName || assignedDriverName || '');
|
||||
setDriverLicense(inspection.driverLicense || truckPrefill?.driverLicense || '');
|
||||
setDriverPhone(inspection.driverPhone || truckPrefill?.driverPhone || '');
|
||||
setTruckType(inspection.truckType || truckPrefill?.truckType || assignedTruckType || '');
|
||||
setContainerNumbers(initialContainerNumbers(item, inspection.containerNumber || prefillContainerNumber || assignedContainerNumber));
|
||||
setGateInTime(inspection.gateInTime);
|
||||
setTareWeight(inspection.tareWeight);
|
||||
setWeighTruck(inspection.weighingSkipped ? 'no' : 'yes');
|
||||
setGrossWeight(inspection.grossWeight);
|
||||
setNetWeight(item?.weight == null ? inspection.netWeight : Number(item.weight));
|
||||
setGateOutTime(inspection.gateOutTime);
|
||||
}
|
||||
}, [opened, item, truckPrefill]);
|
||||
|
||||
const savedInspection = parseInspectionNote(item?.notes);
|
||||
const isExitStep = savedInspection.tareWeight !== '' || savedInspection.weighingSkipped;
|
||||
const isEntranceLocked = isExitStep;
|
||||
const isCustomerAssignedTruck = Boolean(item?.booking?.customerTruckAssignedAt);
|
||||
const hasLastMileTruckPrefill = Boolean(truckPrefill?.truckPlateNumber || truckPrefill?.trailerPlateNumber);
|
||||
|
||||
// Opened from the warehouse flow (no truckPrefill prop): once the last-mile
|
||||
// truck query resolves, auto-fill the first assigned EDR truck — without
|
||||
// overwriting anything the operator typed or the locked exit-step values.
|
||||
useEffect(() => {
|
||||
if (!opened || truckPrefill || isExitStep) return;
|
||||
const first = lastMileTrucks[0];
|
||||
if (!first) return;
|
||||
setTruckPlateNumber((p) => p || first.truckPlateNumber || '');
|
||||
setTrailerPlateNumber((p) => p || first.trailerPlateNumber || '');
|
||||
setDriverName((p) => p || first.driverName || '');
|
||||
setDriverLicense((p) => p || first.driverLicense || '');
|
||||
setDriverPhone((p) => p || first.driverPhone || '');
|
||||
setTruckType((p) => p || first.truckType || '');
|
||||
setContainerNumbers((prev) =>
|
||||
prev.length === 1 && !prev[0] && first.containerNumber ? [first.containerNumber] : prev,
|
||||
);
|
||||
}, [opened, truckPrefill, isExitStep, lastMileTrucks]);
|
||||
|
||||
// The same for a customer self-haul truck. The prefill above reads the
|
||||
// booking.customer_truck_* columns, but multi-truck self-haul writes the plate
|
||||
// and driver to customer_truck_assignments and leaves those columns null — so
|
||||
// a booking with a truck on file still opened this form blank. Only auto-fills
|
||||
// a single truck: with several, the operator picks which one is at the gate.
|
||||
useEffect(() => {
|
||||
if (!opened || truckPrefill || isExitStep) return;
|
||||
if (customerTrucks.length !== 1) return;
|
||||
const [truck] = customerTrucks;
|
||||
setTruckPlateNumber((p) => p || truck.plateNumber || '');
|
||||
setDriverName((p) => p || truck.driverName || '');
|
||||
setTruckType((p) => p || truck.truckType || '');
|
||||
setContainerNumbers((prev) => {
|
||||
const loaded = (truck.containers ?? []).map((c) => c.containerNumber).filter(Boolean);
|
||||
return prev.every((n) => !n) && loaded.length ? loaded : prev;
|
||||
});
|
||||
}, [opened, truckPrefill, isExitStep, customerTrucks]);
|
||||
const savedBlocks = parseInspectionBlocks(item?.notes);
|
||||
|
||||
// Registered trucks for THIS booking, from both sources: EDR last-mile
|
||||
// (truckPrefill) and the customer portal (customer_truck_assignments).
|
||||
const assignedTruckOptions = [
|
||||
...(truckPrefill?.truckPlateNumber
|
||||
...(truckPrefill?.truckPlateNumber && !truckPrefill.truckPlateNumber.includes(',')
|
||||
? [
|
||||
{
|
||||
value: truckPrefill.truckPlateNumber,
|
||||
@@ -232,6 +206,9 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
driverName: truckPrefill.driverName ?? '',
|
||||
driverPhone: truckPrefill.driverPhone ?? '',
|
||||
truckType: truckPrefill.truckType ?? '',
|
||||
containerNumbers: splitContainerNumbers(truckPrefill.containerNumber),
|
||||
arrived: false,
|
||||
left: false,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
@@ -242,6 +219,9 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
driverName: t.driverName,
|
||||
driverPhone: '',
|
||||
truckType: t.truckType,
|
||||
containerNumbers: (t.containers ?? []).map((c) => c.containerNumber).filter(Boolean),
|
||||
arrived: Boolean(t.arrivedAt),
|
||||
left: Boolean(t.departedAt),
|
||||
})),
|
||||
...lastMileTrucks
|
||||
.filter((t) => t.truckPlateNumber || t.vehicleId)
|
||||
@@ -252,6 +232,9 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
driverName: t.driverName ?? '',
|
||||
driverPhone: t.driverPhone ?? '',
|
||||
truckType: t.truckType ?? '',
|
||||
containerNumbers: splitContainerNumbers(t.containerNumber),
|
||||
arrived: Boolean(t.arrivedAt),
|
||||
left: Boolean(t.departedAt),
|
||||
})),
|
||||
];
|
||||
// Only trucks actually assigned to THIS booking (last-mile prefill or customer
|
||||
@@ -261,10 +244,132 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
const truckSelectOptions = [
|
||||
...new Map(assignedTruckOptions.map((t) => [t.value, t])).values(),
|
||||
];
|
||||
const isCustomerAssignedTruck = Boolean(item?.booking?.customerTruckAssignedAt);
|
||||
// Neither a last-mile truck nor a customer truck has been assigned yet.
|
||||
const noTruckAssigned = assignedTruckOptions.length === 0 && !isCustomerAssignedTruck;
|
||||
const isTruckIdentityLocked = isEntranceLocked || isCustomerAssignedTruck || hasLastMileTruckPrefill;
|
||||
const isDriverNameLocked = isEntranceLocked || isCustomerAssignedTruck || Boolean(truckPrefill?.driverName);
|
||||
|
||||
// Per-truck progress: every truck is weighed in and out on its own; the saved
|
||||
// blocks also cover walk-in trucks that were never formally assigned.
|
||||
const truckProgress = new Map<string, { arrived: boolean; left: boolean }>();
|
||||
for (const option of truckSelectOptions) {
|
||||
truckProgress.set(option.value.trim().toUpperCase(), { arrived: option.arrived, left: option.left });
|
||||
}
|
||||
for (const block of savedBlocks) {
|
||||
const key = block.truckPlateNumber.trim().toUpperCase();
|
||||
const prior = truckProgress.get(key);
|
||||
truckProgress.set(key, {
|
||||
arrived: Boolean(prior?.arrived) || blockArrived(block),
|
||||
left: Boolean(prior?.left) || blockLeft(block),
|
||||
});
|
||||
}
|
||||
const totalTrucks = truckProgress.size;
|
||||
const arrivedTrucks = [...truckProgress.values()].filter((t) => t.arrived).length;
|
||||
const leftTrucks = [...truckProgress.values()].filter((t) => t.left).length;
|
||||
|
||||
// The step is decided PER TRUCK: the selected plate's saved block. A new plate
|
||||
// (or a truck without a saved arrival) starts at the arrival step even when
|
||||
// other trucks of the booking are already mid-flow or gone.
|
||||
const selectedBlock = blockForPlate(savedBlocks, truckPlateNumber);
|
||||
const isExitStep = blockArrived(selectedBlock);
|
||||
const hasTruckLeft = blockLeft(selectedBlock);
|
||||
const isEntranceLocked = isExitStep;
|
||||
const selectedOption = truckSelectOptions.find(
|
||||
(option) => option.value.trim().toUpperCase() === truckPlateNumber.trim().toUpperCase(),
|
||||
);
|
||||
// Identity comes from the arrival record or the assignment — locked either
|
||||
// way. A walk-in truck (typed plate, no assignment) stays editable at arrival.
|
||||
const isTruckIdentityLocked = isEntranceLocked || Boolean(selectedOption);
|
||||
const isDriverNameLocked = isEntranceLocked || Boolean(selectedOption?.driverName);
|
||||
const referenceLocked = Boolean(item?.releaseOrderReference) || savedBlocks.length > 0;
|
||||
|
||||
/** Load a truck into the form: its saved block if any, else its assignment. */
|
||||
const applyTruckSelection = (plate: string) => {
|
||||
const block = blockForPlate(savedBlocks, plate);
|
||||
const option = truckSelectOptions.find(
|
||||
(o) => o.value.trim().toUpperCase() === plate.trim().toUpperCase(),
|
||||
);
|
||||
loadedPlateRef.current = plate.trim().toUpperCase();
|
||||
setTruckPlateNumber(plate);
|
||||
setTrailerPlateNumber(block?.trailerPlateNumber || option?.trailerPlate || '');
|
||||
setDriverName(block?.driverName || option?.driverName || '');
|
||||
setDriverLicense(block?.driverLicense || '');
|
||||
setDriverPhone(block?.driverPhone || option?.driverPhone || '');
|
||||
setTruckType(block?.truckType || option?.truckType || '');
|
||||
const loaded = block
|
||||
? splitContainerNumbers(block.containerNumber)
|
||||
: (option?.containerNumbers ?? []);
|
||||
setContainerNumbers(loaded.length ? loaded : initialContainerNumbers(item, ''));
|
||||
setGateInTime(block?.gateInTime ?? '');
|
||||
setTareWeight(block?.tareWeight ?? '');
|
||||
setWeighTruck(block?.weighingSkipped ? 'no' : 'yes');
|
||||
setGrossWeight(block?.grossWeight ?? '');
|
||||
setNetWeight(block?.netWeight ?? (item?.weight == null ? '' : Number(item.weight)));
|
||||
setGateOutTime(block?.gateOutTime ?? '');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
loadedPlateRef.current = null;
|
||||
setReference(item?.releaseOrderReference ?? generateReleaseReference(item));
|
||||
// Initial truck: the caller's prefill, else the first truck still mid-flow
|
||||
// (arrived but not left) — the operator can switch trucks in the select.
|
||||
const prefillPlate =
|
||||
truckPrefill?.truckPlateNumber && !truckPrefill.truckPlateNumber.includes(',')
|
||||
? truckPrefill.truckPlateNumber
|
||||
: '';
|
||||
const blocks = parseInspectionBlocks(item?.notes);
|
||||
const inProgress = blocks.find((block) => blockArrived(block) && !blockLeft(block));
|
||||
// Legacy single-truck bookings stored the truck on the booking columns; a
|
||||
// comma-joined value means several trucks, so the operator picks instead.
|
||||
const bookingPlate = item?.booking?.customerTruckPlateNumber ?? '';
|
||||
const legacyPlate = bookingPlate && !bookingPlate.includes(',') ? bookingPlate : '';
|
||||
const initialPlate = prefillPlate || inProgress?.truckPlateNumber || legacyPlate || '';
|
||||
const block = blockForPlate(blocks, initialPlate);
|
||||
loadedPlateRef.current = initialPlate ? initialPlate.trim().toUpperCase() : null;
|
||||
setTruckPlateNumber(initialPlate);
|
||||
setTrailerPlateNumber(block?.trailerPlateNumber || truckPrefill?.trailerPlateNumber || '');
|
||||
setDriverName(
|
||||
block?.driverName ||
|
||||
truckPrefill?.driverName ||
|
||||
(legacyPlate && initialPlate === legacyPlate ? (item?.booking?.customerTruckDriverName ?? '') : ''),
|
||||
);
|
||||
setDriverLicense(block?.driverLicense || truckPrefill?.driverLicense || '');
|
||||
setDriverPhone(block?.driverPhone || truckPrefill?.driverPhone || '');
|
||||
setTruckType(
|
||||
block?.truckType ||
|
||||
truckPrefill?.truckType ||
|
||||
(legacyPlate && initialPlate === legacyPlate ? (item?.booking?.customerTruckType ?? '') : ''),
|
||||
);
|
||||
setContainerNumbers(
|
||||
initialContainerNumbers(item, block?.containerNumber || truckPrefill?.containerNumber || ''),
|
||||
);
|
||||
setGateInTime(block?.gateInTime ?? '');
|
||||
setTareWeight(block?.tareWeight ?? '');
|
||||
setWeighTruck(block?.weighingSkipped ? 'no' : 'yes');
|
||||
setGrossWeight(block?.grossWeight ?? '');
|
||||
setNetWeight(block?.netWeight ?? (item?.weight == null ? '' : Number(item.weight)));
|
||||
setGateOutTime(block?.gateOutTime ?? '');
|
||||
}
|
||||
}, [opened, item, truckPrefill]);
|
||||
|
||||
// No truck chosen yet and exactly one is assigned — load it. With several
|
||||
// trucks the operator picks which one is at the gate.
|
||||
useEffect(() => {
|
||||
if (!opened || truckPlateNumber || loadedPlateRef.current) return;
|
||||
if (truckSelectOptions.length !== 1) return;
|
||||
applyTruckSelection(truckSelectOptions[0].value);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [opened, truckPlateNumber, customerTrucks, lastMileTrucks]);
|
||||
|
||||
// A typed plate that matches a saved arrival reloads that truck's record, so
|
||||
// the exit step opens with the weigh-in data instead of blank fields.
|
||||
useEffect(() => {
|
||||
if (!opened) return;
|
||||
const key = truckPlateNumber.trim().toUpperCase();
|
||||
if (!key || loadedPlateRef.current === key) return;
|
||||
if (blockForPlate(savedBlocks, truckPlateNumber)) applyTruckSelection(truckPlateNumber);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [opened, truckPlateNumber]);
|
||||
|
||||
// Which containers ride this truck, and their combined cargo weight. When the
|
||||
// booking has container weights, that sum is the authoritative net; the
|
||||
@@ -294,7 +399,9 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
);
|
||||
// Skip is only offered for container bookings; bulk always weighs.
|
||||
const skipWeighing = hasContainerWeights && weighTruck === 'no';
|
||||
const useContainerNet = hasContainerWeights && selectedContainerNumbers.length > 0 && !skipWeighing;
|
||||
// Even an unweighed truck records the cargo weight it is holding — the
|
||||
// selected containers' sum is the net that goes on the exit record.
|
||||
const useContainerNet = hasContainerWeights && selectedContainerNumbers.length > 0;
|
||||
|
||||
const systemNetWeight = useContainerNet
|
||||
? selectedCargoWeight
|
||||
@@ -314,6 +421,10 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
toast({ variant: 'destructive', title: 'Truck plate and driver name are required' });
|
||||
return;
|
||||
}
|
||||
if (hasTruckLeft) {
|
||||
toast({ variant: 'destructive', title: `Truck ${truckPlateNumber} has already left — its exit record is locked` });
|
||||
return;
|
||||
}
|
||||
if (!gateInTime || (!skipWeighing && tareWeight === '')) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
@@ -363,13 +474,17 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
weighingSkipped: skipWeighing || undefined,
|
||||
tareWeight: skipWeighing ? undefined : Number(tareWeight),
|
||||
grossWeight: skipWeighing || grossWeight === '' ? undefined : Number(grossWeight),
|
||||
netWeight: !skipWeighing && isExitStep && systemNetWeight !== '' ? Number(systemNetWeight) : undefined,
|
||||
// Skipped weighing still records the net from what the truck holds.
|
||||
netWeight: isExitStep && systemNetWeight !== '' ? Number(systemNetWeight) : undefined,
|
||||
gateOutTime: isExitStep ? toIsoDateTime(gateOutTime) : undefined,
|
||||
},
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: ['release-customer-trucks', bookingId] });
|
||||
await queryClient.invalidateQueries({ queryKey: ['release-last-mile-trucks', bookingId] });
|
||||
if (!isExitStep) {
|
||||
const remaining = totalTrucks > 1 ? ` (${Math.min(arrivedTrucks + 1, totalTrucks)} of ${totalTrucks} trucks arrived)` : '';
|
||||
toast({
|
||||
title: 'Truck arrival saved',
|
||||
title: `Truck ${truckPlateNumber.trim()} arrival saved${remaining}`,
|
||||
description: `${released.releaseOrderReference ?? reference} is ready for exit weighing.`,
|
||||
});
|
||||
onClose();
|
||||
@@ -380,11 +495,12 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
const blob = response.data;
|
||||
const filename = `release-${released.booking?.reference ?? released.bookingId ?? item.id}.pdf`;
|
||||
const opened = openPdfBlob(blob, filename, pdfWindow);
|
||||
const remainingExit = totalTrucks > 1 ? ` ${Math.min(leftTrucks + 1, totalTrucks)} of ${totalTrucks} trucks have left.` : '';
|
||||
toast({
|
||||
title: 'Release exit paper issued',
|
||||
description: opened
|
||||
description: (opened
|
||||
? 'The PDF opened in a browser tab for printing or saving.'
|
||||
: 'The browser blocked the preview tab, so the PDF was downloaded.',
|
||||
: 'The browser blocked the preview tab, so the PDF was downloaded.') + remainingExit,
|
||||
});
|
||||
onClose();
|
||||
} catch (error) {
|
||||
@@ -411,12 +527,35 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
</Text>
|
||||
)}
|
||||
</Alert>
|
||||
{totalTrucks > 1 && (
|
||||
<Alert icon={<Truck size={16} />} color="blue" variant="light">
|
||||
<Group gap="xs">
|
||||
<Text size="sm">
|
||||
{totalTrucks} trucks on this booking — each is weighed in and out separately.
|
||||
</Text>
|
||||
<Badge size="sm" variant="light" color={arrivedTrucks === totalTrucks ? 'green' : 'blue'}>
|
||||
{arrivedTrucks}/{totalTrucks} arrived
|
||||
</Badge>
|
||||
<Badge size="sm" variant="light" color={leftTrucks === totalTrucks ? 'green' : 'gray'}>
|
||||
{leftTrucks}/{totalTrucks} left
|
||||
</Badge>
|
||||
</Group>
|
||||
</Alert>
|
||||
)}
|
||||
{hasTruckLeft && (
|
||||
<Alert icon={<Info size={16} />} color="green" variant="light">
|
||||
<Text size="sm">
|
||||
Truck {truckPlateNumber} has already left — its exit record is locked. Pick another
|
||||
truck to continue the remaining arrivals and exits.
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
<TextInput
|
||||
label="Release document reference"
|
||||
placeholder="e.g. REL-2026-001"
|
||||
value={reference}
|
||||
onChange={(e) => setReference(e.currentTarget.value)}
|
||||
readOnly={isEntranceLocked}
|
||||
readOnly={referenceLocked}
|
||||
/>
|
||||
{noTruckAssigned && (
|
||||
<Alert color="orange" variant="light" icon={<Info size={16} />}>
|
||||
@@ -425,22 +564,19 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
)}
|
||||
{truckSelectOptions.length > 0 && (
|
||||
<Select
|
||||
label="Assigned first / last-mile truck"
|
||||
label="Truck at the gate"
|
||||
description="Pick which assigned truck is being processed — switching trucks loads that truck's own arrival/exit record."
|
||||
placeholder="Select the assigned truck"
|
||||
searchable
|
||||
clearable
|
||||
// Enabled at arrival so the operator picks which assigned truck came;
|
||||
// only locked on the exit (leaving) step once identity is captured.
|
||||
disabled={isEntranceLocked}
|
||||
data={truckSelectOptions}
|
||||
disabled={releaseMutation.isPending || downloading}
|
||||
data={truckSelectOptions.map(({ value, label, arrived, left }) => ({
|
||||
value,
|
||||
label: `${label}${left ? ' · LEFT' : arrived ? ' · ON SITE' : ''}`,
|
||||
}))}
|
||||
value={truckSelectOptions.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null}
|
||||
onChange={(value) => {
|
||||
const truck = truckSelectOptions.find((row) => row.value === value);
|
||||
setTruckPlateNumber(truck?.value ?? '');
|
||||
setTrailerPlateNumber(truck?.trailerPlate ?? '');
|
||||
if (truck?.driverName) setDriverName(truck.driverName);
|
||||
if (truck?.driverPhone) setDriverPhone(truck.driverPhone);
|
||||
if (truck?.truckType) setTruckType(truck.truckType);
|
||||
if (value) applyTruckSelection(value);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -481,6 +617,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
data={containerSelectData}
|
||||
value={selectedContainerNumbers}
|
||||
onChange={(values) => setContainerNumbers(values.length ? values : [''])}
|
||||
disabled={hasTruckLeft}
|
||||
/>
|
||||
) : (
|
||||
<Stack gap={6}>
|
||||
@@ -514,13 +651,15 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
disabled={isEntranceLocked}
|
||||
/>
|
||||
{skipWeighing && (
|
||||
<Text size="xs" c="dimmed">Weighbridge skipped — container passes without tare/gross.</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Weighbridge skipped — the selected containers' cargo weight is recorded as the net.
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
<Group grow>
|
||||
<NumberInput label="Tare weight (t)" required={!skipWeighing} min={0} value={tareWeight} onChange={(v) => setTareWeight(v === '' ? '' : Number(v))} readOnly={isEntranceLocked} disabled={skipWeighing} />
|
||||
<NumberInput label="Gross weight (t)" required={isExitStep && !skipWeighing} min={0} value={grossWeight} onChange={(v) => setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep || skipWeighing} />
|
||||
<NumberInput label="Gross weight (t)" required={isExitStep && !skipWeighing} min={0} value={grossWeight} onChange={(v) => setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep || skipWeighing || hasTruckLeft} />
|
||||
<NumberInput
|
||||
label={useContainerNet ? 'Selected cargo net (t)' : 'Recorded net weight (system t)'}
|
||||
min={0}
|
||||
@@ -532,7 +671,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
<Text size="sm" c={weightMismatch ? 'red' : 'dimmed'}>
|
||||
Computed net: <b>{computedNetWeight == null ? '-' : `${computedNetWeight.toLocaleString()} t`}</b>
|
||||
</Text>
|
||||
<TextInput label="Gate out time" type="datetime-local" value={gateOutTime} onChange={(e) => setGateOutTime(e.currentTarget.value)} disabled={!isExitStep} />
|
||||
<TextInput label="Gate out time" type="datetime-local" value={gateOutTime} onChange={(e) => setGateOutTime(e.currentTarget.value)} disabled={!isExitStep || hasTruckLeft} />
|
||||
</Group>
|
||||
{weightMismatch && (
|
||||
<Alert icon={<Scale size={16} />} color="red" variant="light">
|
||||
@@ -546,7 +685,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
<Button variant="default" onClick={onClose} disabled={releaseMutation.isPending || downloading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="orange" onClick={handleSubmit} loading={releaseMutation.isPending || downloading}>
|
||||
<Button color="orange" onClick={handleSubmit} loading={releaseMutation.isPending || downloading} disabled={hasTruckLeft}>
|
||||
{isExitStep ? 'Save Truck Leaving & View Exit Paper' : 'Save Truck Arrival'}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -51,8 +51,10 @@ const actionColor: Record<InventoryAction, string> = {
|
||||
deliver: 'green',
|
||||
};
|
||||
|
||||
// After the first truck registers, the modal decides per truck whether it is
|
||||
// arriving or leaving — the item-level label covers both for multi-truck.
|
||||
const releaseActionLabel = (item: WarehouseInventoryItem) =>
|
||||
item.releaseOrderReference ? 'Truck Leaving' : 'Truck Arrival';
|
||||
item.releaseOrderReference ? 'Truck Arrival / Leaving' : 'Truck Arrival';
|
||||
|
||||
const noteLineValue = (notes: string | null | undefined, label: string) => {
|
||||
const match = notes?.match(new RegExp(`^${label}:\\s*(.+)$`, 'im'));
|
||||
@@ -276,6 +278,19 @@ export function WarehouseInventoryTable({
|
||||
{nextAction === 'release' ? releaseActionLabel(item) : humanizeEnum(nextAction.replace(/-/g, '_'))}
|
||||
</Button>
|
||||
)}
|
||||
{/* After the first exit the primary action flips to Deliver, but a
|
||||
multi-truck booking still weighs its remaining trucks in and out. */}
|
||||
{item.status === 'READY_FOR_PICKUP' && item.releaseDate && nextAction !== 'release' && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="yellow"
|
||||
loading={busy}
|
||||
onClick={() => onAdvance(item, 'release')}
|
||||
>
|
||||
Truck Arrival / Leaving
|
||||
</Button>
|
||||
)}
|
||||
{item.status === 'READY_FOR_PICKUP' && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
|
||||
@@ -23,23 +23,24 @@ export function WarehouseOpsKpiStrip() {
|
||||
delta:
|
||||
data != null ? data.receivedToday - data.receivedYesterday : undefined,
|
||||
hint: "vs yesterday",
|
||||
// The received cargo itself, on the inventory board.
|
||||
href: "/dashboard/warehouse-inventory?status=RECEIVED",
|
||||
// Exactly the items behind the counter: received today.
|
||||
href: "/dashboard/warehouse-inventory?receivedToday=1",
|
||||
},
|
||||
{
|
||||
label: "Pending inspection",
|
||||
value: data?.pendingInspection ?? 0,
|
||||
icon: ClipboardCheck,
|
||||
color: "yellow",
|
||||
// Received cargo still awaiting inspection lives in the RECEIVED bucket.
|
||||
href: "/dashboard/warehouse-inventory?status=RECEIVED",
|
||||
// RECEIVED items with no inspection recorded yet.
|
||||
href: "/dashboard/warehouse-inventory?pendingInspection=1",
|
||||
},
|
||||
{
|
||||
label: "Trucks on-site",
|
||||
value: data?.trucksOnSite ?? 0,
|
||||
icon: Truck,
|
||||
color: "blue",
|
||||
href: "/dashboard/trucks-on-site",
|
||||
// Land on the On-site tab — the counter excludes inbound trucks.
|
||||
href: "/dashboard/trucks-on-site?scope=ON_SITE",
|
||||
},
|
||||
{
|
||||
label: "Items aging (>7d)",
|
||||
@@ -47,8 +48,7 @@ export function WarehouseOpsKpiStrip() {
|
||||
icon: AlertTriangle,
|
||||
color: (data?.itemsAging ?? 0) > 0 ? "red" : "edr-green",
|
||||
hint: "In warehouse over 7 days",
|
||||
// No aging filter on the board; the inventory list is the landing.
|
||||
href: "/dashboard/warehouse-inventory",
|
||||
href: "/dashboard/warehouse-inventory?agingOverDays=7",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -69,6 +69,11 @@ export interface FleetFormFieldDef extends FormFieldDef {
|
||||
* (e.g. a license expiry); "past" (default) = cannot be in the future.
|
||||
*/
|
||||
dateBound?: "past" | "future";
|
||||
/**
|
||||
* Format the value must match, checked on submit. The value is upper-cased and
|
||||
* trimmed before the test, matching the server. Empty optional fields skip it.
|
||||
*/
|
||||
pattern?: { regex: RegExp; message: string; uppercase?: boolean };
|
||||
}
|
||||
|
||||
export interface FleetListFilterDef {
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import type { FleetResourceConfig } from "./resources";
|
||||
|
||||
/**
|
||||
* A plate is two or three letters, a hyphen, then two to six digits — ET-9875,
|
||||
* AA-8642. Mirrors VEHICLE_PLATE_REGEX on the API so the form and the server
|
||||
* agree on what a plate looks like.
|
||||
*/
|
||||
const PLATE_PATTERN = {
|
||||
regex: /^[A-Z]{2,3}-\d{2,6}$/,
|
||||
message: "Use letters and numbers like ET-9875 or AA-8642",
|
||||
};
|
||||
|
||||
const VEHICLE_TYPE_OPTIONS = [
|
||||
{ label: "Truck", value: "TRUCK" },
|
||||
{ label: "Van", value: "VAN" },
|
||||
@@ -77,9 +87,9 @@ export const vehiclesConfig: FleetResourceConfig = {
|
||||
],
|
||||
formFields: [
|
||||
{ name: "code", label: "Code", type: "text" },
|
||||
{ name: "plateNumber", label: "Power Plate No", type: "text", required: true },
|
||||
{ name: "plateNumber", label: "Power Plate No", type: "text", required: true, pattern: PLATE_PATTERN },
|
||||
// { name: "powerPlateNo", label: "Power Plate No", type: "text" },
|
||||
{ name: "trailerPlateNo", label: "Trailer Plate No", type: "text" },
|
||||
{ name: "trailerPlateNo", label: "Trailer Plate No", type: "text", pattern: PLATE_PATTERN },
|
||||
{ name: "vehicleType", label: "Vehicle Type", type: "select", required: true, options: VEHICLE_TYPE_OPTIONS },
|
||||
{ name: "manufacturer", label: "Manufacturer", type: "text", required: true },
|
||||
{ name: "model", label: "Model", type: "text", required: true },
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
@@ -139,7 +140,13 @@ function Rows({ rows }: { rows: TruckOnSite[] }) {
|
||||
|
||||
export default function TrucksOnSitePage() {
|
||||
const { data: trucks = [], isLoading } = useTrucksOnSite();
|
||||
const [scope, setScope] = useState<"ALL" | "ON_SITE" | "INBOUND">("ALL");
|
||||
// The dashboard's "Trucks on-site" card counts only arrived trucks, so it
|
||||
// deep-links here with ?scope=ON_SITE to land on the matching tab.
|
||||
const [searchParams] = useSearchParams();
|
||||
const scopeParam = searchParams.get("scope");
|
||||
const [scope, setScope] = useState<"ALL" | "ON_SITE" | "INBOUND">(
|
||||
scopeParam === "ON_SITE" || scopeParam === "INBOUND" ? scopeParam : "ALL",
|
||||
);
|
||||
const [source, setSource] = useState<"ALL" | "CUSTOMER" | "EDR">("ALL");
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { Button, Card, Group, Select, Stack, TextInput } from '@mantine/core';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import { PackageOpen, Search, Truck } from 'lucide-react';
|
||||
import { PackageOpen, Search, Truck, X } from 'lucide-react';
|
||||
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
import {
|
||||
@@ -25,14 +25,36 @@ export default function WarehouseInventoryPage() {
|
||||
const [filter, setFilter] = useState<InventoryFilter>(
|
||||
initialStatus ? { status: initialStatus } : {},
|
||||
);
|
||||
// KPI drill-downs arriving from the ops dashboard cards; each shows as a
|
||||
// dismissible chip so the list can be widened back out in place.
|
||||
const [quick, setQuick] = useState<
|
||||
Pick<InventoryFilter, 'receivedToday' | 'pendingInspection' | 'agingOverDays'>
|
||||
>(() => {
|
||||
const aging = Number(searchParams.get('agingOverDays'));
|
||||
return {
|
||||
receivedToday: searchParams.get('receivedToday') ? true : undefined,
|
||||
pendingInspection: searchParams.get('pendingInspection') ? true : undefined,
|
||||
agingOverDays: Number.isFinite(aging) && aging > 0 ? aging : undefined,
|
||||
};
|
||||
});
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const [debouncedSearch] = useDebouncedValue(search, 300);
|
||||
const queryFilter = useMemo<InventoryFilter>(
|
||||
() => ({ ...filter, direction, search: debouncedSearch || undefined }),
|
||||
[filter, direction, debouncedSearch],
|
||||
() => ({ ...filter, ...quick, direction, search: debouncedSearch || undefined }),
|
||||
[filter, quick, direction, debouncedSearch],
|
||||
);
|
||||
|
||||
const quickChips: Array<{ key: keyof typeof quick; label: string }> = [
|
||||
...(quick.receivedToday ? [{ key: 'receivedToday' as const, label: 'Received today' }] : []),
|
||||
...(quick.pendingInspection
|
||||
? [{ key: 'pendingInspection' as const, label: 'Pending inspection' }]
|
||||
: []),
|
||||
...(quick.agingOverDays
|
||||
? [{ key: 'agingOverDays' as const, label: `In warehouse >${quick.agingOverDays}d` }]
|
||||
: []),
|
||||
];
|
||||
|
||||
const warehousesQuery = useWarehouses();
|
||||
const yardsQuery = useWarehouseYards(filter.warehouseId);
|
||||
const zonesQuery = useWarehouseZones(filter.yardId);
|
||||
@@ -136,6 +158,17 @@ export default function WarehouseInventoryPage() {
|
||||
}
|
||||
w={200}
|
||||
/>
|
||||
{quickChips.map((chip) => (
|
||||
<Button
|
||||
key={chip.key}
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
rightSection={<X size={12} />}
|
||||
onClick={() => setQuick((q) => ({ ...q, [chip.key]: undefined }))}
|
||||
>
|
||||
{chip.label}
|
||||
</Button>
|
||||
))}
|
||||
</Group>
|
||||
|
||||
<InventoryWorkbench items={inventoryQuery.data ?? []} isLoading={inventoryQuery.isLoading} />
|
||||
|
||||
@@ -144,6 +144,8 @@ export interface LastMileArrivalTruck {
|
||||
driverPhone: string | null;
|
||||
truckType: string | null;
|
||||
containerNumber: string | null;
|
||||
arrivedAt: string | null;
|
||||
departedAt: string | null;
|
||||
}
|
||||
|
||||
export const warehouseService = {
|
||||
|
||||
@@ -1083,6 +1083,10 @@ export interface InventoryFilter {
|
||||
search?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
/** KPI drill-downs — mirror the ops-stats counters exactly. */
|
||||
receivedToday?: boolean;
|
||||
pendingInspection?: boolean;
|
||||
agingOverDays?: number;
|
||||
}
|
||||
|
||||
export interface InventoryInquiryFilter {
|
||||
|
||||
@@ -34,6 +34,7 @@ import { HeaderButton, PageHeader } from "./components/PageHeader";
|
||||
import { PaymentMethodModal } from "./components/PaymentMethodModal";
|
||||
import { ScheduleCard } from "./components/ScheduleCard";
|
||||
import { WarehousePaymentsSection } from "./components/WarehousePaymentsSection";
|
||||
import { WarehouseLocationCard } from "./components/WarehouseLocationCard";
|
||||
import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard";
|
||||
import { ShipmentTrackingCard } from "./components/ShipmentTrackingCard";
|
||||
import { StatusHero } from "./components/StatusHero";
|
||||
@@ -273,6 +274,8 @@ export function ReadonlyBookingView({
|
||||
|
||||
<ContainersCard booking={booking} />
|
||||
|
||||
<WarehouseLocationCard bookingId={booking.id} />
|
||||
|
||||
<ContractInfoCard booking={booking} />
|
||||
|
||||
<ShipmentTrackingCard bookingId={booking.id} />
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Group, Paper, Stack, Text, Divider } from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Warehouse as WarehouseIcon } from "lucide-react";
|
||||
|
||||
import { warehouseService } from "@/services/warehouse.service";
|
||||
|
||||
interface WarehouseLocationCardProps {
|
||||
bookingId: string;
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text size="sm" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm" fw={500} ta="right">
|
||||
{value}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export function WarehouseLocationCard({ bookingId }: WarehouseLocationCardProps) {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["warehouse-inventory", bookingId],
|
||||
queryFn: () => warehouseService.listInventory({ bookingId }),
|
||||
});
|
||||
|
||||
const items = data ?? [];
|
||||
const latest = items[0];
|
||||
|
||||
return (
|
||||
<Paper withBorder radius="md" padding="lg">
|
||||
<Stack gap="md">
|
||||
<Group gap="xs">
|
||||
<WarehouseIcon size={18} />
|
||||
<Text fw={700}>Warehouse Location</Text>
|
||||
</Group>
|
||||
|
||||
<Divider />
|
||||
|
||||
{isLoading ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
Loading…
|
||||
</Text>
|
||||
) : !latest ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
Your cargo will appear here once it arrives at the warehouse.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
<Row
|
||||
label="Warehouse"
|
||||
value={latest.warehouse ? `${latest.warehouse.name} (${latest.warehouse.code})` : "—"}
|
||||
/>
|
||||
<Row
|
||||
label="Yard"
|
||||
value={latest.yard ? `${latest.yard.name} (${latest.yard.code})` : "—"}
|
||||
/>
|
||||
<Row
|
||||
label="Zone"
|
||||
value={latest.zone ? `${latest.zone.name} (${latest.zone.code})` : "—"}
|
||||
/>
|
||||
<Row label="Arrived At" value={latest.arrivedAt ? new Date(latest.arrivedAt).toLocaleString() : "—"} />
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,15 @@
|
||||
import { Alert, Button, Group, Loader, Modal, Stack, Text, TextInput } from "@mantine/core";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { CheckCircle2, Info } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -36,7 +47,10 @@ const downloadBlob = (blob: Blob, filename: string) => {
|
||||
|
||||
/**
|
||||
* Approve-delivery flow: open the handover document for the customer to review,
|
||||
* then apply their saved signature (approve) and hand back the signed PDF.
|
||||
* then sign it with their typed full name (saved signature applied when present).
|
||||
* Self-haul: one booking-level handover, signed once. EDR last-mile: one
|
||||
* handover per delivering truck — the customer signs each; when the last one is
|
||||
* signed the delivery completes automatically.
|
||||
*/
|
||||
export function ApproveDeliveryModal({
|
||||
bookingId,
|
||||
@@ -48,15 +62,33 @@ export function ApproveDeliveryModal({
|
||||
const queryClient = useQueryClient();
|
||||
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
||||
const [signerName, setSignerName] = useState("");
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
const { data: handovers } = useQuery({
|
||||
queryKey: ["booking-handovers", bookingId],
|
||||
queryFn: () => bookingsService.listBookingHandovers(bookingId),
|
||||
enabled: opened && Boolean(bookingId),
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
// Per-truck mode: any EDR last-mile handover means one signature per truck.
|
||||
const edrMode = (handovers ?? []).some((h) => h.mileType === "EDR_LAST_MILE");
|
||||
const unsigned = (handovers ?? []).filter((h) => !h.signedAt);
|
||||
const selected =
|
||||
(handovers ?? []).find((h) => h.id === selectedId && !h.signedAt) ?? unsigned[0] ?? null;
|
||||
|
||||
const {
|
||||
data: docBlob,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery({
|
||||
queryKey: ["booking-handover-doc", bookingId],
|
||||
queryFn: () => bookingsService.downloadBookingHandoverDocument(bookingId),
|
||||
enabled: opened && Boolean(bookingId),
|
||||
queryKey: ["booking-handover-doc", bookingId, edrMode ? selected?.id : "booking"],
|
||||
queryFn: () =>
|
||||
bookingsService.downloadBookingHandoverDocument(
|
||||
bookingId,
|
||||
edrMode ? selected?.id : undefined,
|
||||
),
|
||||
enabled: opened && Boolean(bookingId) && (!edrMode || Boolean(selected)),
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
@@ -70,10 +102,32 @@ export function ApproveDeliveryModal({
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [docBlob]);
|
||||
|
||||
const invalidateBooking = () =>
|
||||
Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.bookings.get.queryKey({ id: bookingId }),
|
||||
}),
|
||||
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }),
|
||||
queryClient.invalidateQueries({ queryKey: ["companies", "getDashboard"] }),
|
||||
]);
|
||||
|
||||
const onSignError = (error: unknown) => {
|
||||
const message = errorMessage(error);
|
||||
toast.error(message);
|
||||
if (message.toLowerCase().includes("save your signature")) {
|
||||
onClose();
|
||||
navigate("/signature");
|
||||
} else if (/demurrage|storage|warehouse invoice|fully paid/i.test(message)) {
|
||||
onClose();
|
||||
navigate("/billing");
|
||||
}
|
||||
};
|
||||
|
||||
const handoverMutation = useMutation(
|
||||
api.bookings.downloadHandoverDocument.mutationOptions(),
|
||||
);
|
||||
|
||||
// Booking-level (self-haul) approval — signs every handover at once.
|
||||
const approve = useMutation({
|
||||
...api.bookings.approveDelivery.mutationOptions(),
|
||||
onSuccess: async (result) => {
|
||||
@@ -87,30 +141,35 @@ export function ApproveDeliveryModal({
|
||||
toast.success("Delivery approved and handover signed");
|
||||
toast.error("Signed handover document could not be downloaded");
|
||||
}
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.bookings.get.queryKey({ id: bookingId }),
|
||||
}),
|
||||
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }),
|
||||
queryClient.invalidateQueries({ queryKey: ["companies", "getDashboard"] }),
|
||||
]);
|
||||
await invalidateBooking();
|
||||
onApproved?.();
|
||||
onClose();
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = errorMessage(error);
|
||||
toast.error(message);
|
||||
if (message.toLowerCase().includes("save your signature")) {
|
||||
onClose();
|
||||
navigate("/signature");
|
||||
} else if (/demurrage|storage|warehouse invoice|fully paid/i.test(message)) {
|
||||
onClose();
|
||||
navigate("/billing");
|
||||
}
|
||||
},
|
||||
onError: onSignError,
|
||||
});
|
||||
|
||||
const busy = approve.isPending || handoverMutation.isPending;
|
||||
// Per-truck (EDR last-mile) signature — one handover at a time.
|
||||
const signOne = useMutation({
|
||||
mutationFn: ({ handoverId, name }: { handoverId: string; name: string }) =>
|
||||
bookingsService.signHandover(handoverId, name),
|
||||
onSuccess: async (result) => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["booking-handovers", bookingId],
|
||||
});
|
||||
setSelectedId(null);
|
||||
if (result.allSigned) {
|
||||
toast.success("All handovers signed — delivery confirmed");
|
||||
await invalidateBooking();
|
||||
onApproved?.();
|
||||
onClose();
|
||||
} else {
|
||||
toast.success("Handover signed — please sign the remaining truck(s)");
|
||||
}
|
||||
},
|
||||
onError: onSignError,
|
||||
});
|
||||
|
||||
const busy = approve.isPending || handoverMutation.isPending || signOne.isPending;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -123,12 +182,42 @@ export function ApproveDeliveryModal({
|
||||
<Stack gap="md">
|
||||
<Alert color="blue" variant="light" icon={<Info size={16} />}>
|
||||
<Text size="sm">
|
||||
Review the handover document below, then type your full name to sign and
|
||||
confirm you received the goods. Your saved signature is applied automatically
|
||||
if you have one.
|
||||
{edrMode
|
||||
? "Your goods were delivered by EDR truck(s). Review and sign the handover for each truck to confirm you received the goods — delivery completes once every truck is signed."
|
||||
: "Review the handover document below, then type your full name to sign and confirm you received the goods. Your saved signature is applied automatically if you have one."}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
{edrMode && (handovers?.length ?? 0) > 0 && (
|
||||
<Stack gap={4}>
|
||||
{handovers!.map((h) => (
|
||||
<UnstyledButton
|
||||
key={h.id}
|
||||
onClick={() => !h.signedAt && setSelectedId(h.id)}
|
||||
style={{
|
||||
padding: "8px 12px",
|
||||
borderRadius: 8,
|
||||
border:
|
||||
selected?.id === h.id
|
||||
? "1px solid var(--mantine-color-edr-green-6)"
|
||||
: "1px solid var(--mantine-color-gray-3)",
|
||||
cursor: h.signedAt ? "default" : "pointer",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text size="sm" fw={600}>
|
||||
{h.truckPlate ? `Truck ${h.truckPlate}` : "Booking handover"} —{" "}
|
||||
{h.reference}
|
||||
</Text>
|
||||
<Badge color={h.signedAt ? "green" : "yellow"} variant="light">
|
||||
{h.signedAt ? `Signed${h.signerName ? ` — ${h.signerName}` : ""}` : "Awaiting signature"}
|
||||
</Badge>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader size="sm" />
|
||||
@@ -170,10 +259,21 @@ export function ApproveDeliveryModal({
|
||||
color="edr-green"
|
||||
leftSection={<CheckCircle2 size={16} />}
|
||||
loading={busy}
|
||||
disabled={isLoading || isError || !signerName.trim()}
|
||||
onClick={() => approve.mutate({ id: bookingId, signerName: signerName.trim() })}
|
||||
disabled={
|
||||
isLoading ||
|
||||
isError ||
|
||||
!signerName.trim() ||
|
||||
(edrMode && !selected)
|
||||
}
|
||||
onClick={() =>
|
||||
edrMode && selected
|
||||
? signOne.mutate({ handoverId: selected.id, name: signerName.trim() })
|
||||
: approve.mutate({ id: bookingId, signerName: signerName.trim() })
|
||||
}
|
||||
>
|
||||
Approve & sign delivery
|
||||
{edrMode && selected?.truckPlate
|
||||
? `Sign for truck ${selected.truckPlate}`
|
||||
: "Approve & sign delivery"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -137,6 +137,26 @@ export interface ApproveDeliveryResponse {
|
||||
signerDisplayName: string;
|
||||
}
|
||||
|
||||
/** One import handover record — booking-level or per truck (EDR last-mile). */
|
||||
export interface BookingHandoverRecord {
|
||||
id: string;
|
||||
reference: string;
|
||||
truckPlate: string | null;
|
||||
mileType: "SELF_HAUL" | "EDR_LAST_MILE";
|
||||
generatedAt: string;
|
||||
signedAt: string | null;
|
||||
signerName: string | null;
|
||||
deliveredAt: string | null;
|
||||
}
|
||||
|
||||
export interface SignHandoverResponse {
|
||||
handoverId: string;
|
||||
bookingId: string;
|
||||
signedAt: string | null;
|
||||
signerDisplayName: string;
|
||||
allSigned: boolean;
|
||||
}
|
||||
|
||||
export interface CustomerTruckAssignmentPayload {
|
||||
truckPlateNumber: string;
|
||||
driverName: string;
|
||||
@@ -206,13 +226,36 @@ export const bookingsService = {
|
||||
);
|
||||
return data;
|
||||
},
|
||||
downloadBookingHandoverDocument: async (bookingId: string): Promise<Blob> => {
|
||||
downloadBookingHandoverDocument: async (
|
||||
bookingId: string,
|
||||
handoverId?: string,
|
||||
): Promise<Blob> => {
|
||||
const { data } = await client.get(
|
||||
`/api/warehouse-inventory/bookings/${bookingId}/handover-document`,
|
||||
{ responseType: "blob" },
|
||||
{ responseType: "blob", params: handoverId ? { handoverId } : undefined },
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
listBookingHandovers: async (
|
||||
bookingId: string,
|
||||
): Promise<BookingHandoverRecord[]> => {
|
||||
const { data } = await client.get(
|
||||
`/api/warehouse-inventory/bookings/${bookingId}/handovers`,
|
||||
);
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
signHandover: async (
|
||||
handoverId: string,
|
||||
signerName: string,
|
||||
): Promise<SignHandoverResponse> => {
|
||||
const { data } = await client.post(
|
||||
`/api/warehouse-inventory/handovers/${handoverId}/sign`,
|
||||
{ signerName },
|
||||
);
|
||||
return data.data ?? data;
|
||||
},
|
||||
downloadBookingGrnDocument: async (bookingId: string): Promise<Blob> => {
|
||||
const { data } = await client.get(
|
||||
`/api/warehouse-inventory/bookings/${bookingId}/grn-document`,
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { client } from "@/utils/api";
|
||||
|
||||
export interface InventoryFilter {
|
||||
bookingId?: string;
|
||||
}
|
||||
|
||||
export interface WarehouseInventoryItem {
|
||||
id: string;
|
||||
bookingId: string | null;
|
||||
warehouseId: string;
|
||||
yardId: string;
|
||||
zoneId: string;
|
||||
status: string;
|
||||
arrivedAt: string | null;
|
||||
warehouse?: {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
} | null;
|
||||
yard?: {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
} | null;
|
||||
zone?: {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface BookingScheduleView {
|
||||
schedule: {
|
||||
status: string;
|
||||
scheduledDepartureDate: string | null;
|
||||
scheduledArrivalDate: string | null;
|
||||
} | null;
|
||||
wagon?: {
|
||||
wagonNumber: string | null;
|
||||
sequenceNo: number | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export const warehouseService = {
|
||||
listInventory: async (filter?: InventoryFilter): Promise<WarehouseInventoryItem[]> => {
|
||||
const { data } = await client.get("/warehouse-inventory", {
|
||||
params: filter,
|
||||
});
|
||||
return data?.data ?? data ?? [];
|
||||
},
|
||||
|
||||
bookingSchedule: async (bookingId: string): Promise<BookingScheduleView> => {
|
||||
const { data } = await client.get(`/warehouse-inventory/booking-schedule/${bookingId}`);
|
||||
return data?.data ?? data ?? { schedule: null, wagon: null };
|
||||
},
|
||||
};
|
||||
@@ -580,7 +580,7 @@ model BookingSeat {
|
||||
bookingId String
|
||||
seatId String
|
||||
leg Int @default(1) // 1=outbound/leg-1, 2=return/leg-2
|
||||
scheduleId String // which schedule this seat belongs to
|
||||
scheduleId String? // which schedule this seat belongs to
|
||||
passengerName String
|
||||
dateOfBirth DateTime?
|
||||
passengerCategory PassengerCategory @default(ADULT)
|
||||
|
||||
@@ -826,6 +826,19 @@ export class PaymentsService {
|
||||
});
|
||||
if (!intent) throw new NotFoundException("PaymentIntent not found");
|
||||
if (intent.status === PaymentIntentStatus.SUCCEEDED) {
|
||||
// Idempotency guard — but still repair missing tickets. They can be absent
|
||||
// when the first finalization threw from generate() after the transaction
|
||||
// committed: the caller got a 500, retried, and now hits this early-return.
|
||||
const ticketCount = await this.prisma.ticket.count({ where: { bookingId: intent.bookingId } });
|
||||
if (ticketCount === 0) {
|
||||
try {
|
||||
await this.ticketsService.generate(intent.bookingId);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Error generating ticket on idempotency retry for booking ${intent.bookingId}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return { alreadyFinalized: true };
|
||||
}
|
||||
if (intent.status === PaymentIntentStatus.CANCELLED) {
|
||||
@@ -877,9 +890,8 @@ export class PaymentsService {
|
||||
await this.ticketsService.generate(booking.id);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Error generating ticket: ${err instanceof Error ? err.message : String(err)}`,
|
||||
`Error generating ticket for booking ${booking.id}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -18,10 +18,10 @@ export class ReportsController {
|
||||
return this.service.generateReport(dto);
|
||||
}
|
||||
|
||||
@Get("schedules")
|
||||
@ApiOperation({ summary: "List schedules for the passengers report picker" })
|
||||
listSchedulesForPicker() {
|
||||
return this.service.listSchedulesForPicker();
|
||||
@Get('schedules')
|
||||
@ApiOperation({ summary: 'List schedules for the passengers report picker' })
|
||||
listSchedulesForPicker(@Query('all') all?: string) {
|
||||
return this.service.listSchedulesForPicker(all === 'true');
|
||||
}
|
||||
|
||||
@Get("passengers/list")
|
||||
|
||||
@@ -264,13 +264,10 @@ export class ReportsService {
|
||||
},
|
||||
bookings: {
|
||||
where: { status: { in: ["CONFIRMED", "BOARDED"] } },
|
||||
include: {
|
||||
seats: {
|
||||
where: { leg: 1 },
|
||||
include: {
|
||||
seat: { include: { coach: { include: { coachType: true } } } },
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
originStationId: true,
|
||||
destinationStationId: true,
|
||||
},
|
||||
},
|
||||
stopTimes: { include: { station: true }, orderBy: { sequence: "asc" } },
|
||||
@@ -411,10 +408,10 @@ export class ReportsService {
|
||||
};
|
||||
}
|
||||
|
||||
async listSchedulesForPicker() {
|
||||
async listSchedulesForPicker(all = false) {
|
||||
const now = new Date();
|
||||
const schedules = await this.prisma.trainSchedule.findMany({
|
||||
where: { departureAt: { gte: now } },
|
||||
where: all ? undefined : { departureAt: { gte: now } },
|
||||
select: {
|
||||
id: true,
|
||||
departureAt: true,
|
||||
@@ -423,7 +420,7 @@ export class ReportsService {
|
||||
originStation: { select: { name: true } },
|
||||
destinationStation: { select: { name: true } },
|
||||
},
|
||||
orderBy: { departureAt: 'asc' },
|
||||
orderBy: { departureAt: all ? 'desc' : 'asc' },
|
||||
take: 200,
|
||||
});
|
||||
return schedules.map((s) => ({
|
||||
@@ -439,8 +436,9 @@ export class ReportsService {
|
||||
async getPassengerList(scheduleId: string) {
|
||||
const seats = await this.prisma.bookingSeat.findMany({
|
||||
where: {
|
||||
scheduleId,
|
||||
leg: 1,
|
||||
booking: { scheduleId, status: { in: ["CONFIRMED", "BOARDED"] } },
|
||||
booking: { status: { in: ["CONFIRMED", "BOARDED"] } },
|
||||
},
|
||||
include: {
|
||||
booking: {
|
||||
@@ -507,8 +505,9 @@ export class ReportsService {
|
||||
// Booked seats — exclude dining coaches
|
||||
const bookingSeats = await this.prisma.bookingSeat.findMany({
|
||||
where: {
|
||||
scheduleId,
|
||||
leg: 1,
|
||||
booking: { scheduleId, status: { in: ['CONFIRMED', 'BOARDED', 'PENDING_PAYMENT'] } },
|
||||
booking: { status: { in: ['CONFIRMED', 'BOARDED', 'PENDING_PAYMENT'] } },
|
||||
seat: { coach: { coachType: { type: { not: 'dining' } } } },
|
||||
},
|
||||
include: {
|
||||
|
||||
@@ -1069,6 +1069,7 @@ export class SeatsService {
|
||||
booking: {
|
||||
select: {
|
||||
id: true, bookingRef: true, scheduleId: true,
|
||||
originStationId: true, destinationStationId: true,
|
||||
createdAt: true, contactPhone: true,
|
||||
},
|
||||
},
|
||||
@@ -1086,7 +1087,8 @@ export class SeatsService {
|
||||
});
|
||||
const occupiedIds = new Set(journeySegments.map(js => js.seatId!));
|
||||
|
||||
// Group BookingSeat rows by (seatId::leg) to detect duplicates
|
||||
// Group BookingSeat rows by seatId::leg to find candidate duplicates,
|
||||
// then filter to only those whose booking segments actually overlap.
|
||||
type BS = (typeof bookingSeats)[number];
|
||||
const groups = new Map<string, BS[]>();
|
||||
for (const bs of bookingSeats) {
|
||||
@@ -1095,6 +1097,59 @@ export class SeatsService {
|
||||
groups.get(key)!.push(bs);
|
||||
}
|
||||
|
||||
// Build stop-sequence map for this schedule once
|
||||
const stopTimes = await this.prisma.tripStopTime.findMany({
|
||||
where: { scheduleId: schedule.id },
|
||||
select: { stationId: true, sequence: true },
|
||||
});
|
||||
const seqOf = (stationId: string | null | undefined): number | undefined =>
|
||||
stationId ? stopTimes.find(s => s.stationId === stationId)?.sequence : undefined;
|
||||
|
||||
// Fetch JourneySegment ranges for all booking IDs in candidate groups
|
||||
const candidateBookingIds = [...new Set(
|
||||
[...groups.values()].filter(g => g.length > 1).flatMap(g => g.map(bs => bs.booking.id)),
|
||||
)];
|
||||
const candidateSegments = candidateBookingIds.length > 0
|
||||
? await this.prisma.journeySegment.findMany({
|
||||
where: {
|
||||
scheduleId: schedule.id,
|
||||
journey: { bookingId: { in: candidateBookingIds }, status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } },
|
||||
},
|
||||
select: { departureStationId: true, arrivalStationId: true, journey: { select: { bookingId: true } } },
|
||||
})
|
||||
: [];
|
||||
|
||||
// Collapse per-booking segments into a single [from, to) range
|
||||
const rangeByBookingId = new Map<string, { from: number; to: number }>();
|
||||
for (const seg of candidateSegments) {
|
||||
const bookingId = seg.journey.bookingId;
|
||||
if (!bookingId) continue;
|
||||
const depSeq = seqOf(seg.departureStationId);
|
||||
const arrSeq = seqOf(seg.arrivalStationId);
|
||||
if (depSeq === undefined || arrSeq === undefined) continue;
|
||||
const existing = rangeByBookingId.get(bookingId);
|
||||
rangeByBookingId.set(bookingId, existing
|
||||
? { from: Math.min(existing.from, depSeq), to: Math.max(existing.to, arrSeq) }
|
||||
: { from: depSeq, to: arrSeq });
|
||||
}
|
||||
|
||||
// Fall back to booking-level origin/destination when JourneySegments are missing
|
||||
const rangeForBooking = (bs: BS): { from: number; to: number } | null => {
|
||||
const fromSegments = rangeByBookingId.get(bs.booking.id);
|
||||
if (fromSegments) return fromSegments;
|
||||
// BookingSeat.scheduleId tells us which leg this seat belongs to
|
||||
const bsScheduleId = bs.scheduleId ?? bs.booking.scheduleId;
|
||||
if (bsScheduleId !== schedule.id) return null;
|
||||
const from = seqOf(bs.booking.originStationId);
|
||||
const to = seqOf(bs.booking.destinationStationId);
|
||||
if (from === undefined || to === undefined) return null;
|
||||
return { from, to };
|
||||
};
|
||||
|
||||
// Two bookings are true duplicates only if their segments overlap
|
||||
const segmentsOverlap = (a: { from: number; to: number }, b: { from: number; to: number }) =>
|
||||
a.from < b.to && b.from < a.to;
|
||||
|
||||
// All seats held by any confirmed BookingSeat — union of JourneySegment-based
|
||||
// occupancy AND BookingSeat-based occupancy so that seats whose JourneySegments
|
||||
// are missing (e.g. created via enhanced-seats path without bookingId) are still
|
||||
@@ -1114,13 +1169,30 @@ export class SeatsService {
|
||||
for (const [key, group] of groups) {
|
||||
if (group.length <= 1) continue;
|
||||
if (group[0].seat.coachId !== coach.id) continue;
|
||||
|
||||
// Filter to bookings that actually have overlapping segments
|
||||
const overlapping: BS[] = [];
|
||||
for (let i = 0; i < group.length; i++) {
|
||||
const rangeA = rangeForBooking(group[i]);
|
||||
for (let j = i + 1; j < group.length; j++) {
|
||||
const rangeB = rangeForBooking(group[j]);
|
||||
// If either range is unknown, conservatively treat as overlap
|
||||
const isOverlap = !rangeA || !rangeB || segmentsOverlap(rangeA, rangeB);
|
||||
if (isOverlap) {
|
||||
if (!overlapping.includes(group[i])) overlapping.push(group[i]);
|
||||
if (!overlapping.includes(group[j])) overlapping.push(group[j]);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (overlapping.length <= 1) continue;
|
||||
|
||||
const [seatId] = key.split('::');
|
||||
const seat = coach.seats.find(s => s.id === seatId);
|
||||
duplicates.push({
|
||||
seatId,
|
||||
seatNumber: seat?.seatNumber ?? seatId,
|
||||
leg: group[0].leg,
|
||||
bookings: group.map(bs => ({
|
||||
leg: overlapping[0].leg,
|
||||
bookings: overlapping.map(bs => ({
|
||||
bookingSeatId: bs.id,
|
||||
bookingId: bs.booking.id,
|
||||
bookingRef: bs.booking.bookingRef,
|
||||
|
||||
@@ -56,11 +56,9 @@ interface PassengerRow {
|
||||
seatClassName: string | null;
|
||||
coachNumber: string | null;
|
||||
coachType: string | null;
|
||||
coachSeat: string | null;
|
||||
nationality: string | null;
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
departureAt: string | null;
|
||||
amountPaidMinor: number;
|
||||
currency: string;
|
||||
isGroupBooking: boolean;
|
||||
@@ -73,7 +71,6 @@ export default function PassengersReportPage() {
|
||||
const [scheduleId, setScheduleId] = useState("");
|
||||
const [tab, setTab] = useState<Tab>("occupancy");
|
||||
const [listSearch, setListSearch] = useState("");
|
||||
const [filterCoach, setFilterCoach] = useState("");
|
||||
const [filterOrigin, setFilterOrigin] = useState("");
|
||||
const [filterSeatClass, setFilterSeatClass] = useState("");
|
||||
const [filterCoachNumber, setFilterCoachNumber] = useState("");
|
||||
@@ -81,8 +78,8 @@ export default function PassengersReportPage() {
|
||||
const { data: schedulesRaw, isLoading: loadingSchedules } = useQuery<
|
||||
ScheduleOption[]
|
||||
>({
|
||||
queryKey: ["report-schedules"],
|
||||
queryFn: () => apiClient.get("/reports/schedules"),
|
||||
queryKey: ["report-schedules-all"],
|
||||
queryFn: () => apiClient.get("/reports/schedules?all=true"),
|
||||
});
|
||||
const schedules = schedulesRaw ?? [];
|
||||
|
||||
@@ -102,7 +99,7 @@ export default function PassengersReportPage() {
|
||||
enabled: !!scheduleId,
|
||||
});
|
||||
|
||||
const coachOptions = [
|
||||
const coachNumberOptions = [
|
||||
...new Set(passengerList.map((p) => p.coachNumber).filter(Boolean)),
|
||||
].sort() as string[];
|
||||
const seatClassOptions = [
|
||||
@@ -111,11 +108,9 @@ export default function PassengersReportPage() {
|
||||
const originOptions = [
|
||||
...new Set(passengerList.map((p) => p.origin).filter(Boolean)),
|
||||
].sort() as string[];
|
||||
const coachNumberOptions = coachOptions;
|
||||
|
||||
const filteredList = passengerList
|
||||
.filter((p) => {
|
||||
if (filterCoach && p.coachNumber !== filterCoach) return false;
|
||||
if (filterCoachNumber && p.coachNumber !== filterCoachNumber) return false;
|
||||
if (filterOrigin && p.origin !== filterOrigin) return false;
|
||||
if (filterSeatClass && p.seatClassName !== filterSeatClass) return false;
|
||||
@@ -161,29 +156,24 @@ export default function PassengersReportPage() {
|
||||
};
|
||||
|
||||
const doExportList = () => {
|
||||
if (!passengerList.length) return;
|
||||
const headers = [
|
||||
"#",
|
||||
"Name",
|
||||
"Coach·Seat",
|
||||
"Origin",
|
||||
"Destination",
|
||||
"Date",
|
||||
"Booking Ref",
|
||||
];
|
||||
const rows = passengerList.map((p, i) =>
|
||||
if (!filteredList.length) return;
|
||||
const headers = ['Name', 'Nationality', 'Passport Number', 'Seat Class', 'Coach', 'Seat', 'Origin', 'Destination', 'Amount Paid (ETB)', 'Booking Ref'];
|
||||
const rows = filteredList.map((p) =>
|
||||
[
|
||||
String(i + 1),
|
||||
p.passengerName,
|
||||
p.coachSeat,
|
||||
p.origin,
|
||||
p.destination,
|
||||
p.departureAt ? formatDateTime(p.departureAt) : "—",
|
||||
p.nationality ?? '—',
|
||||
p.passportNumber ?? '—',
|
||||
p.seatClassName ?? '—',
|
||||
p.coachNumber ?? '—',
|
||||
p.seatNumber ?? '—',
|
||||
p.origin ?? '—',
|
||||
p.destination ?? '—',
|
||||
(p.amountPaidMinor / 100).toFixed(2),
|
||||
p.bookingRef,
|
||||
].map((v) => `"${String(v).replace(/"/g, '""')}"`),
|
||||
);
|
||||
downloadCsv(
|
||||
[headers.join(","), ...rows.map((r) => r.join(","))].join("\n"),
|
||||
[headers.join(','), ...rows.map((r) => r.join(','))].join('\n'),
|
||||
`passengers-${scheduleId}.csv`,
|
||||
);
|
||||
};
|
||||
@@ -211,7 +201,6 @@ export default function PassengersReportPage() {
|
||||
setScheduleId(e.target.value);
|
||||
setTab("occupancy");
|
||||
setListSearch("");
|
||||
setFilterCoach("");
|
||||
setFilterCoachNumber("");
|
||||
setFilterOrigin("");
|
||||
setFilterSeatClass("");
|
||||
@@ -520,94 +509,41 @@ export default function PassengersReportPage() {
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left text-xs text-muted-foreground uppercase tracking-wider">
|
||||
<th className="pb-2 pr-4">Name</th>
|
||||
<th className="pb-2 pr-4">Nationality</th>
|
||||
<th className="pb-2 pr-4">Passport</th>
|
||||
<th className="pb-2 pr-4">Coach</th>
|
||||
<th className="pb-2 pr-4">Seat</th>
|
||||
<th className="pb-2 pr-4">Class</th>
|
||||
<th className="pb-2 pr-4">Origin</th>
|
||||
<th className="pb-2 pr-4">Destination</th>
|
||||
<th className="pb-2 pr-4">Amount Paid</th>
|
||||
<th className="pb-2">Booking Ref</th>
|
||||
<th className="pb-2 pr-4 whitespace-nowrap">Name</th>
|
||||
<th className="pb-2 pr-4 whitespace-nowrap">Nationality</th>
|
||||
<th className="pb-2 pr-4 whitespace-nowrap">Seat Class · Coach · Seat</th>
|
||||
<th className="pb-2 pr-4 whitespace-nowrap">Route</th>
|
||||
<th className="pb-2 pr-4 whitespace-nowrap">Amount Paid</th>
|
||||
<th className="pb-2 whitespace-nowrap">Booking Ref</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{filteredList.map((p, i) => (
|
||||
<tr
|
||||
key={`${p.bookingRef}-${i}`}
|
||||
className="hover:bg-muted/30"
|
||||
>
|
||||
<td className="py-2 pr-4 font-medium">
|
||||
{p.passengerName}
|
||||
</td>
|
||||
<tr key={`${p.bookingRef}-${i}`} className="hover:bg-muted/30">
|
||||
<td className="py-2 pr-4 font-medium whitespace-nowrap">{p.passengerName}</td>
|
||||
<td className="py-2 pr-4 text-xs">
|
||||
{p.passportNumber ? (
|
||||
<>
|
||||
<span className="text-muted-foreground">
|
||||
{p.passportCountry ?? "Intl"}
|
||||
</span>
|
||||
<span className="ml-1 font-mono">
|
||||
{p.passportNumber}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground">
|
||||
{p.idDocumentNumber ?? "—"}
|
||||
</span>
|
||||
<span className="text-muted-foreground">{p.nationality ?? '—'}</span>
|
||||
{p.passportNumber && (
|
||||
<span className="ml-1.5 font-mono text-foreground">{p.passportNumber}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pr-4 font-mono text-xs">
|
||||
{p.coachNumber && p.seatLabel ? (
|
||||
<>
|
||||
{p.coachNumber} · {p.seatLabel}
|
||||
{p.coachType && (
|
||||
<span className="font-sans text-muted-foreground ml-1">
|
||||
({p.coachType})
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
(p.coachNumber ?? p.seatLabel ?? "—")
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-muted-foreground text-xs">
|
||||
{p.origin && p.destination
|
||||
? `${p.origin} → ${p.destination}`
|
||||
: (p.origin ?? p.destination ?? "—")}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="tabular-nums font-medium">
|
||||
{(p.amountPaidMinor / 100).toLocaleString(
|
||||
"en-US",
|
||||
{
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
},
|
||||
)}{" "}
|
||||
{p.currency}
|
||||
</span>
|
||||
{p.isGroupBooking && (
|
||||
<span className="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-semibold bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300">
|
||||
Group
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="font-medium">{p.seatClassName ?? '—'}</span>
|
||||
{p.coachNumber && <span className="text-muted-foreground"> · {p.coachNumber}</span>}
|
||||
{p.seatNumber && <span className="text-muted-foreground"> · #{p.seatNumber}</span>}
|
||||
</td>
|
||||
<td className="py-2 font-mono text-xs">
|
||||
{p.bookingRef}
|
||||
<td className="py-2 pr-4 text-xs text-muted-foreground whitespace-nowrap">
|
||||
{p.origin && p.destination ? `${p.origin} → ${p.destination}` : (p.origin ?? p.destination ?? '—')}
|
||||
</td>
|
||||
<td className="py-2 pr-4 tabular-nums text-xs">
|
||||
{(p.amountPaidMinor / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })} {p.currency}
|
||||
</td>
|
||||
<td className="py-2 font-mono text-xs">{p.bookingRef}</td>
|
||||
</tr>
|
||||
))}
|
||||
{filteredList.length === 0 && (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={6}
|
||||
className="py-8 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
No passengers found
|
||||
</td>
|
||||
<td colSpan={6} className="py-8 text-center text-sm text-muted-foreground">No passengers found</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
|
||||
@@ -260,7 +260,7 @@ function drawJourneyLeg(doc: jsPDF, schedule: ScheduleInfo, legLabel: string | n
|
||||
doc.setTextColor(...INK); doc.setFontSize(16); doc.setFont('helvetica', 'bold');
|
||||
doc.text(schedule.origin.code, margin + padX, topY + 7);
|
||||
doc.setTextColor(...BODY); doc.setFontSize(8.5); doc.setFont('helvetica', 'normal');
|
||||
doc.text(schedule.origin.city || schedule.origin.name, margin + padX, topY + 11.5);
|
||||
doc.text(schedule.origin.name, margin + padX, topY + 11.5);
|
||||
|
||||
const dep = new Date(schedule.departureAt);
|
||||
doc.setTextColor(...BRAND); doc.setFontSize(11.5); doc.setFont('helvetica', 'bold');
|
||||
@@ -274,7 +274,7 @@ function drawJourneyLeg(doc: jsPDF, schedule: ScheduleInfo, legLabel: string | n
|
||||
doc.setTextColor(...INK); doc.setFontSize(16); doc.setFont('helvetica', 'bold');
|
||||
doc.text(schedule.destination.code, dx, topY + 7, { align: 'right' });
|
||||
doc.setTextColor(...BODY); doc.setFontSize(8.5); doc.setFont('helvetica', 'normal');
|
||||
doc.text(schedule.destination.city || schedule.destination.name, dx, topY + 11.5, { align: 'right' });
|
||||
doc.text(schedule.destination.name, dx, topY + 11.5, { align: 'right' });
|
||||
|
||||
const arr = new Date(schedule.arrivalAt);
|
||||
doc.setTextColor(...BRAND); doc.setFontSize(11.5); doc.setFont('helvetica', 'bold');
|
||||
|
||||
Reference in New Issue
Block a user