mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 11:55:42 +00:00
feat(warehouses): handover-driven approve delivery, weigh-skip, 5-min sign reminders
Approve delivery now tracks the handover lifecycle exactly: the button appears (detail page + portal dashboard) the moment a handover is generated and disappears when the customer signs. The bookings list attaches the handoverAwaitingSignature flag via one batched query per page; status heuristics (COMPLETED / TRUCK_ASSIGNED+arrived) are gone. - generate the arrival handover for ANY self-haul truck: portal-assigned OR walk-in registered at the gate (isSelfHaulBooking: assigned_at set, or no EDR last-mile leg). Same rule now guards the exit paper. - remind every 5 minutes (in-app + SMS + email) until the handover is signed (@Cron in HandoverService; one reminder per booking per tick) - Truck Leaving no longer opens blank: the import queue mapper now carries inv.notes, so the saved arrival renders read-only with only gate-out time and gross weight editable - container bookings get "Weigh truck? Yes/No": No skips tare/gross and the container weight match (weighingSkipped on ReleaseOrderDto, decision made at arrival sticks for the exit via the Weighing: SKIPPED note). Bulk always weighs, unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1125,6 +1125,30 @@ export class BookingsService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batched version of the findById flag: marks each page item whose booking
|
||||||
|
* has a generated-but-unsigned SELF_HAUL handover, so list rows (portal
|
||||||
|
* dashboard) can show "Approve delivery" for exactly the generated→signed
|
||||||
|
* window. One query for the whole page.
|
||||||
|
*/
|
||||||
|
private async attachHandoverFlags(bookings: Booking[]): Promise<void> {
|
||||||
|
const ids = bookings.map((b) => b.id);
|
||||||
|
if (!ids.length) return;
|
||||||
|
const rows: Array<{ bookingId: string }> = await this.dataSource.query(
|
||||||
|
`SELECT DISTINCT booking_id AS "bookingId"
|
||||||
|
FROM freight.booking_handovers
|
||||||
|
WHERE booking_id = ANY($1::uuid[])
|
||||||
|
AND signed_at IS NULL AND deleted_at IS NULL
|
||||||
|
AND mile_type = 'SELF_HAUL'`,
|
||||||
|
[ids],
|
||||||
|
);
|
||||||
|
const pending = new Set(rows.map((r) => r.bookingId));
|
||||||
|
for (const b of bookings) {
|
||||||
|
(b as Booking & { handoverAwaitingSignature?: boolean }).handoverAwaitingSignature =
|
||||||
|
pending.has(b.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async findAll(
|
async findAll(
|
||||||
filter: FilterBookingDto,
|
filter: FilterBookingDto,
|
||||||
forceCompanyId?: string,
|
forceCompanyId?: string,
|
||||||
@@ -1135,7 +1159,7 @@ export class BookingsService {
|
|||||||
const statusFilter = this.parseStatusFilter(filter);
|
const statusFilter = this.parseStatusFilter(filter);
|
||||||
const schedulingStatusFilter = this.parseSchedulingStatusFilter(filter);
|
const schedulingStatusFilter = this.parseSchedulingStatusFilter(filter);
|
||||||
|
|
||||||
return this.bookingsRepository.findAllPaginated({
|
const result = await this.bookingsRepository.findAllPaginated({
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
...statusFilter,
|
...statusFilter,
|
||||||
@@ -1162,6 +1186,8 @@ export class BookingsService {
|
|||||||
sortBy: filter.sortBy,
|
sortBy: filter.sortBy,
|
||||||
sortOrder: filter.sortOrder,
|
sortOrder: filter.sortOrder,
|
||||||
});
|
});
|
||||||
|
await this.attachHandoverFlags(result.items ?? []);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Booking statuses at which a customer can pay (mirrors booking-payment.service). */
|
/** Booking statuses at which a customer can pay (mirrors booking-payment.service). */
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsDateString, IsNumber, IsOptional, IsString, Min } from 'class-validator';
|
import { IsBoolean, IsDateString, IsNumber, IsOptional, IsString, Min } from 'class-validator';
|
||||||
|
|
||||||
/** Records a DO / release order being sent to the customer for import pickup. */
|
/** Records a DO / release order being sent to the customer for import pickup. */
|
||||||
export class ReleaseOrderDto {
|
export class ReleaseOrderDto {
|
||||||
@@ -90,4 +90,13 @@ export class ReleaseOrderDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
gateOutTime?: string;
|
gateOutTime?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'Container bookings only: the operator chose not to weigh this truck. ' +
|
||||||
|
'Tare/gross become optional and the container weight match is skipped. Bulk always weighs.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
weighingSkipped?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
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 } from 'typeorm';
|
||||||
|
|
||||||
@@ -150,6 +151,37 @@ export class HandoverService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reminder loop: until a self-haul handover is signed, re-send the sign
|
||||||
|
* notification (in-app + SMS + email) every 5 minutes. One reminder per
|
||||||
|
* booking per tick, newest unsigned handover's reference. Stops the moment
|
||||||
|
* signForBooking() stamps signed_at.
|
||||||
|
*
|
||||||
|
* NB: runs in every API instance — keep a single instance in dev or the
|
||||||
|
* customer is reminded once per instance per tick.
|
||||||
|
*/
|
||||||
|
@Cron(CronExpression.EVERY_5_MINUTES, { name: 'handover-sign-reminder' })
|
||||||
|
async remindUnsignedHandovers(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const rows: Array<{ bookingId: string; reference: string }> = await this.dataSource.query(
|
||||||
|
`SELECT DISTINCT ON (booking_id)
|
||||||
|
booking_id AS "bookingId", reference
|
||||||
|
FROM freight.booking_handovers
|
||||||
|
WHERE signed_at IS NULL
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
AND mile_type = 'SELF_HAUL'
|
||||||
|
ORDER BY booking_id, generated_at DESC`,
|
||||||
|
);
|
||||||
|
if (!rows.length) return;
|
||||||
|
this.logger.log(`Handover sign reminder: ${rows.length} booking(s) still unsigned`);
|
||||||
|
for (const row of rows) {
|
||||||
|
await this.notifySignNeeded(row.bookingId, row.reference);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Handover sign reminder tick failed: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 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(bookingId: string, userId?: string | null): Promise<void> {
|
async signForBooking(bookingId: string, userId?: string | null): Promise<void> {
|
||||||
await this.dataSource
|
await this.dataSource
|
||||||
|
|||||||
@@ -2357,6 +2357,27 @@ export class WarehouseInventoryService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Self-haul = the customer's own truck collects the goods: either a truck
|
||||||
|
* assigned via the portal (customer_truck_assigned_at), or a walk-in truck
|
||||||
|
* registered at the gate on a booking with no EDR last-mile leg. EDR
|
||||||
|
* last-mile bookings are never self-haul.
|
||||||
|
*/
|
||||||
|
private async isSelfHaulBooking(bookingId: string, manager?: EntityManager): Promise<boolean> {
|
||||||
|
const runner = manager ?? this.dataSource;
|
||||||
|
const [row]: Array<{ ok: number }> = await runner.query(
|
||||||
|
`SELECT 1 AS ok
|
||||||
|
FROM freight.bookings b
|
||||||
|
LEFT JOIN freight.service_types st ON st.id = b.service_type_id
|
||||||
|
WHERE b.id = $1 AND b.deleted_at IS NULL
|
||||||
|
AND (b.customer_truck_assigned_at IS NOT NULL
|
||||||
|
OR (NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NULL
|
||||||
|
AND COALESCE(st.includes_last_mile, false) = false))`,
|
||||||
|
[bookingId],
|
||||||
|
);
|
||||||
|
return Boolean(row);
|
||||||
|
}
|
||||||
|
|
||||||
/** Record a DO / release order sent to the customer. Item stays READY_FOR_PICKUP. */
|
/** Record a DO / release order sent to the customer. Item stays READY_FOR_PICKUP. */
|
||||||
async release(id: string, dto: ReleaseOrderDto): Promise<WarehouseInventory> {
|
async release(id: string, dto: ReleaseOrderDto): Promise<WarehouseInventory> {
|
||||||
const item = await this.findById(id);
|
const item = await this.findById(id);
|
||||||
@@ -2366,19 +2387,17 @@ export class WarehouseInventoryService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isTruckLeaving = dto.grossWeight !== undefined && Boolean(dto.gateOutTime);
|
// Leaving = gate-out captured, with either a weighed gross or an explicit
|
||||||
|
// container weighing skip (bulk always weighs).
|
||||||
|
const isTruckLeaving =
|
||||||
|
Boolean(dto.gateOutTime) && (dto.grossWeight !== undefined || dto.weighingSkipped === true);
|
||||||
if (isTruckLeaving) {
|
if (isTruckLeaving) {
|
||||||
await this.invoices.assertClearanceAllowed(id);
|
await this.invoices.assertClearanceAllowed(id);
|
||||||
|
|
||||||
if (item.bookingId) {
|
if (item.bookingId) {
|
||||||
const [truckInfo]: Array<{ customerTruckAssignedAt: string | null }> =
|
// Self-haul = customer collects: a truck assigned via the portal, OR a
|
||||||
await this.dataSource.query(
|
// walk-in truck registered at the gate on a booking with no EDR last mile.
|
||||||
`SELECT customer_truck_assigned_at AS "customerTruckAssignedAt"
|
const usesCustomerTruck = await this.isSelfHaulBooking(item.bookingId);
|
||||||
FROM freight.bookings
|
|
||||||
WHERE id = $1 AND deleted_at IS NULL`,
|
|
||||||
[item.bookingId],
|
|
||||||
);
|
|
||||||
const usesCustomerTruck = Boolean(truckInfo?.customerTruckAssignedAt);
|
|
||||||
// Self-haul: the handover must be signed before the exit paper is issued.
|
// Self-haul: the handover must be signed before the exit paper is issued.
|
||||||
// Prefer the structured handover record; fall back to the legacy note.
|
// Prefer the structured handover record; fall back to the legacy note.
|
||||||
const handoverSigned =
|
const handoverSigned =
|
||||||
@@ -2392,7 +2411,8 @@ export class WarehouseInventoryService {
|
|||||||
|
|
||||||
// Authoritative weight match: the truck's net (gross − tare) must equal the
|
// Authoritative weight match: the truck's net (gross − tare) must equal the
|
||||||
// total VGM cargo weight of the containers selected as loaded on it.
|
// total VGM cargo weight of the containers selected as loaded on it.
|
||||||
if (dto.containerNumber && dto.grossWeight != null && dto.tareWeight != null) {
|
// Skipped when the operator chose not to weigh (containers only).
|
||||||
|
if (!dto.weighingSkipped && dto.containerNumber && dto.grossWeight != null && dto.tareWeight != null) {
|
||||||
const selected = dto.containerNumber
|
const selected = dto.containerNumber
|
||||||
.split(/[,;\n]+/)
|
.split(/[,;\n]+/)
|
||||||
.map((n) => n.trim())
|
.map((n) => n.trim())
|
||||||
@@ -2462,13 +2482,10 @@ export class WarehouseInventoryService {
|
|||||||
[item.bookingId],
|
[item.bookingId],
|
||||||
);
|
);
|
||||||
// Self-haul: generate the per-booking handover on first truck arrival
|
// Self-haul: generate the per-booking handover on first truck arrival
|
||||||
// (idempotent). It must be signed before the truck leaves.
|
// (idempotent) and notify the customer to sign it. Covers BOTH portal-
|
||||||
const [selfHaul]: Array<{ ok: number }> = await manager.query(
|
// assigned trucks and walk-in trucks registered manually at the gate
|
||||||
`SELECT 1 AS ok FROM freight.bookings
|
// (no portal assignment, no EDR last mile). Must be signed before leaving.
|
||||||
WHERE id = $1 AND customer_truck_assigned_at IS NOT NULL AND deleted_at IS NULL`,
|
if (await this.isSelfHaulBooking(item.bookingId, manager)) {
|
||||||
[item.bookingId],
|
|
||||||
);
|
|
||||||
if (selfHaul) {
|
|
||||||
await this.handover.ensureForArrivedTruck(item.bookingId, {}, manager);
|
await this.handover.ensureForArrivedTruck(item.bookingId, {}, manager);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4453,14 +4470,17 @@ export class WarehouseInventoryService {
|
|||||||
if (!dto.driverName?.trim()) {
|
if (!dto.driverName?.trim()) {
|
||||||
throw new BadRequestException('Driver name is required for exit inspection');
|
throw new BadRequestException('Driver name is required for exit inspection');
|
||||||
}
|
}
|
||||||
if (dto.tareWeight === undefined) {
|
// Container bookings may skip the weighbridge entirely (weighingSkipped);
|
||||||
|
// bulk always weighs.
|
||||||
|
const weighingSkipped = dto.weighingSkipped === true;
|
||||||
|
if (dto.tareWeight === undefined && !weighingSkipped) {
|
||||||
throw new BadRequestException('Tare weight is required for truck arrival');
|
throw new BadRequestException('Tare weight is required for truck arrival');
|
||||||
}
|
}
|
||||||
|
|
||||||
const tareWeight = Number(dto.tareWeight);
|
const tareWeight = dto.tareWeight === undefined ? null : Number(dto.tareWeight);
|
||||||
const grossWeight = dto.grossWeight === undefined ? null : Number(dto.grossWeight);
|
const grossWeight = dto.grossWeight === undefined ? null : Number(dto.grossWeight);
|
||||||
const computedNetWeight =
|
const computedNetWeight =
|
||||||
grossWeight == null ? null : Number((grossWeight - tareWeight).toFixed(3));
|
grossWeight == null || tareWeight == null ? null : Number((grossWeight - tareWeight).toFixed(3));
|
||||||
const submittedNetWeight =
|
const submittedNetWeight =
|
||||||
dto.netWeight === undefined || computedNetWeight == null ? computedNetWeight : Number(dto.netWeight);
|
dto.netWeight === undefined || computedNetWeight == null ? computedNetWeight : Number(dto.netWeight);
|
||||||
|
|
||||||
@@ -4472,7 +4492,11 @@ export class WarehouseInventoryService {
|
|||||||
throw new BadRequestException('Weight mismatch: net weight must equal gross weight minus tare weight.');
|
throw new BadRequestException('Weight mismatch: net weight must equal gross weight minus tare weight.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ((dto.grossWeight !== undefined || dto.gateOutTime || dto.netWeight !== undefined) && grossWeight == null) {
|
if (
|
||||||
|
!weighingSkipped &&
|
||||||
|
(dto.grossWeight !== undefined || dto.gateOutTime || dto.netWeight !== undefined) &&
|
||||||
|
grossWeight == null
|
||||||
|
) {
|
||||||
throw new BadRequestException('Gross weight is required for truck exit');
|
throw new BadRequestException('Gross weight is required for truck exit');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4488,7 +4512,8 @@ export class WarehouseInventoryService {
|
|||||||
dto.truckType?.trim() ? `Truck Type: ${dto.truckType.trim()}` : null,
|
dto.truckType?.trim() ? `Truck Type: ${dto.truckType.trim()}` : null,
|
||||||
dto.containerNumber?.trim() ? `Container Number: ${dto.containerNumber.trim()}` : null,
|
dto.containerNumber?.trim() ? `Container Number: ${dto.containerNumber.trim()}` : null,
|
||||||
dto.gateInTime ? `Gate In Time: ${dto.gateInTime}` : null,
|
dto.gateInTime ? `Gate In Time: ${dto.gateInTime}` : null,
|
||||||
`Tare Weight: ${tareWeight} t`,
|
weighingSkipped ? 'Weighing: SKIPPED' : null,
|
||||||
|
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`,
|
computedNetWeight == null ? null : `Net Weight: ${computedNetWeight} t`,
|
||||||
dto.gateOutTime ? `Gate Out Time: ${dto.gateOutTime}` : null,
|
dto.gateOutTime ? `Gate Out Time: ${dto.gateOutTime}` : null,
|
||||||
@@ -4512,6 +4537,8 @@ export class WarehouseInventoryService {
|
|||||||
containerNumber: this.extractExitInspectionLine(inspection, 'Container Number') || dto.containerNumber,
|
containerNumber: this.extractExitInspectionLine(inspection, 'Container Number') || dto.containerNumber,
|
||||||
gateInTime: this.extractExitInspectionLine(inspection, 'Gate In Time') || dto.gateInTime,
|
gateInTime: this.extractExitInspectionLine(inspection, 'Gate In Time') || dto.gateInTime,
|
||||||
tareWeight: this.extractExitInspectionNumber(inspection, 'Tare Weight') ?? dto.tareWeight,
|
tareWeight: this.extractExitInspectionNumber(inspection, 'Tare Weight') ?? dto.tareWeight,
|
||||||
|
// The weigh/skip decision is made at arrival and sticks for the exit.
|
||||||
|
weighingSkipped: dto.weighingSkipped || /^Weighing:\s*SKIPPED/im.test(inspection) || undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2245,6 +2245,9 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
|||||||
handoverDocumentReference: row.handoverDocumentReference,
|
handoverDocumentReference: row.handoverDocumentReference,
|
||||||
handoverDocumentDate: row.handoverDocumentDate,
|
handoverDocumentDate: row.handoverDocumentDate,
|
||||||
deliveredAt: row.deliveredAt,
|
deliveredAt: row.deliveredAt,
|
||||||
|
// Carries the saved [Exit Inspection] block so Truck Leaving opens with the
|
||||||
|
// arrival details (plate, driver, tare, gate-in) read-only instead of blank.
|
||||||
|
notes: row.notes,
|
||||||
booking: row.bookingId
|
booking: row.bookingId
|
||||||
? {
|
? {
|
||||||
id: row.bookingId,
|
id: row.bookingId,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Alert, Button, Group, Modal, MultiSelect, NumberInput, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
|
import { Alert, Button, Group, Modal, MultiSelect, NumberInput, SegmentedControl, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
|
||||||
import { Info, Scale } from 'lucide-react';
|
import { Info, Scale } from 'lucide-react';
|
||||||
|
|
||||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
@@ -105,6 +105,7 @@ const parseInspectionNote = (notes: string | null | undefined) => {
|
|||||||
grossWeight: lineNumber(note, 'Gross Weight'),
|
grossWeight: lineNumber(note, 'Gross Weight'),
|
||||||
netWeight: lineNumber(note, 'Net Weight'),
|
netWeight: lineNumber(note, 'Net Weight'),
|
||||||
gateOutTime: toLocalDateTimeInput(lineValue(note, 'Gate Out Time')),
|
gateOutTime: toLocalDateTimeInput(lineValue(note, 'Gate Out Time')),
|
||||||
|
weighingSkipped: /^Weighing:\s*SKIPPED/im.test(note ?? ''),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -135,6 +136,8 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
const [containerNumbers, setContainerNumbers] = useState<string[]>(['']);
|
const [containerNumbers, setContainerNumbers] = useState<string[]>(['']);
|
||||||
const [gateInTime, setGateInTime] = useState('');
|
const [gateInTime, setGateInTime] = useState('');
|
||||||
const [tareWeight, setTareWeight] = useState<number | ''>('');
|
const [tareWeight, setTareWeight] = useState<number | ''>('');
|
||||||
|
// Containers may skip the weighbridge (decided at arrival, sticks for exit). Bulk always weighs.
|
||||||
|
const [weighTruck, setWeighTruck] = useState<'yes' | 'no'>('yes');
|
||||||
const [grossWeight, setGrossWeight] = useState<number | ''>('');
|
const [grossWeight, setGrossWeight] = useState<number | ''>('');
|
||||||
const [netWeight, setNetWeight] = useState<number | ''>('');
|
const [netWeight, setNetWeight] = useState<number | ''>('');
|
||||||
const [gateOutTime, setGateOutTime] = useState('');
|
const [gateOutTime, setGateOutTime] = useState('');
|
||||||
@@ -158,6 +161,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
setContainerNumbers(initialContainerNumbers(item, inspection.containerNumber || prefillContainerNumber || assignedContainerNumber));
|
setContainerNumbers(initialContainerNumbers(item, inspection.containerNumber || prefillContainerNumber || assignedContainerNumber));
|
||||||
setGateInTime(inspection.gateInTime);
|
setGateInTime(inspection.gateInTime);
|
||||||
setTareWeight(inspection.tareWeight);
|
setTareWeight(inspection.tareWeight);
|
||||||
|
setWeighTruck(inspection.weighingSkipped ? 'no' : 'yes');
|
||||||
setGrossWeight(inspection.grossWeight);
|
setGrossWeight(inspection.grossWeight);
|
||||||
setNetWeight(item?.weight == null ? inspection.netWeight : Number(item.weight));
|
setNetWeight(item?.weight == null ? inspection.netWeight : Number(item.weight));
|
||||||
setGateOutTime(inspection.gateOutTime);
|
setGateOutTime(inspection.gateOutTime);
|
||||||
@@ -165,7 +169,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
}, [opened, item, truckPrefill]);
|
}, [opened, item, truckPrefill]);
|
||||||
|
|
||||||
const savedInspection = parseInspectionNote(item?.notes);
|
const savedInspection = parseInspectionNote(item?.notes);
|
||||||
const isExitStep = savedInspection.tareWeight !== '';
|
const isExitStep = savedInspection.tareWeight !== '' || savedInspection.weighingSkipped;
|
||||||
const isEntranceLocked = isExitStep;
|
const isEntranceLocked = isExitStep;
|
||||||
const isCustomerAssignedTruck = Boolean(item?.booking?.customerTruckAssignedAt);
|
const isCustomerAssignedTruck = Boolean(item?.booking?.customerTruckAssignedAt);
|
||||||
const hasLastMileTruckPrefill = Boolean(truckPrefill?.truckPlateNumber || truckPrefill?.trailerPlateNumber);
|
const hasLastMileTruckPrefill = Boolean(truckPrefill?.truckPlateNumber || truckPrefill?.trailerPlateNumber);
|
||||||
@@ -220,7 +224,9 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
.reduce((sum, n) => sum + (containerWeightByNumber.get(n.toUpperCase()) ?? 0), 0)
|
.reduce((sum, n) => sum + (containerWeightByNumber.get(n.toUpperCase()) ?? 0), 0)
|
||||||
.toFixed(3),
|
.toFixed(3),
|
||||||
);
|
);
|
||||||
const useContainerNet = hasContainerWeights && selectedContainerNumbers.length > 0;
|
// Skip is only offered for container bookings; bulk always weighs.
|
||||||
|
const skipWeighing = hasContainerWeights && weighTruck === 'no';
|
||||||
|
const useContainerNet = hasContainerWeights && selectedContainerNumbers.length > 0 && !skipWeighing;
|
||||||
|
|
||||||
const systemNetWeight = useContainerNet
|
const systemNetWeight = useContainerNet
|
||||||
? selectedCargoWeight
|
? selectedCargoWeight
|
||||||
@@ -230,6 +236,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
const computedNetWeight =
|
const computedNetWeight =
|
||||||
tareWeight !== '' && grossWeight !== '' ? Number((Number(grossWeight) - Number(tareWeight)).toFixed(3)) : null;
|
tareWeight !== '' && grossWeight !== '' ? Number((Number(grossWeight) - Number(tareWeight)).toFixed(3)) : null;
|
||||||
const weightMismatch =
|
const weightMismatch =
|
||||||
|
!skipWeighing &&
|
||||||
computedNetWeight != null && systemNetWeight !== '' && Math.abs(Number(systemNetWeight) - computedNetWeight) > 0.001;
|
computedNetWeight != null && systemNetWeight !== '' && Math.abs(Number(systemNetWeight) - computedNetWeight) > 0.001;
|
||||||
const title = isExitStep ? 'Customer truck leaving and exit weighing' : 'Customer truck arrival weighing';
|
const title = isExitStep ? 'Customer truck leaving and exit weighing' : 'Customer truck arrival weighing';
|
||||||
|
|
||||||
@@ -239,19 +246,25 @@ 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 (!gateInTime || tareWeight === '') {
|
if (!gateInTime || (!skipWeighing && tareWeight === '')) {
|
||||||
toast({ variant: 'destructive', title: 'Gate in time and tare weight are required' });
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: skipWeighing ? 'Gate in time is required' : 'Gate in time and tare weight are required',
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isExitStep && (!gateOutTime || grossWeight === '')) {
|
if (isExitStep && (!gateOutTime || (!skipWeighing && grossWeight === ''))) {
|
||||||
toast({ variant: 'destructive', title: 'Gate out time and gross weight are required' });
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: skipWeighing ? 'Gate out time is required' : 'Gate out time and gross weight are required',
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isExitStep && hasContainerWeights && selectedContainerNumbers.length === 0) {
|
if (isExitStep && hasContainerWeights && selectedContainerNumbers.length === 0) {
|
||||||
toast({ variant: 'destructive', title: 'Select the containers loaded on this truck' });
|
toast({ variant: 'destructive', title: 'Select the containers loaded on this truck' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isExitStep && systemNetWeight === '') {
|
if (isExitStep && !skipWeighing && systemNetWeight === '') {
|
||||||
toast({ variant: 'destructive', title: 'System recorded net weight is missing' });
|
toast({ variant: 'destructive', title: 'System recorded net weight is missing' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -279,9 +292,10 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
truckType: truckType.trim() || undefined,
|
truckType: truckType.trim() || undefined,
|
||||||
containerNumber: containerNumbers.map((number) => number.trim()).filter(Boolean).join(', ') || undefined,
|
containerNumber: containerNumbers.map((number) => number.trim()).filter(Boolean).join(', ') || undefined,
|
||||||
gateInTime: toIsoDateTime(gateInTime),
|
gateInTime: toIsoDateTime(gateInTime),
|
||||||
tareWeight: Number(tareWeight),
|
weighingSkipped: skipWeighing || undefined,
|
||||||
grossWeight: grossWeight === '' ? undefined : Number(grossWeight),
|
tareWeight: skipWeighing ? undefined : Number(tareWeight),
|
||||||
netWeight: isExitStep && systemNetWeight !== '' ? Number(systemNetWeight) : undefined,
|
grossWeight: skipWeighing || grossWeight === '' ? undefined : Number(grossWeight),
|
||||||
|
netWeight: !skipWeighing && isExitStep && systemNetWeight !== '' ? Number(systemNetWeight) : undefined,
|
||||||
gateOutTime: isExitStep ? toIsoDateTime(gateOutTime) : undefined,
|
gateOutTime: isExitStep ? toIsoDateTime(gateOutTime) : undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -421,9 +435,24 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
)}
|
)}
|
||||||
<TextInput label="Gate in time" type="datetime-local" value={gateInTime} onChange={(e) => setGateInTime(e.currentTarget.value)} readOnly={isEntranceLocked} />
|
<TextInput label="Gate in time" type="datetime-local" value={gateInTime} onChange={(e) => setGateInTime(e.currentTarget.value)} readOnly={isEntranceLocked} />
|
||||||
</Group>
|
</Group>
|
||||||
|
{hasContainerWeights && (
|
||||||
|
<Group gap="md" align="center">
|
||||||
|
<Text size="sm" fw={600}>Weigh truck?</Text>
|
||||||
|
<SegmentedControl
|
||||||
|
size="xs"
|
||||||
|
data={[{ value: 'yes', label: 'Yes — weigh' }, { value: 'no', label: 'No — pass' }]}
|
||||||
|
value={weighTruck}
|
||||||
|
onChange={(v) => setWeighTruck((v as 'yes' | 'no') ?? 'yes')}
|
||||||
|
disabled={isEntranceLocked}
|
||||||
|
/>
|
||||||
|
{skipWeighing && (
|
||||||
|
<Text size="xs" c="dimmed">Weighbridge skipped — container passes without tare/gross.</Text>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
<Group grow>
|
<Group grow>
|
||||||
<NumberInput label="Tare weight (t)" required min={0} value={tareWeight} onChange={(v) => setTareWeight(v === '' ? '' : Number(v))} readOnly={isEntranceLocked} />
|
<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} min={0} value={grossWeight} onChange={(v) => setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep} />
|
<NumberInput label="Gross weight (t)" required={isExitStep && !skipWeighing} min={0} value={grossWeight} onChange={(v) => setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep || skipWeighing} />
|
||||||
<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}
|
||||||
|
|||||||
@@ -368,6 +368,8 @@ export interface ReleaseOrderPayload {
|
|||||||
grossWeight?: number;
|
grossWeight?: number;
|
||||||
netWeight?: number;
|
netWeight?: number;
|
||||||
gateOutTime?: string;
|
gateOutTime?: string;
|
||||||
|
/** Container bookings only: operator chose not to weigh — tare/gross omitted, match skipped. */
|
||||||
|
weighingSkipped?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Import branch: proof of delivery captured on customer pickup. */
|
/** Import branch: proof of delivery captured on customer pickup. */
|
||||||
|
|||||||
@@ -36,7 +36,9 @@ export const BookingRow = memo(function BookingRow({
|
|||||||
// Contract ready for signature → "View & sign" jumps straight to the
|
// Contract ready for signature → "View & sign" jumps straight to the
|
||||||
// full-page contract viewer where the signature flow lives.
|
// full-page contract viewer where the signature flow lives.
|
||||||
const canSign = bookingIsSignable(booking);
|
const canSign = bookingIsSignable(booking);
|
||||||
const canApproveDelivery = booking.status === "COMPLETED";
|
// Visible from handover generation until the customer signs (flag is attached
|
||||||
|
// by the bookings list endpoint; false again the moment it's signed).
|
||||||
|
const canApproveDelivery = Boolean(booking.handoverAwaitingSignature);
|
||||||
const origin = booking.originYard?.label ?? booking.originYard?.code ?? "—";
|
const origin = booking.originYard?.label ?? booking.originYard?.code ?? "—";
|
||||||
const dest =
|
const dest =
|
||||||
booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—";
|
booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—";
|
||||||
|
|||||||
@@ -107,10 +107,12 @@ export function ReadonlyBookingView({
|
|||||||
(isGeneralContract
|
(isGeneralContract
|
||||||
? status === "FULLY_EXECUTED"
|
? status === "FULLY_EXECUTED"
|
||||||
: status === "SELECTED_FOR_BATCH");
|
: status === "SELECTED_FOR_BATCH");
|
||||||
const canApproveDelivery =
|
// Approve delivery tracks the handover lifecycle exactly: the button appears
|
||||||
status === "COMPLETED" ||
|
// the moment a handover is generated (truck arrival, or an operator's
|
||||||
Boolean(booking.handoverAwaitingSignature) ||
|
// signature request) and disappears the moment the customer signs it. The
|
||||||
(status === "TRUCK_ASSIGNED" && Boolean(booking.customerTruckArrivedAt));
|
// backend flag counts only unsigned SELF_HAUL handovers, so no status
|
||||||
|
// heuristics are needed here.
|
||||||
|
const canApproveDelivery = Boolean(booking.handoverAwaitingSignature);
|
||||||
const usesCustomerTruck =
|
const usesCustomerTruck =
|
||||||
booking.tradeDirection === "IMPORT"
|
booking.tradeDirection === "IMPORT"
|
||||||
? !booking.lastMileDeliveryAddress
|
? !booking.lastMileDeliveryAddress
|
||||||
|
|||||||
Reference in New Issue
Block a user