mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 21:50:57 +00:00
- single-row Assign vehicle uses the full single-record flow (details + containers) - release() rejects exit containers not assigned to the departing truck - weighing modal offers only the selected truck's assigned containers
704 lines
33 KiB
TypeScript
704 lines
33 KiB
TypeScript
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, useQueryClient } from '@tanstack/react-query';
|
||
|
||
import { api } from '@/services/api';
|
||
import { useToast } from '@/hooks/use-toast';
|
||
import { warehouseService } from '@/services/warehouse.service';
|
||
import type { WarehouseInventoryItem } from '@/types/warehouse';
|
||
import { extractErrorMessage } from './options';
|
||
import { openPdfBlob } from './pdf';
|
||
|
||
interface ReleaseOrderModalProps {
|
||
opened: boolean;
|
||
onClose: () => void;
|
||
item: WarehouseInventoryItem | null;
|
||
truckPrefill?: ReleaseOrderTruckPrefill | null;
|
||
}
|
||
|
||
export interface ReleaseOrderTruckPrefill {
|
||
truckPlateNumber?: string | null;
|
||
trailerPlateNumber?: string | null;
|
||
driverName?: string | null;
|
||
driverLicense?: string | null;
|
||
driverPhone?: string | null;
|
||
truckType?: string | null;
|
||
containerNumber?: string | null;
|
||
}
|
||
|
||
const EXIT_INSPECTION_MARKER = '[Exit Inspection]';
|
||
|
||
const toIsoDateTime = (value: string) => {
|
||
if (!value) return undefined;
|
||
const date = new Date(value);
|
||
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
|
||
};
|
||
|
||
const toLocalDateTimeInput = (value?: string | null) => {
|
||
if (!value) return '';
|
||
const date = new Date(value);
|
||
if (Number.isNaN(date.getTime())) return '';
|
||
const offsetMs = date.getTimezoneOffset() * 60_000;
|
||
return new Date(date.getTime() - offsetMs).toISOString().slice(0, 16);
|
||
};
|
||
|
||
const generateReleaseReference = (item: WarehouseInventoryItem | null) => {
|
||
const bookingReference = item?.booking?.reference;
|
||
if (bookingReference) return `REL-${bookingReference.replace(/^BK-?/i, '')}`;
|
||
if (item?.bookingId) return `REL-${item.bookingId.replace(/-/g, '').slice(0, 8).toUpperCase()}`;
|
||
return '';
|
||
};
|
||
|
||
const lineValue = (notes: string | null | undefined, label: string) => {
|
||
const match = notes?.match(new RegExp(`^${label}:\\s*(.+)$`, 'im'));
|
||
return match?.[1]?.trim() ?? '';
|
||
};
|
||
|
||
const lineNumber = (notes: string | null | undefined, label: string): number | '' => {
|
||
const value = lineValue(notes, label).replace(/\s*(kg|t)$/i, '');
|
||
if (!value) return '';
|
||
const parsed = Number(value);
|
||
return Number.isFinite(parsed) ? parsed : '';
|
||
};
|
||
|
||
const splitContainerNumbers = (value: string | null | undefined) =>
|
||
(value ?? '')
|
||
.split(/[,;\n]+/)
|
||
.map((number) => number.trim())
|
||
.filter(Boolean);
|
||
|
||
const getItemContainerNumber = (item: WarehouseInventoryItem | null) =>
|
||
(item as (WarehouseInventoryItem & { containerNumber?: string | null }) | null)?.containerNumber ?? '';
|
||
|
||
const isContainerInventory = (item: WarehouseInventoryItem | null, containerCount: number) => {
|
||
const freightType = (item as (WarehouseInventoryItem & { booking?: { freightType?: string | null } | null }) | null)
|
||
?.booking?.freightType;
|
||
return Boolean(item?.containerId || containerCount > 0 || freightType === 'CONTAINER');
|
||
};
|
||
|
||
const initialContainerNumbers = (item: WarehouseInventoryItem | null, savedContainerNumber: string) => {
|
||
const savedNumbers = splitContainerNumbers(savedContainerNumber);
|
||
const itemNumbers = splitContainerNumbers(getItemContainerNumber(item));
|
||
const sourceNumbers = savedNumbers.length ? savedNumbers : itemNumbers;
|
||
const quantityCount = isContainerInventory(item, sourceNumbers.length) ? Number(item?.quantity ?? 0) : 0;
|
||
const expectedCount = Math.max(1, sourceNumbers.length, quantityCount);
|
||
return Array.from({ length: expectedCount }, (_, index) => sourceNumbers[index] ?? '');
|
||
};
|
||
|
||
/** 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.
|
||
const bookingId = item?.booking?.id ?? item?.bookingId ?? undefined;
|
||
// Customer self-haul trucks assigned to this booking via the portal.
|
||
const { data: customerTrucks = [] } = useQuery({
|
||
queryKey: ['release-customer-trucks', bookingId],
|
||
queryFn: () => warehouseService.getCustomerTrucks(bookingId as string),
|
||
enabled: opened && Boolean(bookingId),
|
||
});
|
||
// EDR last-mile trucks assigned to this booking — surfaced even when the modal
|
||
// is opened from the warehouse flow (which passes no truckPrefill prop), so an
|
||
// assigned EDR truck no longer shows as "not assigned yet".
|
||
const { data: lastMileTrucks = [] } = useQuery({
|
||
queryKey: ['release-last-mile-trucks', bookingId],
|
||
queryFn: () => warehouseService.getLastMileTrucks(bookingId as string),
|
||
enabled: opened && Boolean(bookingId),
|
||
});
|
||
// Per-container cargo weights — the truck's net (gross − tare) must equal the
|
||
// total cargo weight of the containers selected as loaded on it.
|
||
const { data: containerWeights = [] } = useQuery({
|
||
queryKey: ['release-container-weights', bookingId],
|
||
queryFn: () => warehouseService.getContainerWeights(bookingId as string),
|
||
enabled: opened && Boolean(bookingId),
|
||
});
|
||
const [reference, setReference] = useState('');
|
||
const [truckPlateNumber, setTruckPlateNumber] = useState('');
|
||
const [trailerPlateNumber, setTrailerPlateNumber] = useState('');
|
||
const [driverName, setDriverName] = useState('');
|
||
const [driverLicense, setDriverLicense] = useState('');
|
||
const [driverPhone, setDriverPhone] = useState('');
|
||
const [truckType, setTruckType] = useState('');
|
||
const [containerNumbers, setContainerNumbers] = useState<string[]>(['']);
|
||
const [gateInTime, setGateInTime] = useState('');
|
||
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 [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);
|
||
|
||
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.includes(',')
|
||
? [
|
||
{
|
||
value: truckPrefill.truckPlateNumber,
|
||
label: `Last-mile · ${truckPrefill.truckPlateNumber}`,
|
||
trailerPlate: truckPrefill.trailerPlateNumber ?? '',
|
||
driverName: truckPrefill.driverName ?? '',
|
||
driverPhone: truckPrefill.driverPhone ?? '',
|
||
truckType: truckPrefill.truckType ?? '',
|
||
containerNumbers: splitContainerNumbers(truckPrefill.containerNumber),
|
||
arrived: false,
|
||
left: false,
|
||
},
|
||
]
|
||
: []),
|
||
...customerTrucks.map((t) => ({
|
||
value: t.plateNumber,
|
||
label: `Customer · ${t.plateNumber} — ${t.driverName}`,
|
||
trailerPlate: '',
|
||
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)
|
||
.map((t) => ({
|
||
value: (t.truckPlateNumber || t.vehicleId) as string,
|
||
label: `Last-mile · ${t.truckPlateNumber ?? ''}${t.driverName ? ` — ${t.driverName}` : ''}`,
|
||
trailerPlate: t.trailerPlateNumber ?? '',
|
||
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
|
||
// portal) are selectable. No global fleet list — if nothing is assigned, the
|
||
// operator types the plate manually in the field below. Deduped by plate:
|
||
// duplicate option values crash Mantine's Select.
|
||
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;
|
||
|
||
// 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
|
||
// operator selects the containers loaded on the truck at exit.
|
||
const hasContainerWeights = containerWeights.length > 0;
|
||
const containerWeightByNumber = new Map(
|
||
containerWeights.map((c) => [c.containerNumber.toUpperCase(), Number(c.weightTons) || 0]),
|
||
);
|
||
// A truck may only carry out its OWN assigned containers — when the selected
|
||
// truck has an assigned load, other trucks' containers are not offered.
|
||
const assignedLoad = (selectedOption?.containerNumbers ?? []).map((n) => n.toUpperCase());
|
||
// Mantine Selects throw on duplicate option values — legacy bookings can carry
|
||
// the same container number on two lines, so dedupe defensively.
|
||
const containerSelectData = [
|
||
...new Map(
|
||
containerWeights.map((c) => [
|
||
c.containerNumber,
|
||
{
|
||
value: c.containerNumber,
|
||
label: `${c.containerNumber} · ${(Number(c.weightTons) || 0).toLocaleString()} t`,
|
||
},
|
||
]),
|
||
).values(),
|
||
].filter(
|
||
(option) =>
|
||
assignedLoad.length === 0 ||
|
||
assignedLoad.includes(option.value.toUpperCase()) ||
|
||
containerNumbers.some((n) => n.trim().toUpperCase() === option.value.toUpperCase()),
|
||
);
|
||
const selectedContainerNumbers = containerNumbers.map((n) => n.trim()).filter(Boolean);
|
||
const selectedCargoWeight = Number(
|
||
selectedContainerNumbers
|
||
.reduce((sum, n) => sum + (containerWeightByNumber.get(n.toUpperCase()) ?? 0), 0)
|
||
.toFixed(3),
|
||
);
|
||
// Skip is only offered for container bookings; bulk always weighs.
|
||
const skipWeighing = hasContainerWeights && weighTruck === 'no';
|
||
// 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
|
||
: item?.weight == null
|
||
? netWeight
|
||
: Number(item.weight);
|
||
const computedNetWeight =
|
||
tareWeight !== '' && grossWeight !== '' ? Number((Number(grossWeight) - Number(tareWeight)).toFixed(3)) : null;
|
||
const weightMismatch =
|
||
!skipWeighing &&
|
||
computedNetWeight != null && systemNetWeight !== '' && Math.abs(Number(systemNetWeight) - computedNetWeight) > 0.001;
|
||
const title = isExitStep ? 'Customer truck leaving and exit weighing' : 'Customer truck arrival weighing';
|
||
|
||
const handleSubmit = async () => {
|
||
if (!item) return;
|
||
if (!truckPlateNumber.trim() || !driverName.trim()) {
|
||
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',
|
||
title: skipWeighing ? 'Gate in time is required' : 'Gate in time and tare weight are required',
|
||
});
|
||
return;
|
||
}
|
||
if (isExitStep && (!gateOutTime || (!skipWeighing && grossWeight === ''))) {
|
||
toast({
|
||
variant: 'destructive',
|
||
title: skipWeighing ? 'Gate out time is required' : 'Gate out time and gross weight are required',
|
||
});
|
||
return;
|
||
}
|
||
if (isExitStep && hasContainerWeights && selectedContainerNumbers.length === 0) {
|
||
toast({ variant: 'destructive', title: 'Select the containers loaded on this truck' });
|
||
return;
|
||
}
|
||
if (isExitStep && !skipWeighing && systemNetWeight === '') {
|
||
toast({ variant: 'destructive', title: 'System recorded net weight is missing' });
|
||
return;
|
||
}
|
||
if (isExitStep && weightMismatch) {
|
||
toast({
|
||
variant: 'destructive',
|
||
title: 'Weight mismatch',
|
||
description: 'Gate clearance is blocked. Reassign the item to warehouse if it cannot exit.',
|
||
});
|
||
return;
|
||
}
|
||
const pdfWindow = isExitStep ? window.open('', '_blank') : null;
|
||
try {
|
||
const released = await releaseMutation.mutateAsync({
|
||
id: item.id,
|
||
payload: {
|
||
reference: reference.trim() || undefined,
|
||
bookingId: item.bookingId ?? undefined,
|
||
customerId: undefined,
|
||
truckPlateNumber: truckPlateNumber.trim(),
|
||
trailerPlateNumber: trailerPlateNumber.trim() || undefined,
|
||
driverName: driverName.trim(),
|
||
driverLicense: driverLicense.trim() || undefined,
|
||
driverPhone: driverPhone.trim() || undefined,
|
||
truckType: truckType.trim() || undefined,
|
||
containerNumber: containerNumbers.map((number) => number.trim()).filter(Boolean).join(', ') || undefined,
|
||
gateInTime: toIsoDateTime(gateInTime),
|
||
weighingSkipped: skipWeighing || undefined,
|
||
tareWeight: skipWeighing ? undefined : Number(tareWeight),
|
||
grossWeight: skipWeighing || grossWeight === '' ? undefined : Number(grossWeight),
|
||
// 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 ${truckPlateNumber.trim()} arrival saved${remaining}`,
|
||
description: `${released.releaseOrderReference ?? reference} is ready for exit weighing.`,
|
||
});
|
||
onClose();
|
||
return;
|
||
}
|
||
setDownloading(true);
|
||
const response = await warehouseService.downloadReleaseDocument(item.id);
|
||
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
|
||
? 'The PDF opened in a browser tab for printing or saving.'
|
||
: 'The browser blocked the preview tab, so the PDF was downloaded.') + remainingExit,
|
||
});
|
||
onClose();
|
||
} catch (error) {
|
||
pdfWindow?.close();
|
||
toast({ variant: 'destructive', title: 'Release failed', description: extractErrorMessage(error) });
|
||
} finally {
|
||
setDownloading(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<Modal opened={opened} onClose={onClose} title={title} centered size="lg">
|
||
<Stack gap="md">
|
||
<Alert icon={<Info size={16} />} color="orange" variant="light">
|
||
{isExitStep ? (
|
||
<Text size="sm">
|
||
Record the truck leaving time and gross weight. The system recorded net weight is locked,
|
||
and the exit paper is generated only when it equals gross weight minus tare weight.
|
||
</Text>
|
||
) : (
|
||
<Text size="sm">
|
||
Register the customer truck and driver at arrival, then save gate in time and tare weight.
|
||
Reopen this form when the truck is leaving to complete the exit weighing.
|
||
</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={referenceLocked}
|
||
/>
|
||
{noTruckAssigned && (
|
||
<Alert color="orange" variant="light" icon={<Info size={16} />}>
|
||
Truck is not assigned yet — assign a last-mile or customer truck, or enter the plate manually below.
|
||
</Alert>
|
||
)}
|
||
{truckSelectOptions.length > 0 && (
|
||
<Select
|
||
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
|
||
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) => {
|
||
if (value) applyTruckSelection(value);
|
||
}}
|
||
/>
|
||
)}
|
||
<Group grow>
|
||
<TextInput
|
||
label="Truck plate number"
|
||
required
|
||
value={truckPlateNumber}
|
||
onChange={(e) => setTruckPlateNumber(e.currentTarget.value)}
|
||
readOnly={isTruckIdentityLocked}
|
||
/>
|
||
<TextInput
|
||
label="Trailer plate number"
|
||
value={trailerPlateNumber}
|
||
onChange={(e) => setTrailerPlateNumber(e.currentTarget.value)}
|
||
readOnly={isEntranceLocked}
|
||
/>
|
||
</Group>
|
||
<Group grow>
|
||
<TextInput label="Driver name" required value={driverName} onChange={(e) => setDriverName(e.currentTarget.value)} readOnly={isDriverNameLocked} />
|
||
<TextInput label="Driver license" value={driverLicense} onChange={(e) => setDriverLicense(e.currentTarget.value)} readOnly={isEntranceLocked} />
|
||
</Group>
|
||
<Group grow>
|
||
<TextInput label="Driver phone" value={driverPhone} onChange={(e) => setDriverPhone(e.currentTarget.value)} readOnly={isEntranceLocked} />
|
||
<TextInput label="Truck type" value={truckType} onChange={(e) => setTruckType(e.currentTarget.value)} readOnly={isTruckIdentityLocked} />
|
||
</Group>
|
||
<Group grow align="flex-start">
|
||
{hasContainerWeights ? (
|
||
<MultiSelect
|
||
label="Containers on this truck"
|
||
description={
|
||
isExitStep
|
||
? 'Select the containers loaded on this truck — their cargo weight must match gross − tare.'
|
||
: 'Containers this truck will carry.'
|
||
}
|
||
placeholder="Select containers"
|
||
searchable
|
||
data={containerSelectData}
|
||
value={selectedContainerNumbers}
|
||
onChange={(values) => setContainerNumbers(values.length ? values : [''])}
|
||
disabled={hasTruckLeft}
|
||
/>
|
||
) : (
|
||
<Stack gap={6}>
|
||
<SimpleGrid cols={containerNumbers.length > 1 ? 2 : 1} spacing="sm">
|
||
{containerNumbers.map((containerNumber, index) => (
|
||
<TextInput
|
||
key={index}
|
||
label={containerNumbers.length > 1 ? `Container number ${index + 1}` : 'Container number'}
|
||
value={containerNumber}
|
||
onChange={(e) =>
|
||
setContainerNumbers((numbers) =>
|
||
numbers.map((number, numberIndex) => (numberIndex === index ? e.currentTarget.value : number)),
|
||
)
|
||
}
|
||
readOnly={isTruckIdentityLocked}
|
||
/>
|
||
))}
|
||
</SimpleGrid>
|
||
</Stack>
|
||
)}
|
||
<TextInput label="Gate in time" type="datetime-local" value={gateInTime} onChange={(e) => setGateInTime(e.currentTarget.value)} readOnly={isEntranceLocked} />
|
||
</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 — 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 || hasTruckLeft} />
|
||
<NumberInput
|
||
label={useContainerNet ? 'Selected cargo net (t)' : 'Recorded net weight (system t)'}
|
||
min={0}
|
||
value={systemNetWeight}
|
||
readOnly
|
||
/>
|
||
</Group>
|
||
<Group justify="space-between">
|
||
<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 || hasTruckLeft} />
|
||
</Group>
|
||
{weightMismatch && (
|
||
<Alert icon={<Scale size={16} />} color="red" variant="light">
|
||
<Text size="sm">
|
||
Weight mismatch detected. Exit paper and gate clearance are blocked; use Store or Move to
|
||
reassign the item back to warehouse handling.
|
||
</Text>
|
||
</Alert>
|
||
)}
|
||
<Group justify="flex-end" mt="sm">
|
||
<Button variant="default" onClick={onClose} disabled={releaseMutation.isPending || downloading}>
|
||
Cancel
|
||
</Button>
|
||
<Button color="orange" onClick={handleSubmit} loading={releaseMutation.isPending || downloading} disabled={hasTruckLeft}>
|
||
{isExitStep ? 'Save Truck Leaving & View Exit Paper' : 'Save Truck Arrival'}
|
||
</Button>
|
||
</Group>
|
||
</Stack>
|
||
</Modal>
|
||
);
|
||
}
|