Files
edr-platform/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx
Hagernesh 19906b273e fix(operations): prefill booking context on single assign; enforce per-truck container loads
- 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
2026-07-22 07:24:14 +00:00

704 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}