|
|
|
|
@@ -1,8 +1,8 @@
|
|
|
|
|
import { useEffect, useState } from 'react';
|
|
|
|
|
import { Alert, Button, Group, Modal, MultiSelect, NumberInput, SegmentedControl, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
|
|
|
|
|
import { Info, Scale } from 'lucide-react';
|
|
|
|
|
import { useEffect, useRef, useState } from 'react';
|
|
|
|
|
import { Alert, Badge, Button, Group, Modal, MultiSelect, NumberInput, SegmentedControl, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
|
|
|
|
|
import { Info, Scale, Truck } from 'lucide-react';
|
|
|
|
|
|
|
|
|
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
|
|
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
|
|
|
|
|
|
|
|
import { api } from '@/services/api';
|
|
|
|
|
import { useToast } from '@/hooks/use-toast';
|
|
|
|
|
@@ -28,6 +28,8 @@ export interface ReleaseOrderTruckPrefill {
|
|
|
|
|
containerNumber?: string | null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const EXIT_INSPECTION_MARKER = '[Exit Inspection]';
|
|
|
|
|
|
|
|
|
|
const toIsoDateTime = (value: string) => {
|
|
|
|
|
if (!value) return undefined;
|
|
|
|
|
const date = new Date(value);
|
|
|
|
|
@@ -70,9 +72,6 @@ const splitContainerNumbers = (value: string | null | undefined) =>
|
|
|
|
|
const getItemContainerNumber = (item: WarehouseInventoryItem | null) =>
|
|
|
|
|
(item as (WarehouseInventoryItem & { containerNumber?: string | null }) | null)?.containerNumber ?? '';
|
|
|
|
|
|
|
|
|
|
const assignedTruckValue = (item: WarehouseInventoryItem | null, key: keyof NonNullable<WarehouseInventoryItem['booking']>) =>
|
|
|
|
|
item?.booking?.[key] == null ? '' : String(item.booking[key]);
|
|
|
|
|
|
|
|
|
|
const isContainerInventory = (item: WarehouseInventoryItem | null, containerCount: number) => {
|
|
|
|
|
const freightType = (item as (WarehouseInventoryItem & { booking?: { freightType?: string | null } | null }) | null)
|
|
|
|
|
?.booking?.freightType;
|
|
|
|
|
@@ -88,29 +87,66 @@ const initialContainerNumbers = (item: WarehouseInventoryItem | null, savedConta
|
|
|
|
|
return Array.from({ length: expectedCount }, (_, index) => sourceNumbers[index] ?? '');
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const parseInspectionNote = (notes: string | null | undefined) => {
|
|
|
|
|
const marker = '[Exit Inspection]';
|
|
|
|
|
const index = notes?.lastIndexOf(marker) ?? -1;
|
|
|
|
|
const note = index >= 0 ? notes?.slice(index + marker.length) : notes;
|
|
|
|
|
return {
|
|
|
|
|
truckPlateNumber: lineValue(note, 'Truck Plate'),
|
|
|
|
|
trailerPlateNumber: lineValue(note, 'Trailer Plate'),
|
|
|
|
|
driverName: lineValue(note, 'Driver'),
|
|
|
|
|
driverLicense: lineValue(note, 'Driver License'),
|
|
|
|
|
driverPhone: lineValue(note, 'Driver Phone'),
|
|
|
|
|
truckType: lineValue(note, 'Truck Type'),
|
|
|
|
|
containerNumber: lineValue(note, 'Container Number'),
|
|
|
|
|
gateInTime: toLocalDateTimeInput(lineValue(note, 'Gate In Time')),
|
|
|
|
|
tareWeight: lineNumber(note, 'Tare Weight'),
|
|
|
|
|
grossWeight: lineNumber(note, 'Gross Weight'),
|
|
|
|
|
netWeight: lineNumber(note, 'Net Weight'),
|
|
|
|
|
gateOutTime: toLocalDateTimeInput(lineValue(note, 'Gate Out Time')),
|
|
|
|
|
weighingSkipped: /^Weighing:\s*SKIPPED/im.test(note ?? ''),
|
|
|
|
|
};
|
|
|
|
|
/** One truck's saved arrival/exit weighing, parsed from its inspection block. */
|
|
|
|
|
interface InspectionBlock {
|
|
|
|
|
truckPlateNumber: string;
|
|
|
|
|
trailerPlateNumber: string;
|
|
|
|
|
driverName: string;
|
|
|
|
|
driverLicense: string;
|
|
|
|
|
driverPhone: string;
|
|
|
|
|
truckType: string;
|
|
|
|
|
containerNumber: string;
|
|
|
|
|
gateInTime: string;
|
|
|
|
|
tareWeight: number | '';
|
|
|
|
|
grossWeight: number | '';
|
|
|
|
|
netWeight: number | '';
|
|
|
|
|
gateOutTime: string;
|
|
|
|
|
weighingSkipped: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const parseInspectionSection = (note: string): InspectionBlock => ({
|
|
|
|
|
truckPlateNumber: lineValue(note, 'Truck Plate'),
|
|
|
|
|
trailerPlateNumber: lineValue(note, 'Trailer Plate'),
|
|
|
|
|
driverName: lineValue(note, 'Driver'),
|
|
|
|
|
driverLicense: lineValue(note, 'Driver License'),
|
|
|
|
|
driverPhone: lineValue(note, 'Driver Phone'),
|
|
|
|
|
truckType: lineValue(note, 'Truck Type'),
|
|
|
|
|
containerNumber: lineValue(note, 'Container Number'),
|
|
|
|
|
gateInTime: toLocalDateTimeInput(lineValue(note, 'Gate In Time')),
|
|
|
|
|
tareWeight: lineNumber(note, 'Tare Weight'),
|
|
|
|
|
grossWeight: lineNumber(note, 'Gross Weight'),
|
|
|
|
|
netWeight: lineNumber(note, 'Net Weight'),
|
|
|
|
|
gateOutTime: toLocalDateTimeInput(lineValue(note, 'Gate Out Time')),
|
|
|
|
|
weighingSkipped: /^Weighing:\s*SKIPPED/im.test(note),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/** Every truck's saved block — multi-truck bookings weigh each truck separately. */
|
|
|
|
|
const parseInspectionBlocks = (notes: string | null | undefined): InspectionBlock[] =>
|
|
|
|
|
(notes ?? '')
|
|
|
|
|
.split(EXIT_INSPECTION_MARKER)
|
|
|
|
|
.slice(1)
|
|
|
|
|
.map(parseInspectionSection)
|
|
|
|
|
.filter((block) => block.truckPlateNumber);
|
|
|
|
|
|
|
|
|
|
/** Match by plate; a legacy block may hold a comma-joined plate list. */
|
|
|
|
|
const blockForPlate = (blocks: InspectionBlock[], plate: string): InspectionBlock | undefined => {
|
|
|
|
|
const key = plate.trim().toUpperCase();
|
|
|
|
|
if (!key) return undefined;
|
|
|
|
|
return blocks.find((block) => {
|
|
|
|
|
const stored = block.truckPlateNumber.toUpperCase();
|
|
|
|
|
return stored === key || stored.split(/[,;]+/).map((p) => p.trim()).includes(key);
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const blockArrived = (block: InspectionBlock | undefined) =>
|
|
|
|
|
Boolean(block && (block.tareWeight !== '' || block.weighingSkipped));
|
|
|
|
|
|
|
|
|
|
const blockLeft = (block: InspectionBlock | undefined) =>
|
|
|
|
|
Boolean(block?.gateOutTime && (block.grossWeight !== '' || block.weighingSkipped));
|
|
|
|
|
|
|
|
|
|
export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: ReleaseOrderModalProps) {
|
|
|
|
|
const { toast } = useToast();
|
|
|
|
|
const queryClient = useQueryClient();
|
|
|
|
|
const releaseMutation = useMutation(api.warehouses.release.mutationOptions());
|
|
|
|
|
// Some openers (inventory workbench) supply bookingId without the booking
|
|
|
|
|
// relation — fall back to it, or the truck/container-weight queries never run.
|
|
|
|
|
@@ -152,78 +188,16 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|
|
|
|
const [netWeight, setNetWeight] = useState<number | ''>('');
|
|
|
|
|
const [gateOutTime, setGateOutTime] = useState('');
|
|
|
|
|
const [downloading, setDownloading] = useState(false);
|
|
|
|
|
// Plate whose saved block was last loaded into the form — stops the
|
|
|
|
|
// per-plate loader effect from clobbering operator edits in a loop.
|
|
|
|
|
const loadedPlateRef = useRef<string | null>(null);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (opened) {
|
|
|
|
|
const inspection = parseInspectionNote(item?.notes);
|
|
|
|
|
const assignedTruckPlate = assignedTruckValue(item, 'customerTruckPlateNumber');
|
|
|
|
|
const assignedDriverName = assignedTruckValue(item, 'customerTruckDriverName');
|
|
|
|
|
const assignedTruckType = assignedTruckValue(item, 'customerTruckType');
|
|
|
|
|
const assignedContainerNumber = assignedTruckValue(item, 'customerTruckContainerNumber');
|
|
|
|
|
const prefillContainerNumber = truckPrefill?.containerNumber ?? '';
|
|
|
|
|
setReference(item?.releaseOrderReference ?? generateReleaseReference(item));
|
|
|
|
|
setTruckPlateNumber(inspection.truckPlateNumber || truckPrefill?.truckPlateNumber || assignedTruckPlate || '');
|
|
|
|
|
setTrailerPlateNumber(inspection.trailerPlateNumber || truckPrefill?.trailerPlateNumber || '');
|
|
|
|
|
setDriverName(inspection.driverName || truckPrefill?.driverName || assignedDriverName || '');
|
|
|
|
|
setDriverLicense(inspection.driverLicense || truckPrefill?.driverLicense || '');
|
|
|
|
|
setDriverPhone(inspection.driverPhone || truckPrefill?.driverPhone || '');
|
|
|
|
|
setTruckType(inspection.truckType || truckPrefill?.truckType || assignedTruckType || '');
|
|
|
|
|
setContainerNumbers(initialContainerNumbers(item, inspection.containerNumber || prefillContainerNumber || assignedContainerNumber));
|
|
|
|
|
setGateInTime(inspection.gateInTime);
|
|
|
|
|
setTareWeight(inspection.tareWeight);
|
|
|
|
|
setWeighTruck(inspection.weighingSkipped ? 'no' : 'yes');
|
|
|
|
|
setGrossWeight(inspection.grossWeight);
|
|
|
|
|
setNetWeight(item?.weight == null ? inspection.netWeight : Number(item.weight));
|
|
|
|
|
setGateOutTime(inspection.gateOutTime);
|
|
|
|
|
}
|
|
|
|
|
}, [opened, item, truckPrefill]);
|
|
|
|
|
|
|
|
|
|
const savedInspection = parseInspectionNote(item?.notes);
|
|
|
|
|
const isExitStep = savedInspection.tareWeight !== '' || savedInspection.weighingSkipped;
|
|
|
|
|
const isEntranceLocked = isExitStep;
|
|
|
|
|
const isCustomerAssignedTruck = Boolean(item?.booking?.customerTruckAssignedAt);
|
|
|
|
|
const hasLastMileTruckPrefill = Boolean(truckPrefill?.truckPlateNumber || truckPrefill?.trailerPlateNumber);
|
|
|
|
|
|
|
|
|
|
// Opened from the warehouse flow (no truckPrefill prop): once the last-mile
|
|
|
|
|
// truck query resolves, auto-fill the first assigned EDR truck — without
|
|
|
|
|
// overwriting anything the operator typed or the locked exit-step values.
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!opened || truckPrefill || isExitStep) return;
|
|
|
|
|
const first = lastMileTrucks[0];
|
|
|
|
|
if (!first) return;
|
|
|
|
|
setTruckPlateNumber((p) => p || first.truckPlateNumber || '');
|
|
|
|
|
setTrailerPlateNumber((p) => p || first.trailerPlateNumber || '');
|
|
|
|
|
setDriverName((p) => p || first.driverName || '');
|
|
|
|
|
setDriverLicense((p) => p || first.driverLicense || '');
|
|
|
|
|
setDriverPhone((p) => p || first.driverPhone || '');
|
|
|
|
|
setTruckType((p) => p || first.truckType || '');
|
|
|
|
|
setContainerNumbers((prev) =>
|
|
|
|
|
prev.length === 1 && !prev[0] && first.containerNumber ? [first.containerNumber] : prev,
|
|
|
|
|
);
|
|
|
|
|
}, [opened, truckPrefill, isExitStep, lastMileTrucks]);
|
|
|
|
|
|
|
|
|
|
// The same for a customer self-haul truck. The prefill above reads the
|
|
|
|
|
// booking.customer_truck_* columns, but multi-truck self-haul writes the plate
|
|
|
|
|
// and driver to customer_truck_assignments and leaves those columns null — so
|
|
|
|
|
// a booking with a truck on file still opened this form blank. Only auto-fills
|
|
|
|
|
// a single truck: with several, the operator picks which one is at the gate.
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!opened || truckPrefill || isExitStep) return;
|
|
|
|
|
if (customerTrucks.length !== 1) return;
|
|
|
|
|
const [truck] = customerTrucks;
|
|
|
|
|
setTruckPlateNumber((p) => p || truck.plateNumber || '');
|
|
|
|
|
setDriverName((p) => p || truck.driverName || '');
|
|
|
|
|
setTruckType((p) => p || truck.truckType || '');
|
|
|
|
|
setContainerNumbers((prev) => {
|
|
|
|
|
const loaded = (truck.containers ?? []).map((c) => c.containerNumber).filter(Boolean);
|
|
|
|
|
return prev.every((n) => !n) && loaded.length ? loaded : prev;
|
|
|
|
|
});
|
|
|
|
|
}, [opened, truckPrefill, isExitStep, customerTrucks]);
|
|
|
|
|
const savedBlocks = parseInspectionBlocks(item?.notes);
|
|
|
|
|
|
|
|
|
|
// Registered trucks for THIS booking, from both sources: EDR last-mile
|
|
|
|
|
// (truckPrefill) and the customer portal (customer_truck_assignments).
|
|
|
|
|
const assignedTruckOptions = [
|
|
|
|
|
...(truckPrefill?.truckPlateNumber
|
|
|
|
|
...(truckPrefill?.truckPlateNumber && !truckPrefill.truckPlateNumber.includes(',')
|
|
|
|
|
? [
|
|
|
|
|
{
|
|
|
|
|
value: truckPrefill.truckPlateNumber,
|
|
|
|
|
@@ -232,6 +206,9 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|
|
|
|
driverName: truckPrefill.driverName ?? '',
|
|
|
|
|
driverPhone: truckPrefill.driverPhone ?? '',
|
|
|
|
|
truckType: truckPrefill.truckType ?? '',
|
|
|
|
|
containerNumbers: splitContainerNumbers(truckPrefill.containerNumber),
|
|
|
|
|
arrived: false,
|
|
|
|
|
left: false,
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
: []),
|
|
|
|
|
@@ -242,6 +219,9 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|
|
|
|
driverName: t.driverName,
|
|
|
|
|
driverPhone: '',
|
|
|
|
|
truckType: t.truckType,
|
|
|
|
|
containerNumbers: (t.containers ?? []).map((c) => c.containerNumber).filter(Boolean),
|
|
|
|
|
arrived: Boolean(t.arrivedAt),
|
|
|
|
|
left: Boolean(t.departedAt),
|
|
|
|
|
})),
|
|
|
|
|
...lastMileTrucks
|
|
|
|
|
.filter((t) => t.truckPlateNumber || t.vehicleId)
|
|
|
|
|
@@ -252,6 +232,9 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|
|
|
|
driverName: t.driverName ?? '',
|
|
|
|
|
driverPhone: t.driverPhone ?? '',
|
|
|
|
|
truckType: t.truckType ?? '',
|
|
|
|
|
containerNumbers: splitContainerNumbers(t.containerNumber),
|
|
|
|
|
arrived: Boolean(t.arrivedAt),
|
|
|
|
|
left: Boolean(t.departedAt),
|
|
|
|
|
})),
|
|
|
|
|
];
|
|
|
|
|
// Only trucks actually assigned to THIS booking (last-mile prefill or customer
|
|
|
|
|
@@ -261,10 +244,132 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|
|
|
|
const truckSelectOptions = [
|
|
|
|
|
...new Map(assignedTruckOptions.map((t) => [t.value, t])).values(),
|
|
|
|
|
];
|
|
|
|
|
const isCustomerAssignedTruck = Boolean(item?.booking?.customerTruckAssignedAt);
|
|
|
|
|
// Neither a last-mile truck nor a customer truck has been assigned yet.
|
|
|
|
|
const noTruckAssigned = assignedTruckOptions.length === 0 && !isCustomerAssignedTruck;
|
|
|
|
|
const isTruckIdentityLocked = isEntranceLocked || isCustomerAssignedTruck || hasLastMileTruckPrefill;
|
|
|
|
|
const isDriverNameLocked = isEntranceLocked || isCustomerAssignedTruck || Boolean(truckPrefill?.driverName);
|
|
|
|
|
|
|
|
|
|
// Per-truck progress: every truck is weighed in and out on its own; the saved
|
|
|
|
|
// blocks also cover walk-in trucks that were never formally assigned.
|
|
|
|
|
const truckProgress = new Map<string, { arrived: boolean; left: boolean }>();
|
|
|
|
|
for (const option of truckSelectOptions) {
|
|
|
|
|
truckProgress.set(option.value.trim().toUpperCase(), { arrived: option.arrived, left: option.left });
|
|
|
|
|
}
|
|
|
|
|
for (const block of savedBlocks) {
|
|
|
|
|
const key = block.truckPlateNumber.trim().toUpperCase();
|
|
|
|
|
const prior = truckProgress.get(key);
|
|
|
|
|
truckProgress.set(key, {
|
|
|
|
|
arrived: Boolean(prior?.arrived) || blockArrived(block),
|
|
|
|
|
left: Boolean(prior?.left) || blockLeft(block),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
const totalTrucks = truckProgress.size;
|
|
|
|
|
const arrivedTrucks = [...truckProgress.values()].filter((t) => t.arrived).length;
|
|
|
|
|
const leftTrucks = [...truckProgress.values()].filter((t) => t.left).length;
|
|
|
|
|
|
|
|
|
|
// The step is decided PER TRUCK: the selected plate's saved block. A new plate
|
|
|
|
|
// (or a truck without a saved arrival) starts at the arrival step even when
|
|
|
|
|
// other trucks of the booking are already mid-flow or gone.
|
|
|
|
|
const selectedBlock = blockForPlate(savedBlocks, truckPlateNumber);
|
|
|
|
|
const isExitStep = blockArrived(selectedBlock);
|
|
|
|
|
const hasTruckLeft = blockLeft(selectedBlock);
|
|
|
|
|
const isEntranceLocked = isExitStep;
|
|
|
|
|
const selectedOption = truckSelectOptions.find(
|
|
|
|
|
(option) => option.value.trim().toUpperCase() === truckPlateNumber.trim().toUpperCase(),
|
|
|
|
|
);
|
|
|
|
|
// Identity comes from the arrival record or the assignment — locked either
|
|
|
|
|
// way. A walk-in truck (typed plate, no assignment) stays editable at arrival.
|
|
|
|
|
const isTruckIdentityLocked = isEntranceLocked || Boolean(selectedOption);
|
|
|
|
|
const isDriverNameLocked = isEntranceLocked || Boolean(selectedOption?.driverName);
|
|
|
|
|
const referenceLocked = Boolean(item?.releaseOrderReference) || savedBlocks.length > 0;
|
|
|
|
|
|
|
|
|
|
/** Load a truck into the form: its saved block if any, else its assignment. */
|
|
|
|
|
const applyTruckSelection = (plate: string) => {
|
|
|
|
|
const block = blockForPlate(savedBlocks, plate);
|
|
|
|
|
const option = truckSelectOptions.find(
|
|
|
|
|
(o) => o.value.trim().toUpperCase() === plate.trim().toUpperCase(),
|
|
|
|
|
);
|
|
|
|
|
loadedPlateRef.current = plate.trim().toUpperCase();
|
|
|
|
|
setTruckPlateNumber(plate);
|
|
|
|
|
setTrailerPlateNumber(block?.trailerPlateNumber || option?.trailerPlate || '');
|
|
|
|
|
setDriverName(block?.driverName || option?.driverName || '');
|
|
|
|
|
setDriverLicense(block?.driverLicense || '');
|
|
|
|
|
setDriverPhone(block?.driverPhone || option?.driverPhone || '');
|
|
|
|
|
setTruckType(block?.truckType || option?.truckType || '');
|
|
|
|
|
const loaded = block
|
|
|
|
|
? splitContainerNumbers(block.containerNumber)
|
|
|
|
|
: (option?.containerNumbers ?? []);
|
|
|
|
|
setContainerNumbers(loaded.length ? loaded : initialContainerNumbers(item, ''));
|
|
|
|
|
setGateInTime(block?.gateInTime ?? '');
|
|
|
|
|
setTareWeight(block?.tareWeight ?? '');
|
|
|
|
|
setWeighTruck(block?.weighingSkipped ? 'no' : 'yes');
|
|
|
|
|
setGrossWeight(block?.grossWeight ?? '');
|
|
|
|
|
setNetWeight(block?.netWeight ?? (item?.weight == null ? '' : Number(item.weight)));
|
|
|
|
|
setGateOutTime(block?.gateOutTime ?? '');
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (opened) {
|
|
|
|
|
loadedPlateRef.current = null;
|
|
|
|
|
setReference(item?.releaseOrderReference ?? generateReleaseReference(item));
|
|
|
|
|
// Initial truck: the caller's prefill, else the first truck still mid-flow
|
|
|
|
|
// (arrived but not left) — the operator can switch trucks in the select.
|
|
|
|
|
const prefillPlate =
|
|
|
|
|
truckPrefill?.truckPlateNumber && !truckPrefill.truckPlateNumber.includes(',')
|
|
|
|
|
? truckPrefill.truckPlateNumber
|
|
|
|
|
: '';
|
|
|
|
|
const blocks = parseInspectionBlocks(item?.notes);
|
|
|
|
|
const inProgress = blocks.find((block) => blockArrived(block) && !blockLeft(block));
|
|
|
|
|
// Legacy single-truck bookings stored the truck on the booking columns; a
|
|
|
|
|
// comma-joined value means several trucks, so the operator picks instead.
|
|
|
|
|
const bookingPlate = item?.booking?.customerTruckPlateNumber ?? '';
|
|
|
|
|
const legacyPlate = bookingPlate && !bookingPlate.includes(',') ? bookingPlate : '';
|
|
|
|
|
const initialPlate = prefillPlate || inProgress?.truckPlateNumber || legacyPlate || '';
|
|
|
|
|
const block = blockForPlate(blocks, initialPlate);
|
|
|
|
|
loadedPlateRef.current = initialPlate ? initialPlate.trim().toUpperCase() : null;
|
|
|
|
|
setTruckPlateNumber(initialPlate);
|
|
|
|
|
setTrailerPlateNumber(block?.trailerPlateNumber || truckPrefill?.trailerPlateNumber || '');
|
|
|
|
|
setDriverName(
|
|
|
|
|
block?.driverName ||
|
|
|
|
|
truckPrefill?.driverName ||
|
|
|
|
|
(legacyPlate && initialPlate === legacyPlate ? (item?.booking?.customerTruckDriverName ?? '') : ''),
|
|
|
|
|
);
|
|
|
|
|
setDriverLicense(block?.driverLicense || truckPrefill?.driverLicense || '');
|
|
|
|
|
setDriverPhone(block?.driverPhone || truckPrefill?.driverPhone || '');
|
|
|
|
|
setTruckType(
|
|
|
|
|
block?.truckType ||
|
|
|
|
|
truckPrefill?.truckType ||
|
|
|
|
|
(legacyPlate && initialPlate === legacyPlate ? (item?.booking?.customerTruckType ?? '') : ''),
|
|
|
|
|
);
|
|
|
|
|
setContainerNumbers(
|
|
|
|
|
initialContainerNumbers(item, block?.containerNumber || truckPrefill?.containerNumber || ''),
|
|
|
|
|
);
|
|
|
|
|
setGateInTime(block?.gateInTime ?? '');
|
|
|
|
|
setTareWeight(block?.tareWeight ?? '');
|
|
|
|
|
setWeighTruck(block?.weighingSkipped ? 'no' : 'yes');
|
|
|
|
|
setGrossWeight(block?.grossWeight ?? '');
|
|
|
|
|
setNetWeight(block?.netWeight ?? (item?.weight == null ? '' : Number(item.weight)));
|
|
|
|
|
setGateOutTime(block?.gateOutTime ?? '');
|
|
|
|
|
}
|
|
|
|
|
}, [opened, item, truckPrefill]);
|
|
|
|
|
|
|
|
|
|
// No truck chosen yet and exactly one is assigned — load it. With several
|
|
|
|
|
// trucks the operator picks which one is at the gate.
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!opened || truckPlateNumber || loadedPlateRef.current) return;
|
|
|
|
|
if (truckSelectOptions.length !== 1) return;
|
|
|
|
|
applyTruckSelection(truckSelectOptions[0].value);
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
}, [opened, truckPlateNumber, customerTrucks, lastMileTrucks]);
|
|
|
|
|
|
|
|
|
|
// A typed plate that matches a saved arrival reloads that truck's record, so
|
|
|
|
|
// the exit step opens with the weigh-in data instead of blank fields.
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!opened) return;
|
|
|
|
|
const key = truckPlateNumber.trim().toUpperCase();
|
|
|
|
|
if (!key || loadedPlateRef.current === key) return;
|
|
|
|
|
if (blockForPlate(savedBlocks, truckPlateNumber)) applyTruckSelection(truckPlateNumber);
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
}, [opened, truckPlateNumber]);
|
|
|
|
|
|
|
|
|
|
// Which containers ride this truck, and their combined cargo weight. When the
|
|
|
|
|
// booking has container weights, that sum is the authoritative net; the
|
|
|
|
|
@@ -294,7 +399,9 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|
|
|
|
);
|
|
|
|
|
// Skip is only offered for container bookings; bulk always weighs.
|
|
|
|
|
const skipWeighing = hasContainerWeights && weighTruck === 'no';
|
|
|
|
|
const useContainerNet = hasContainerWeights && selectedContainerNumbers.length > 0 && !skipWeighing;
|
|
|
|
|
// Even an unweighed truck records the cargo weight it is holding — the
|
|
|
|
|
// selected containers' sum is the net that goes on the exit record.
|
|
|
|
|
const useContainerNet = hasContainerWeights && selectedContainerNumbers.length > 0;
|
|
|
|
|
|
|
|
|
|
const systemNetWeight = useContainerNet
|
|
|
|
|
? selectedCargoWeight
|
|
|
|
|
@@ -314,6 +421,10 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|
|
|
|
toast({ variant: 'destructive', title: 'Truck plate and driver name are required' });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (hasTruckLeft) {
|
|
|
|
|
toast({ variant: 'destructive', title: `Truck ${truckPlateNumber} has already left — its exit record is locked` });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (!gateInTime || (!skipWeighing && tareWeight === '')) {
|
|
|
|
|
toast({
|
|
|
|
|
variant: 'destructive',
|
|
|
|
|
@@ -363,13 +474,17 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|
|
|
|
weighingSkipped: skipWeighing || undefined,
|
|
|
|
|
tareWeight: skipWeighing ? undefined : Number(tareWeight),
|
|
|
|
|
grossWeight: skipWeighing || grossWeight === '' ? undefined : Number(grossWeight),
|
|
|
|
|
netWeight: !skipWeighing && isExitStep && systemNetWeight !== '' ? Number(systemNetWeight) : undefined,
|
|
|
|
|
// Skipped weighing still records the net from what the truck holds.
|
|
|
|
|
netWeight: isExitStep && systemNetWeight !== '' ? Number(systemNetWeight) : undefined,
|
|
|
|
|
gateOutTime: isExitStep ? toIsoDateTime(gateOutTime) : undefined,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
await queryClient.invalidateQueries({ queryKey: ['release-customer-trucks', bookingId] });
|
|
|
|
|
await queryClient.invalidateQueries({ queryKey: ['release-last-mile-trucks', bookingId] });
|
|
|
|
|
if (!isExitStep) {
|
|
|
|
|
const remaining = totalTrucks > 1 ? ` (${Math.min(arrivedTrucks + 1, totalTrucks)} of ${totalTrucks} trucks arrived)` : '';
|
|
|
|
|
toast({
|
|
|
|
|
title: 'Truck arrival saved',
|
|
|
|
|
title: `Truck ${truckPlateNumber.trim()} arrival saved${remaining}`,
|
|
|
|
|
description: `${released.releaseOrderReference ?? reference} is ready for exit weighing.`,
|
|
|
|
|
});
|
|
|
|
|
onClose();
|
|
|
|
|
@@ -380,11 +495,12 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|
|
|
|
const blob = response.data;
|
|
|
|
|
const filename = `release-${released.booking?.reference ?? released.bookingId ?? item.id}.pdf`;
|
|
|
|
|
const opened = openPdfBlob(blob, filename, pdfWindow);
|
|
|
|
|
const remainingExit = totalTrucks > 1 ? ` ${Math.min(leftTrucks + 1, totalTrucks)} of ${totalTrucks} trucks have left.` : '';
|
|
|
|
|
toast({
|
|
|
|
|
title: 'Release exit paper issued',
|
|
|
|
|
description: opened
|
|
|
|
|
description: (opened
|
|
|
|
|
? 'The PDF opened in a browser tab for printing or saving.'
|
|
|
|
|
: 'The browser blocked the preview tab, so the PDF was downloaded.',
|
|
|
|
|
: 'The browser blocked the preview tab, so the PDF was downloaded.') + remainingExit,
|
|
|
|
|
});
|
|
|
|
|
onClose();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
@@ -411,12 +527,35 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|
|
|
|
</Text>
|
|
|
|
|
)}
|
|
|
|
|
</Alert>
|
|
|
|
|
{totalTrucks > 1 && (
|
|
|
|
|
<Alert icon={<Truck size={16} />} color="blue" variant="light">
|
|
|
|
|
<Group gap="xs">
|
|
|
|
|
<Text size="sm">
|
|
|
|
|
{totalTrucks} trucks on this booking — each is weighed in and out separately.
|
|
|
|
|
</Text>
|
|
|
|
|
<Badge size="sm" variant="light" color={arrivedTrucks === totalTrucks ? 'green' : 'blue'}>
|
|
|
|
|
{arrivedTrucks}/{totalTrucks} arrived
|
|
|
|
|
</Badge>
|
|
|
|
|
<Badge size="sm" variant="light" color={leftTrucks === totalTrucks ? 'green' : 'gray'}>
|
|
|
|
|
{leftTrucks}/{totalTrucks} left
|
|
|
|
|
</Badge>
|
|
|
|
|
</Group>
|
|
|
|
|
</Alert>
|
|
|
|
|
)}
|
|
|
|
|
{hasTruckLeft && (
|
|
|
|
|
<Alert icon={<Info size={16} />} color="green" variant="light">
|
|
|
|
|
<Text size="sm">
|
|
|
|
|
Truck {truckPlateNumber} has already left — its exit record is locked. Pick another
|
|
|
|
|
truck to continue the remaining arrivals and exits.
|
|
|
|
|
</Text>
|
|
|
|
|
</Alert>
|
|
|
|
|
)}
|
|
|
|
|
<TextInput
|
|
|
|
|
label="Release document reference"
|
|
|
|
|
placeholder="e.g. REL-2026-001"
|
|
|
|
|
value={reference}
|
|
|
|
|
onChange={(e) => setReference(e.currentTarget.value)}
|
|
|
|
|
readOnly={isEntranceLocked}
|
|
|
|
|
readOnly={referenceLocked}
|
|
|
|
|
/>
|
|
|
|
|
{noTruckAssigned && (
|
|
|
|
|
<Alert color="orange" variant="light" icon={<Info size={16} />}>
|
|
|
|
|
@@ -425,22 +564,19 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|
|
|
|
)}
|
|
|
|
|
{truckSelectOptions.length > 0 && (
|
|
|
|
|
<Select
|
|
|
|
|
label="Assigned first / last-mile truck"
|
|
|
|
|
label="Truck at the gate"
|
|
|
|
|
description="Pick which assigned truck is being processed — switching trucks loads that truck's own arrival/exit record."
|
|
|
|
|
placeholder="Select the assigned truck"
|
|
|
|
|
searchable
|
|
|
|
|
clearable
|
|
|
|
|
// Enabled at arrival so the operator picks which assigned truck came;
|
|
|
|
|
// only locked on the exit (leaving) step once identity is captured.
|
|
|
|
|
disabled={isEntranceLocked}
|
|
|
|
|
data={truckSelectOptions}
|
|
|
|
|
disabled={releaseMutation.isPending || downloading}
|
|
|
|
|
data={truckSelectOptions.map(({ value, label, arrived, left }) => ({
|
|
|
|
|
value,
|
|
|
|
|
label: `${label}${left ? ' · LEFT' : arrived ? ' · ON SITE' : ''}`,
|
|
|
|
|
}))}
|
|
|
|
|
value={truckSelectOptions.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null}
|
|
|
|
|
onChange={(value) => {
|
|
|
|
|
const truck = truckSelectOptions.find((row) => row.value === value);
|
|
|
|
|
setTruckPlateNumber(truck?.value ?? '');
|
|
|
|
|
setTrailerPlateNumber(truck?.trailerPlate ?? '');
|
|
|
|
|
if (truck?.driverName) setDriverName(truck.driverName);
|
|
|
|
|
if (truck?.driverPhone) setDriverPhone(truck.driverPhone);
|
|
|
|
|
if (truck?.truckType) setTruckType(truck.truckType);
|
|
|
|
|
if (value) applyTruckSelection(value);
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
@@ -481,6 +617,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|
|
|
|
data={containerSelectData}
|
|
|
|
|
value={selectedContainerNumbers}
|
|
|
|
|
onChange={(values) => setContainerNumbers(values.length ? values : [''])}
|
|
|
|
|
disabled={hasTruckLeft}
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<Stack gap={6}>
|
|
|
|
|
@@ -514,13 +651,15 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|
|
|
|
disabled={isEntranceLocked}
|
|
|
|
|
/>
|
|
|
|
|
{skipWeighing && (
|
|
|
|
|
<Text size="xs" c="dimmed">Weighbridge skipped — container passes without tare/gross.</Text>
|
|
|
|
|
<Text size="xs" c="dimmed">
|
|
|
|
|
Weighbridge skipped — the selected containers' cargo weight is recorded as the net.
|
|
|
|
|
</Text>
|
|
|
|
|
)}
|
|
|
|
|
</Group>
|
|
|
|
|
)}
|
|
|
|
|
<Group grow>
|
|
|
|
|
<NumberInput label="Tare weight (t)" required={!skipWeighing} min={0} value={tareWeight} onChange={(v) => setTareWeight(v === '' ? '' : Number(v))} readOnly={isEntranceLocked} disabled={skipWeighing} />
|
|
|
|
|
<NumberInput label="Gross weight (t)" required={isExitStep && !skipWeighing} min={0} value={grossWeight} onChange={(v) => setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep || skipWeighing} />
|
|
|
|
|
<NumberInput label="Gross weight (t)" required={isExitStep && !skipWeighing} min={0} value={grossWeight} onChange={(v) => setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep || skipWeighing || hasTruckLeft} />
|
|
|
|
|
<NumberInput
|
|
|
|
|
label={useContainerNet ? 'Selected cargo net (t)' : 'Recorded net weight (system t)'}
|
|
|
|
|
min={0}
|
|
|
|
|
@@ -532,7 +671,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|
|
|
|
<Text size="sm" c={weightMismatch ? 'red' : 'dimmed'}>
|
|
|
|
|
Computed net: <b>{computedNetWeight == null ? '-' : `${computedNetWeight.toLocaleString()} t`}</b>
|
|
|
|
|
</Text>
|
|
|
|
|
<TextInput label="Gate out time" type="datetime-local" value={gateOutTime} onChange={(e) => setGateOutTime(e.currentTarget.value)} disabled={!isExitStep} />
|
|
|
|
|
<TextInput label="Gate out time" type="datetime-local" value={gateOutTime} onChange={(e) => setGateOutTime(e.currentTarget.value)} disabled={!isExitStep || hasTruckLeft} />
|
|
|
|
|
</Group>
|
|
|
|
|
{weightMismatch && (
|
|
|
|
|
<Alert icon={<Scale size={16} />} color="red" variant="light">
|
|
|
|
|
@@ -546,7 +685,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|
|
|
|
<Button variant="default" onClick={onClose} disabled={releaseMutation.isPending || downloading}>
|
|
|
|
|
Cancel
|
|
|
|
|
</Button>
|
|
|
|
|
<Button color="orange" onClick={handleSubmit} loading={releaseMutation.isPending || downloading}>
|
|
|
|
|
<Button color="orange" onClick={handleSubmit} loading={releaseMutation.isPending || downloading} disabled={hasTruckLeft}>
|
|
|
|
|
{isExitStep ? 'Save Truck Leaving & View Exit Paper' : 'Save Truck Arrival'}
|
|
|
|
|
</Button>
|
|
|
|
|
</Group>
|
|
|
|
|
|