mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 07:08:18 +00:00
Merge pull request #872 from Tria-plc/testfixes
Warehouse Cards count fix
This commit is contained in:
@@ -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;`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -333,6 +333,8 @@ export class LastMileService {
|
|||||||
driverPhone: string | null;
|
driverPhone: string | null;
|
||||||
truckType: string | null;
|
truckType: string | null;
|
||||||
containerNumber: string | null;
|
containerNumber: string | null;
|
||||||
|
arrivedAt: string | null;
|
||||||
|
departedAt: string | null;
|
||||||
}>
|
}>
|
||||||
> {
|
> {
|
||||||
const [lm] = await this.lastMileRepository.findAll({
|
const [lm] = await this.lastMileRepository.findAll({
|
||||||
@@ -347,9 +349,11 @@ export class LastMileService {
|
|||||||
? lm.vehicleAssignments.map((va) => ({
|
? lm.vehicleAssignments.map((va) => ({
|
||||||
vehicle: va.vehicle,
|
vehicle: va.vehicle,
|
||||||
containerNumber: va.containerNumber ?? null,
|
containerNumber: va.containerNumber ?? null,
|
||||||
|
arrivedAt: va.arrivedAt ?? null,
|
||||||
|
departedAt: va.departedAt ?? null,
|
||||||
}))
|
}))
|
||||||
: lm.vehicle
|
: lm.vehicle
|
||||||
? [{ vehicle: lm.vehicle, containerNumber: null }]
|
? [{ vehicle: lm.vehicle, containerNumber: null, arrivedAt: null, departedAt: null }]
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const out: Array<{
|
const out: Array<{
|
||||||
@@ -361,8 +365,10 @@ export class LastMileService {
|
|||||||
driverPhone: string | null;
|
driverPhone: string | null;
|
||||||
truckType: string | null;
|
truckType: string | null;
|
||||||
containerNumber: 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;
|
if (!vehicle) continue;
|
||||||
let driverName = vehicle.assignedDriverName ?? null;
|
let driverName = vehicle.assignedDriverName ?? null;
|
||||||
let driverLicense: string | null = null;
|
let driverLicense: string | null = null;
|
||||||
@@ -386,6 +392,8 @@ export class LastMileService {
|
|||||||
driverPhone,
|
driverPhone,
|
||||||
truckType: vehicle.vehicleType || null,
|
truckType: vehicle.vehicleType || null,
|
||||||
containerNumber,
|
containerNumber,
|
||||||
|
arrivedAt: arrivedAt ? new Date(arrivedAt).toISOString() : null,
|
||||||
|
departedAt: departedAt ? new Date(departedAt).toISOString() : null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return out;
|
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';
|
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 {
|
export class CreateVehicleDto {
|
||||||
|
@Transform(normalizePlate)
|
||||||
|
@Matches(VEHICLE_PLATE_REGEX, { message: `Plate number ${VEHICLE_PLATE_MESSAGE}` })
|
||||||
@IsString()
|
@IsString()
|
||||||
plateNumber!: string;
|
plateNumber!: string;
|
||||||
|
|
||||||
@@ -47,10 +70,14 @@ export class CreateVehicleDto {
|
|||||||
code?: string;
|
code?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
@Transform(normalizePlate)
|
||||||
|
@Matches(VEHICLE_PLATE_REGEX, { message: `Power plate number ${VEHICLE_PLATE_MESSAGE}` })
|
||||||
@IsString()
|
@IsString()
|
||||||
powerPlateNo?: string;
|
powerPlateNo?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
@Transform(normalizePlate)
|
||||||
|
@Matches(VEHICLE_PLATE_REGEX, { message: `Trailer plate number ${VEHICLE_PLATE_MESSAGE}` })
|
||||||
@IsString()
|
@IsString()
|
||||||
trailerPlateNo?: string;
|
trailerPlateNo?: string;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
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 {
|
import {
|
||||||
WAREHOUSE_INVENTORY_STATUSES,
|
WAREHOUSE_INVENTORY_STATUSES,
|
||||||
@@ -71,4 +72,27 @@ export class FilterWarehouseInventoryDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
dateTo?: string;
|
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
|
* 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
|
* whole booking (`truckAssignmentId` null = per-booking), or one per truck when
|
||||||
* multiple trucks are used. Self-haul handovers are generated on truck arrival
|
* 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
|
* and signed before the truck leaves; EDR last-mile handovers are generated
|
||||||
* delivery (after exit).
|
* 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' })
|
@Entity({ schema: 'freight', name: 'booking_handovers' })
|
||||||
@Index(['bookingId'])
|
@Index(['bookingId'])
|
||||||
@@ -21,6 +22,10 @@ export class BookingHandover extends BaseEntity {
|
|||||||
@Column({ name: 'truck_assignment_id', type: 'uuid', nullable: true })
|
@Column({ name: 'truck_assignment_id', type: 'uuid', nullable: true })
|
||||||
truckAssignmentId?: string | null;
|
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). */
|
/** Denormalised plate for display / EDR trucks (which aren't customer trucks). */
|
||||||
@Column({ name: 'truck_plate', type: 'varchar', length: 32, nullable: true })
|
@Column({ name: 'truck_plate', type: 'varchar', length: 32, nullable: true })
|
||||||
truckPlate?: string | null;
|
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 { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
import { NotificationAudience, NotificationType } from '@edr/types';
|
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 { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
import { sendCompanyChannels } from '../notifications/notify-company.util';
|
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 ⇒
|
* Import handover records. A booking has one handover per truck (single truck ⇒
|
||||||
* one, effectively per-booking; multiple trucks ⇒ one each). Timing by mile type:
|
* one, effectively per-booking; multiple trucks ⇒ one each). Timing by mile type:
|
||||||
* - SELF_HAUL: generated when the customer truck arrives, signed before it leaves.
|
* - 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()
|
@Injectable()
|
||||||
export class HandoverService {
|
export class HandoverService {
|
||||||
@@ -25,14 +28,22 @@ export class HandoverService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** Tell the customer a handover is ready and needs their signature. */
|
/** 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 {
|
try {
|
||||||
const [b]: Array<{ companyId: string | null; reference: string }> = await this.dataSource.query(
|
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`,
|
`SELECT company_id AS "companyId", reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
|
||||||
[bookingId],
|
[bookingId],
|
||||||
);
|
);
|
||||||
if (!b?.companyId) return;
|
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({
|
await this.inbox.notify({
|
||||||
recipients: { companyId: b.companyId },
|
recipients: { companyId: b.companyId },
|
||||||
audience: NotificationAudience.PORTAL,
|
audience: NotificationAudience.PORTAL,
|
||||||
@@ -117,31 +128,98 @@ export class HandoverService {
|
|||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Find an existing EDR handover by assignment, else by plate, else booking-level. */
|
||||||
* EDR last-mile: generate a handover at delivery (after exit). One per EDR
|
private async findEdrHandover(
|
||||||
* truck (by plate) or per booking. Idempotent by (booking, plate).
|
repo: Repository<BookingHandover>,
|
||||||
*/
|
|
||||||
async ensureAtDelivery(
|
|
||||||
bookingId: string,
|
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,
|
manager?: EntityManager,
|
||||||
): Promise<BookingHandover> {
|
): Promise<BookingHandover> {
|
||||||
const m = manager ?? this.dataSource.manager;
|
const m = manager ?? this.dataSource.manager;
|
||||||
const repo = m.getRepository(BookingHandover);
|
const repo = m.getRepository(BookingHandover);
|
||||||
const existing = await repo.findOne({
|
const existing = await this.findEdrHandover(repo, bookingId, opts);
|
||||||
where: {
|
|
||||||
bookingId,
|
|
||||||
truckPlate: opts.truckPlate ?? IsNull(),
|
|
||||||
truckAssignmentId: opts.truckAssignmentId ?? IsNull(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (existing) return existing;
|
if (existing) return existing;
|
||||||
|
|
||||||
const reference = await this.generateReference(bookingId, m);
|
const reference = await this.generateReference(bookingId, m);
|
||||||
return repo.save(
|
const saved = await repo.save(
|
||||||
repo.create({
|
repo.create({
|
||||||
bookingId,
|
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,
|
truckPlate: opts.truckPlate ?? null,
|
||||||
mileType: 'EDR_LAST_MILE',
|
mileType: 'EDR_LAST_MILE',
|
||||||
reference,
|
reference,
|
||||||
@@ -149,6 +227,25 @@ export class HandoverService {
|
|||||||
deliveredAt: new Date(),
|
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). */
|
/** Sign all unsigned handovers on a booking (self-haul: before the truck leaves). */
|
||||||
async signForBooking(
|
async signForBooking(
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
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 { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||||
import { generateGrnNumber } from '../../common/grn.util';
|
import { generateGrnNumber } from '../../common/grn.util';
|
||||||
@@ -68,6 +80,7 @@ const isLoadableWagonStatus = (status: string | null | undefined) =>
|
|||||||
|
|
||||||
const CUSTOMER_DELIVERY_APPROVAL_PREFIX = 'CUSTOMER_DELIVERY_APPROVAL:';
|
const CUSTOMER_DELIVERY_APPROVAL_PREFIX = 'CUSTOMER_DELIVERY_APPROVAL:';
|
||||||
const HANDOVER_DOCUMENT_MARKER = '[Handover Document]';
|
const HANDOVER_DOCUMENT_MARKER = '[Handover Document]';
|
||||||
|
const EXIT_INSPECTION_MARKER = '[Exit Inspection]';
|
||||||
|
|
||||||
export interface InventoryInquiryResult {
|
export interface InventoryInquiryResult {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -508,12 +521,20 @@ export class WarehouseInventoryService {
|
|||||||
WHERE deleted_at IS NULL AND created_at::date = CURRENT_DATE - 1) AS "receivedYesterday",
|
WHERE deleted_at IS NULL AND created_at::date = CURRENT_DATE - 1) AS "receivedYesterday",
|
||||||
(SELECT count(*)::int FROM freight.warehouse_inventory
|
(SELECT count(*)::int FROM freight.warehouse_inventory
|
||||||
WHERE deleted_at IS NULL AND status = 'RECEIVED' AND inspection_status IS NULL) AS "pendingInspection",
|
WHERE deleted_at IS NULL AND status = 'RECEIVED' AND inspection_status IS NULL) AS "pendingInspection",
|
||||||
(SELECT count(*)::int FROM freight.customer_truck_assignments
|
-- Both haulage paths, mirroring the ON_SITE rows of trucksOnSite()
|
||||||
WHERE deleted_at IS NULL AND arrived_at IS NOT NULL AND departed_at IS NULL) AS "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
|
(SELECT count(*)::int FROM freight.warehouse_inventory
|
||||||
WHERE deleted_at IS NULL
|
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"`,
|
AND created_at < now() - interval '7 days') AS "itemsAging"`,
|
||||||
|
[this.IN_WAREHOUSE_STATUSES],
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
receivedToday: row?.receivedToday ?? 0,
|
receivedToday: row?.receivedToday ?? 0,
|
||||||
@@ -525,7 +546,7 @@ export class WarehouseInventoryService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** In-warehouse statuses used by the dwell / aging metrics. */
|
/** In-warehouse statuses used by the dwell / aging metrics. */
|
||||||
private readonly IN_WAREHOUSE_STATUSES = [
|
private readonly IN_WAREHOUSE_STATUSES: WarehouseInventoryStatus[] = [
|
||||||
'RECEIVED',
|
'RECEIVED',
|
||||||
'UNLOADED',
|
'UNLOADED',
|
||||||
'STORED',
|
'STORED',
|
||||||
@@ -953,7 +974,7 @@ export class WarehouseInventoryService {
|
|||||||
? LessThanOrEqual(new Date(filter.dateTo))
|
? LessThanOrEqual(new Date(filter.dateTo))
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const base = {
|
const base: FindOptionsWhere<WarehouseInventory> = {
|
||||||
...(filter.warehouseId ? { warehouseId: filter.warehouseId } : {}),
|
...(filter.warehouseId ? { warehouseId: filter.warehouseId } : {}),
|
||||||
...(filter.yardId ? { yardId: filter.yardId } : {}),
|
...(filter.yardId ? { yardId: filter.yardId } : {}),
|
||||||
...(filter.zoneId ? { zoneId: filter.zoneId } : {}),
|
...(filter.zoneId ? { zoneId: filter.zoneId } : {}),
|
||||||
@@ -967,6 +988,24 @@ export class WarehouseInventoryService {
|
|||||||
...(filter.direction ? { booking: { tradeDirection: filter.direction } } : {}),
|
...(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 search = filter.search?.trim();
|
||||||
const where: FindManyOptions<WarehouseInventory>['where'] = search
|
const where: FindManyOptions<WarehouseInventory>['where'] = search
|
||||||
? [
|
? [
|
||||||
@@ -2972,12 +3011,29 @@ export class WarehouseInventoryService {
|
|||||||
const releaseDate = isTruckLeaving
|
const releaseDate = isTruckLeaving
|
||||||
? dto.releaseDate ? new Date(dto.releaseDate) : new Date()
|
? dto.releaseDate ? new Date(dto.releaseDate) : new Date()
|
||||||
: item.releaseDate ?? null;
|
: item.releaseDate ?? null;
|
||||||
const reference = isTruckLeaving
|
// One reference per item — the first truck's arrival mints it, later trucks
|
||||||
? item.releaseOrderReference || dto.reference?.trim() || (await this.generateReleaseReference(item))
|
// (arrival or exit) reuse it so all exit papers share the release order.
|
||||||
: dto.reference?.trim() || (await this.generateReleaseReference(item));
|
const reference =
|
||||||
const exitInspectionDto = isTruckLeaving
|
item.releaseOrderReference || dto.reference?.trim() || (await this.generateReleaseReference(item));
|
||||||
? this.preserveTruckArrivalForExit(dto, item.notes)
|
const exitInspectionDto = {
|
||||||
: dto;
|
...(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);
|
const exitInspectionNote = this.buildExitInspectionNote(exitInspectionDto);
|
||||||
|
|
||||||
// The load actually leaving on this truck, in TONNES (the weighing UI is in
|
// The load actually leaving on this truck, in TONNES (the weighing UI is in
|
||||||
@@ -2990,12 +3046,46 @@ export class WarehouseInventoryService {
|
|||||||
? Math.round((grossTons - tareTons) * 1000) / 1000
|
? Math.round((grossTons - tareTons) * 1000) / 1000
|
||||||
: (exitInspectionDto.netWeight ?? null);
|
: (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 this.dataSource.transaction(async (manager) => {
|
||||||
await manager.getRepository(WarehouseInventory).update(id, {
|
await manager.getRepository(WarehouseInventory).update(id, {
|
||||||
releaseDate,
|
releaseDate,
|
||||||
releaseOrderReference: reference,
|
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) {
|
if (!isTruckLeaving && item.bookingId) {
|
||||||
// Per-truck arrival: mark the customer truck carrying THIS item's
|
// Per-truck arrival: mark the customer truck carrying THIS item's
|
||||||
// container as arrived (matched via the physical container number).
|
// container as arrived (matched via the physical container number).
|
||||||
@@ -3861,7 +3951,9 @@ export class WarehouseInventoryService {
|
|||||||
`SELECT inv.id,
|
`SELECT inv.id,
|
||||||
inv.booking_id AS "bookingId",
|
inv.booking_id AS "bookingId",
|
||||||
inv.quantity,
|
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.status,
|
||||||
inv.notes,
|
inv.notes,
|
||||||
inv.inspection_status AS "inspectionStatus",
|
inv.inspection_status AS "inspectionStatus",
|
||||||
@@ -3913,6 +4005,16 @@ export class WarehouseInventoryService {
|
|||||||
WHERE bc.booking_id = b.id
|
WHERE bc.booking_id = b.id
|
||||||
AND bc.deleted_at IS NULL
|
AND bc.deleted_at IS NULL
|
||||||
) booking_container ON true
|
) 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.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL
|
||||||
LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id)
|
LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id)
|
||||||
LEFT JOIN freight.train_schedule_bookings tsb ON tsb.booking_id = b.id AND tsb.deleted_at IS NULL
|
LEFT JOIN freight.train_schedule_bookings tsb ON tsb.booking_id = b.id AND tsb.deleted_at IS NULL
|
||||||
@@ -4009,14 +4111,25 @@ export class WarehouseInventoryService {
|
|||||||
if (!(await this.handover.isFullySigned(item.bookingId))) {
|
if (!(await this.handover.isFullySigned(item.bookingId))) {
|
||||||
throw new BadRequestException('Handover must be signed before delivery');
|
throw new BadRequestException('Handover must be signed before delivery');
|
||||||
}
|
}
|
||||||
const [left]: Array<{ n: string }> = await this.dataSource.query(
|
const [trucks]: Array<{ total: string; left: string }> = await this.dataSource.query(
|
||||||
`SELECT COUNT(*) AS n FROM freight.customer_truck_assignments
|
`SELECT COUNT(*) AS total,
|
||||||
WHERE booking_id = $1 AND departed_at IS NOT NULL AND deleted_at IS NULL`,
|
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],
|
[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');
|
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)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5297,7 +5410,11 @@ export class WarehouseInventoryService {
|
|||||||
weighingSkipped ? 'Weighing: SKIPPED' : null,
|
weighingSkipped ? 'Weighing: SKIPPED' : null,
|
||||||
tareWeight == null ? null : `Tare Weight: ${tareWeight} t`,
|
tareWeight == null ? null : `Tare Weight: ${tareWeight} t`,
|
||||||
grossWeight == null ? null : `Gross Weight: ${grossWeight} 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,
|
dto.gateOutTime ? `Gate Out Time: ${dto.gateOutTime}` : null,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -5305,12 +5422,14 @@ export class WarehouseInventoryService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private preserveTruckArrivalForExit(dto: ReleaseOrderDto, notes: string | null | undefined): ReleaseOrderDto {
|
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;
|
if (!inspection) return dto;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...dto,
|
...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,
|
trailerPlateNumber: this.extractExitInspectionLine(inspection, 'Trailer Plate') || dto.trailerPlateNumber,
|
||||||
driverName: this.extractExitInspectionLine(inspection, 'Driver') || dto.driverName,
|
driverName: this.extractExitInspectionLine(inspection, 'Driver') || dto.driverName,
|
||||||
driverLicense: this.extractExitInspectionLine(inspection, 'Driver License') || dto.driverLicense,
|
driverLicense: this.extractExitInspectionLine(inspection, 'Driver License') || dto.driverLicense,
|
||||||
@@ -5324,25 +5443,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();
|
const trimmed = notes?.trim();
|
||||||
if (!exitInspectionNote) return trimmed || null;
|
if (!trimmed) return { others: [], blocks: [] };
|
||||||
if (!trimmed) return exitInspectionNote;
|
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 marker = '[Exit Inspection]';
|
const parts = trimmed.split(EXIT_INSPECTION_MARKER);
|
||||||
const index = trimmed.lastIndexOf(marker);
|
const others: string[] = [];
|
||||||
if (index < 0) {
|
const blocks: string[] = [];
|
||||||
return `${trimmed}\n\n${exitInspectionNote}`;
|
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 {
|
private extractExitInspectionNote(notes?: string | null): string | null {
|
||||||
if (!notes) return null;
|
const { blocks } = this.splitExitInspectionSections(notes);
|
||||||
const marker = '[Exit Inspection]';
|
return blocks.length ? blocks[blocks.length - 1] : null;
|
||||||
const index = notes.lastIndexOf(marker);
|
}
|
||||||
if (index < 0) return null;
|
|
||||||
return notes.slice(index + marker.length).trim() || 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 {
|
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);
|
setErrors(next);
|
||||||
return Object.keys(next).length === 0;
|
return Object.keys(next).length === 0;
|
||||||
|
|||||||
@@ -2647,7 +2647,9 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{/* Primary stage action stays visible; the rest live under the kebab. */}
|
{/* 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
|
<Button
|
||||||
size="compact-xs"
|
size="compact-xs"
|
||||||
variant="light"
|
variant="light"
|
||||||
@@ -2655,7 +2657,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
|||||||
leftSection={<Truck size={14} />}
|
leftSection={<Truck size={14} />}
|
||||||
onClick={() => setReleaseItem(toInventoryItem(r))}
|
onClick={() => setReleaseItem(toInventoryItem(r))}
|
||||||
>
|
>
|
||||||
{r.releaseOrderReference ? 'Truck Leaving' : 'Truck Arrival'}
|
{r.releaseOrderReference ? 'Truck Arrival / Leaving' : 'Truck Arrival'}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
|
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
|
||||||
@@ -2692,7 +2694,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
|||||||
Ready for pickup
|
Ready for pickup
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
)}
|
)}
|
||||||
{r.currentStatus === 'READY_FOR_PICKUP' && !r.releaseDate && (
|
{r.currentStatus === 'READY_FOR_PICKUP' && (
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<Truck size={14} />}
|
leftSection={<Truck size={14} />}
|
||||||
disabled={!r.hasAssignedTruck}
|
disabled={!r.hasAssignedTruck}
|
||||||
@@ -2700,7 +2702,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
|||||||
>
|
>
|
||||||
{r.hasAssignedTruck
|
{r.hasAssignedTruck
|
||||||
? r.releaseOrderReference
|
? r.releaseOrderReference
|
||||||
? 'Truck leaving'
|
? 'Truck arrival / leaving'
|
||||||
: 'Truck arrival'
|
: 'Truck arrival'
|
||||||
: 'Truck arrival — assign a truck first'}
|
: 'Truck arrival — assign a truck first'}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Alert, Button, Group, Modal, MultiSelect, NumberInput, SegmentedControl, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
|
import { Alert, Badge, Button, Group, Modal, MultiSelect, NumberInput, SegmentedControl, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
|
||||||
import { Info, Scale } from 'lucide-react';
|
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 { api } from '@/services/api';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
@@ -28,6 +28,8 @@ export interface ReleaseOrderTruckPrefill {
|
|||||||
containerNumber?: string | null;
|
containerNumber?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const EXIT_INSPECTION_MARKER = '[Exit Inspection]';
|
||||||
|
|
||||||
const toIsoDateTime = (value: string) => {
|
const toIsoDateTime = (value: string) => {
|
||||||
if (!value) return undefined;
|
if (!value) return undefined;
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
@@ -70,9 +72,6 @@ const splitContainerNumbers = (value: string | null | undefined) =>
|
|||||||
const getItemContainerNumber = (item: WarehouseInventoryItem | null) =>
|
const getItemContainerNumber = (item: WarehouseInventoryItem | null) =>
|
||||||
(item as (WarehouseInventoryItem & { containerNumber?: string | null }) | null)?.containerNumber ?? '';
|
(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 isContainerInventory = (item: WarehouseInventoryItem | null, containerCount: number) => {
|
||||||
const freightType = (item as (WarehouseInventoryItem & { booking?: { freightType?: string | null } | null }) | null)
|
const freightType = (item as (WarehouseInventoryItem & { booking?: { freightType?: string | null } | null }) | null)
|
||||||
?.booking?.freightType;
|
?.booking?.freightType;
|
||||||
@@ -88,29 +87,66 @@ const initialContainerNumbers = (item: WarehouseInventoryItem | null, savedConta
|
|||||||
return Array.from({ length: expectedCount }, (_, index) => sourceNumbers[index] ?? '');
|
return Array.from({ length: expectedCount }, (_, index) => sourceNumbers[index] ?? '');
|
||||||
};
|
};
|
||||||
|
|
||||||
const parseInspectionNote = (notes: string | null | undefined) => {
|
/** One truck's saved arrival/exit weighing, parsed from its inspection block. */
|
||||||
const marker = '[Exit Inspection]';
|
interface InspectionBlock {
|
||||||
const index = notes?.lastIndexOf(marker) ?? -1;
|
truckPlateNumber: string;
|
||||||
const note = index >= 0 ? notes?.slice(index + marker.length) : notes;
|
trailerPlateNumber: string;
|
||||||
return {
|
driverName: string;
|
||||||
truckPlateNumber: lineValue(note, 'Truck Plate'),
|
driverLicense: string;
|
||||||
trailerPlateNumber: lineValue(note, 'Trailer Plate'),
|
driverPhone: string;
|
||||||
driverName: lineValue(note, 'Driver'),
|
truckType: string;
|
||||||
driverLicense: lineValue(note, 'Driver License'),
|
containerNumber: string;
|
||||||
driverPhone: lineValue(note, 'Driver Phone'),
|
gateInTime: string;
|
||||||
truckType: lineValue(note, 'Truck Type'),
|
tareWeight: number | '';
|
||||||
containerNumber: lineValue(note, 'Container Number'),
|
grossWeight: number | '';
|
||||||
gateInTime: toLocalDateTimeInput(lineValue(note, 'Gate In Time')),
|
netWeight: number | '';
|
||||||
tareWeight: lineNumber(note, 'Tare Weight'),
|
gateOutTime: string;
|
||||||
grossWeight: lineNumber(note, 'Gross Weight'),
|
weighingSkipped: boolean;
|
||||||
netWeight: lineNumber(note, 'Net Weight'),
|
}
|
||||||
gateOutTime: toLocalDateTimeInput(lineValue(note, 'Gate Out Time')),
|
|
||||||
weighingSkipped: /^Weighing:\s*SKIPPED/im.test(note ?? ''),
|
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) {
|
export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: ReleaseOrderModalProps) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const releaseMutation = useMutation(api.warehouses.release.mutationOptions());
|
const releaseMutation = useMutation(api.warehouses.release.mutationOptions());
|
||||||
// Some openers (inventory workbench) supply bookingId without the booking
|
// Some openers (inventory workbench) supply bookingId without the booking
|
||||||
// relation — fall back to it, or the truck/container-weight queries never run.
|
// 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 [netWeight, setNetWeight] = useState<number | ''>('');
|
||||||
const [gateOutTime, setGateOutTime] = useState('');
|
const [gateOutTime, setGateOutTime] = useState('');
|
||||||
const [downloading, setDownloading] = useState(false);
|
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(() => {
|
const savedBlocks = parseInspectionBlocks(item?.notes);
|
||||||
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]);
|
|
||||||
|
|
||||||
// Registered trucks for THIS booking, from both sources: EDR last-mile
|
// Registered trucks for THIS booking, from both sources: EDR last-mile
|
||||||
// (truckPrefill) and the customer portal (customer_truck_assignments).
|
// (truckPrefill) and the customer portal (customer_truck_assignments).
|
||||||
const assignedTruckOptions = [
|
const assignedTruckOptions = [
|
||||||
...(truckPrefill?.truckPlateNumber
|
...(truckPrefill?.truckPlateNumber && !truckPrefill.truckPlateNumber.includes(',')
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
value: truckPrefill.truckPlateNumber,
|
value: truckPrefill.truckPlateNumber,
|
||||||
@@ -232,6 +206,9 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
driverName: truckPrefill.driverName ?? '',
|
driverName: truckPrefill.driverName ?? '',
|
||||||
driverPhone: truckPrefill.driverPhone ?? '',
|
driverPhone: truckPrefill.driverPhone ?? '',
|
||||||
truckType: truckPrefill.truckType ?? '',
|
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,
|
driverName: t.driverName,
|
||||||
driverPhone: '',
|
driverPhone: '',
|
||||||
truckType: t.truckType,
|
truckType: t.truckType,
|
||||||
|
containerNumbers: (t.containers ?? []).map((c) => c.containerNumber).filter(Boolean),
|
||||||
|
arrived: Boolean(t.arrivedAt),
|
||||||
|
left: Boolean(t.departedAt),
|
||||||
})),
|
})),
|
||||||
...lastMileTrucks
|
...lastMileTrucks
|
||||||
.filter((t) => t.truckPlateNumber || t.vehicleId)
|
.filter((t) => t.truckPlateNumber || t.vehicleId)
|
||||||
@@ -252,6 +232,9 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
driverName: t.driverName ?? '',
|
driverName: t.driverName ?? '',
|
||||||
driverPhone: t.driverPhone ?? '',
|
driverPhone: t.driverPhone ?? '',
|
||||||
truckType: t.truckType ?? '',
|
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
|
// 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 = [
|
const truckSelectOptions = [
|
||||||
...new Map(assignedTruckOptions.map((t) => [t.value, t])).values(),
|
...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.
|
// Neither a last-mile truck nor a customer truck has been assigned yet.
|
||||||
const noTruckAssigned = assignedTruckOptions.length === 0 && !isCustomerAssignedTruck;
|
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
|
// Which containers ride this truck, and their combined cargo weight. When the
|
||||||
// booking has container weights, that sum is the authoritative net; 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.
|
// Skip is only offered for container bookings; bulk always weighs.
|
||||||
const skipWeighing = hasContainerWeights && weighTruck === 'no';
|
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
|
const systemNetWeight = useContainerNet
|
||||||
? selectedCargoWeight
|
? selectedCargoWeight
|
||||||
@@ -314,6 +421,10 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
toast({ variant: 'destructive', title: 'Truck plate and driver name are required' });
|
toast({ variant: 'destructive', title: 'Truck plate and driver name are required' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (hasTruckLeft) {
|
||||||
|
toast({ variant: 'destructive', title: `Truck ${truckPlateNumber} has already left — its exit record is locked` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!gateInTime || (!skipWeighing && tareWeight === '')) {
|
if (!gateInTime || (!skipWeighing && tareWeight === '')) {
|
||||||
toast({
|
toast({
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
@@ -363,13 +474,17 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
weighingSkipped: skipWeighing || undefined,
|
weighingSkipped: skipWeighing || undefined,
|
||||||
tareWeight: skipWeighing ? undefined : Number(tareWeight),
|
tareWeight: skipWeighing ? undefined : Number(tareWeight),
|
||||||
grossWeight: skipWeighing || grossWeight === '' ? undefined : Number(grossWeight),
|
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,
|
gateOutTime: isExitStep ? toIsoDateTime(gateOutTime) : undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['release-customer-trucks', bookingId] });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['release-last-mile-trucks', bookingId] });
|
||||||
if (!isExitStep) {
|
if (!isExitStep) {
|
||||||
|
const remaining = totalTrucks > 1 ? ` (${Math.min(arrivedTrucks + 1, totalTrucks)} of ${totalTrucks} trucks arrived)` : '';
|
||||||
toast({
|
toast({
|
||||||
title: 'Truck arrival saved',
|
title: `Truck ${truckPlateNumber.trim()} arrival saved${remaining}`,
|
||||||
description: `${released.releaseOrderReference ?? reference} is ready for exit weighing.`,
|
description: `${released.releaseOrderReference ?? reference} is ready for exit weighing.`,
|
||||||
});
|
});
|
||||||
onClose();
|
onClose();
|
||||||
@@ -380,11 +495,12 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
const blob = response.data;
|
const blob = response.data;
|
||||||
const filename = `release-${released.booking?.reference ?? released.bookingId ?? item.id}.pdf`;
|
const filename = `release-${released.booking?.reference ?? released.bookingId ?? item.id}.pdf`;
|
||||||
const opened = openPdfBlob(blob, filename, pdfWindow);
|
const opened = openPdfBlob(blob, filename, pdfWindow);
|
||||||
|
const remainingExit = totalTrucks > 1 ? ` ${Math.min(leftTrucks + 1, totalTrucks)} of ${totalTrucks} trucks have left.` : '';
|
||||||
toast({
|
toast({
|
||||||
title: 'Release exit paper issued',
|
title: 'Release exit paper issued',
|
||||||
description: opened
|
description: (opened
|
||||||
? 'The PDF opened in a browser tab for printing or saving.'
|
? '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();
|
onClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -411,12 +527,35 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Alert>
|
</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
|
<TextInput
|
||||||
label="Release document reference"
|
label="Release document reference"
|
||||||
placeholder="e.g. REL-2026-001"
|
placeholder="e.g. REL-2026-001"
|
||||||
value={reference}
|
value={reference}
|
||||||
onChange={(e) => setReference(e.currentTarget.value)}
|
onChange={(e) => setReference(e.currentTarget.value)}
|
||||||
readOnly={isEntranceLocked}
|
readOnly={referenceLocked}
|
||||||
/>
|
/>
|
||||||
{noTruckAssigned && (
|
{noTruckAssigned && (
|
||||||
<Alert color="orange" variant="light" icon={<Info size={16} />}>
|
<Alert color="orange" variant="light" icon={<Info size={16} />}>
|
||||||
@@ -425,22 +564,19 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
)}
|
)}
|
||||||
{truckSelectOptions.length > 0 && (
|
{truckSelectOptions.length > 0 && (
|
||||||
<Select
|
<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"
|
placeholder="Select the assigned truck"
|
||||||
searchable
|
searchable
|
||||||
clearable
|
clearable
|
||||||
// Enabled at arrival so the operator picks which assigned truck came;
|
disabled={releaseMutation.isPending || downloading}
|
||||||
// only locked on the exit (leaving) step once identity is captured.
|
data={truckSelectOptions.map(({ value, label, arrived, left }) => ({
|
||||||
disabled={isEntranceLocked}
|
value,
|
||||||
data={truckSelectOptions}
|
label: `${label}${left ? ' · LEFT' : arrived ? ' · ON SITE' : ''}`,
|
||||||
|
}))}
|
||||||
value={truckSelectOptions.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null}
|
value={truckSelectOptions.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
const truck = truckSelectOptions.find((row) => row.value === value);
|
if (value) applyTruckSelection(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);
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -481,6 +617,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
data={containerSelectData}
|
data={containerSelectData}
|
||||||
value={selectedContainerNumbers}
|
value={selectedContainerNumbers}
|
||||||
onChange={(values) => setContainerNumbers(values.length ? values : [''])}
|
onChange={(values) => setContainerNumbers(values.length ? values : [''])}
|
||||||
|
disabled={hasTruckLeft}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Stack gap={6}>
|
<Stack gap={6}>
|
||||||
@@ -514,13 +651,15 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
disabled={isEntranceLocked}
|
disabled={isEntranceLocked}
|
||||||
/>
|
/>
|
||||||
{skipWeighing && (
|
{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>
|
||||||
)}
|
)}
|
||||||
<Group grow>
|
<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="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
|
<NumberInput
|
||||||
label={useContainerNet ? 'Selected cargo net (t)' : 'Recorded net weight (system t)'}
|
label={useContainerNet ? 'Selected cargo net (t)' : 'Recorded net weight (system t)'}
|
||||||
min={0}
|
min={0}
|
||||||
@@ -532,7 +671,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
<Text size="sm" c={weightMismatch ? 'red' : 'dimmed'}>
|
<Text size="sm" c={weightMismatch ? 'red' : 'dimmed'}>
|
||||||
Computed net: <b>{computedNetWeight == null ? '-' : `${computedNetWeight.toLocaleString()} t`}</b>
|
Computed net: <b>{computedNetWeight == null ? '-' : `${computedNetWeight.toLocaleString()} t`}</b>
|
||||||
</Text>
|
</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>
|
</Group>
|
||||||
{weightMismatch && (
|
{weightMismatch && (
|
||||||
<Alert icon={<Scale size={16} />} color="red" variant="light">
|
<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}>
|
<Button variant="default" onClick={onClose} disabled={releaseMutation.isPending || downloading}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</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'}
|
{isExitStep ? 'Save Truck Leaving & View Exit Paper' : 'Save Truck Arrival'}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -51,8 +51,10 @@ const actionColor: Record<InventoryAction, string> = {
|
|||||||
deliver: 'green',
|
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) =>
|
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 noteLineValue = (notes: string | null | undefined, label: string) => {
|
||||||
const match = notes?.match(new RegExp(`^${label}:\\s*(.+)$`, 'im'));
|
const match = notes?.match(new RegExp(`^${label}:\\s*(.+)$`, 'im'));
|
||||||
@@ -276,6 +278,19 @@ export function WarehouseInventoryTable({
|
|||||||
{nextAction === 'release' ? releaseActionLabel(item) : humanizeEnum(nextAction.replace(/-/g, '_'))}
|
{nextAction === 'release' ? releaseActionLabel(item) : humanizeEnum(nextAction.replace(/-/g, '_'))}
|
||||||
</Button>
|
</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' && (
|
{item.status === 'READY_FOR_PICKUP' && (
|
||||||
<Button
|
<Button
|
||||||
size="compact-xs"
|
size="compact-xs"
|
||||||
|
|||||||
@@ -23,23 +23,24 @@ export function WarehouseOpsKpiStrip() {
|
|||||||
delta:
|
delta:
|
||||||
data != null ? data.receivedToday - data.receivedYesterday : undefined,
|
data != null ? data.receivedToday - data.receivedYesterday : undefined,
|
||||||
hint: "vs yesterday",
|
hint: "vs yesterday",
|
||||||
// The received cargo itself, on the inventory board.
|
// Exactly the items behind the counter: received today.
|
||||||
href: "/dashboard/warehouse-inventory?status=RECEIVED",
|
href: "/dashboard/warehouse-inventory?receivedToday=1",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Pending inspection",
|
label: "Pending inspection",
|
||||||
value: data?.pendingInspection ?? 0,
|
value: data?.pendingInspection ?? 0,
|
||||||
icon: ClipboardCheck,
|
icon: ClipboardCheck,
|
||||||
color: "yellow",
|
color: "yellow",
|
||||||
// Received cargo still awaiting inspection lives in the RECEIVED bucket.
|
// RECEIVED items with no inspection recorded yet.
|
||||||
href: "/dashboard/warehouse-inventory?status=RECEIVED",
|
href: "/dashboard/warehouse-inventory?pendingInspection=1",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Trucks on-site",
|
label: "Trucks on-site",
|
||||||
value: data?.trucksOnSite ?? 0,
|
value: data?.trucksOnSite ?? 0,
|
||||||
icon: Truck,
|
icon: Truck,
|
||||||
color: "blue",
|
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)",
|
label: "Items aging (>7d)",
|
||||||
@@ -47,8 +48,7 @@ export function WarehouseOpsKpiStrip() {
|
|||||||
icon: AlertTriangle,
|
icon: AlertTriangle,
|
||||||
color: (data?.itemsAging ?? 0) > 0 ? "red" : "edr-green",
|
color: (data?.itemsAging ?? 0) > 0 ? "red" : "edr-green",
|
||||||
hint: "In warehouse over 7 days",
|
hint: "In warehouse over 7 days",
|
||||||
// No aging filter on the board; the inventory list is the landing.
|
href: "/dashboard/warehouse-inventory?agingOverDays=7",
|
||||||
href: "/dashboard/warehouse-inventory",
|
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -69,6 +69,11 @@ export interface FleetFormFieldDef extends FormFieldDef {
|
|||||||
* (e.g. a license expiry); "past" (default) = cannot be in the future.
|
* (e.g. a license expiry); "past" (default) = cannot be in the future.
|
||||||
*/
|
*/
|
||||||
dateBound?: "past" | "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 {
|
export interface FleetListFilterDef {
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
import type { FleetResourceConfig } from "./resources";
|
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 = [
|
const VEHICLE_TYPE_OPTIONS = [
|
||||||
{ label: "Truck", value: "TRUCK" },
|
{ label: "Truck", value: "TRUCK" },
|
||||||
{ label: "Van", value: "VAN" },
|
{ label: "Van", value: "VAN" },
|
||||||
@@ -77,9 +87,9 @@ export const vehiclesConfig: FleetResourceConfig = {
|
|||||||
],
|
],
|
||||||
formFields: [
|
formFields: [
|
||||||
{ name: "code", label: "Code", type: "text" },
|
{ 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: "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: "vehicleType", label: "Vehicle Type", type: "select", required: true, options: VEHICLE_TYPE_OPTIONS },
|
||||||
{ name: "manufacturer", label: "Manufacturer", type: "text", required: true },
|
{ name: "manufacturer", label: "Manufacturer", type: "text", required: true },
|
||||||
{ name: "model", label: "Model", type: "text", required: true },
|
{ name: "model", label: "Model", type: "text", required: true },
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Badge,
|
Badge,
|
||||||
@@ -139,7 +140,13 @@ function Rows({ rows }: { rows: TruckOnSite[] }) {
|
|||||||
|
|
||||||
export default function TrucksOnSitePage() {
|
export default function TrucksOnSitePage() {
|
||||||
const { data: trucks = [], isLoading } = useTrucksOnSite();
|
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 [source, setSource] = useState<"ALL" | "CUSTOMER" | "EDR">("ALL");
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react';
|
|||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { Button, Card, Group, Select, Stack, TextInput } from '@mantine/core';
|
import { Button, Card, Group, Select, Stack, TextInput } from '@mantine/core';
|
||||||
import { useDebouncedValue } from '@mantine/hooks';
|
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 { PageContainer, PageHeader } from '@/components/page';
|
||||||
import {
|
import {
|
||||||
@@ -25,14 +25,36 @@ export default function WarehouseInventoryPage() {
|
|||||||
const [filter, setFilter] = useState<InventoryFilter>(
|
const [filter, setFilter] = useState<InventoryFilter>(
|
||||||
initialStatus ? { status: initialStatus } : {},
|
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 [search, setSearch] = useState('');
|
||||||
|
|
||||||
const [debouncedSearch] = useDebouncedValue(search, 300);
|
const [debouncedSearch] = useDebouncedValue(search, 300);
|
||||||
const queryFilter = useMemo<InventoryFilter>(
|
const queryFilter = useMemo<InventoryFilter>(
|
||||||
() => ({ ...filter, direction, search: debouncedSearch || undefined }),
|
() => ({ ...filter, ...quick, direction, search: debouncedSearch || undefined }),
|
||||||
[filter, direction, debouncedSearch],
|
[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 warehousesQuery = useWarehouses();
|
||||||
const yardsQuery = useWarehouseYards(filter.warehouseId);
|
const yardsQuery = useWarehouseYards(filter.warehouseId);
|
||||||
const zonesQuery = useWarehouseZones(filter.yardId);
|
const zonesQuery = useWarehouseZones(filter.yardId);
|
||||||
@@ -136,6 +158,17 @@ export default function WarehouseInventoryPage() {
|
|||||||
}
|
}
|
||||||
w={200}
|
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>
|
</Group>
|
||||||
|
|
||||||
<InventoryWorkbench items={inventoryQuery.data ?? []} isLoading={inventoryQuery.isLoading} />
|
<InventoryWorkbench items={inventoryQuery.data ?? []} isLoading={inventoryQuery.isLoading} />
|
||||||
|
|||||||
@@ -144,6 +144,8 @@ export interface LastMileArrivalTruck {
|
|||||||
driverPhone: string | null;
|
driverPhone: string | null;
|
||||||
truckType: string | null;
|
truckType: string | null;
|
||||||
containerNumber: string | null;
|
containerNumber: string | null;
|
||||||
|
arrivedAt: string | null;
|
||||||
|
departedAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const warehouseService = {
|
export const warehouseService = {
|
||||||
|
|||||||
@@ -1083,6 +1083,10 @@ export interface InventoryFilter {
|
|||||||
search?: string;
|
search?: string;
|
||||||
dateFrom?: string;
|
dateFrom?: string;
|
||||||
dateTo?: string;
|
dateTo?: string;
|
||||||
|
/** KPI drill-downs — mirror the ops-stats counters exactly. */
|
||||||
|
receivedToday?: boolean;
|
||||||
|
pendingInspection?: boolean;
|
||||||
|
agingOverDays?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InventoryInquiryFilter {
|
export interface InventoryInquiryFilter {
|
||||||
|
|||||||
@@ -546,6 +546,7 @@ export interface ICustomerTruck {
|
|||||||
truckType: string;
|
truckType: string;
|
||||||
assignedAt: string;
|
assignedAt: string;
|
||||||
arrivedAt?: string | null;
|
arrivedAt?: string | null;
|
||||||
|
departedAt?: string | null;
|
||||||
containers?: ICustomerTruckContainer[];
|
containers?: ICustomerTruckContainer[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user