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 { isBackdated, nowLocalDateTimeInput } from '@/lib/no-backdate'; 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(['']); const [gateInTime, setGateInTime] = useState(''); const [tareWeight, setTareWeight] = useState(''); // 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(''); const [netWeight, setNetWeight] = useState(''); 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(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(); 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; } // No backdating: gate times are recorded as they happen. The locked // entrance (exit step) keeps its original past gate-in untouched. if (!isEntranceLocked && isBackdated(gateInTime)) { toast({ variant: 'destructive', title: 'Gate in time cannot be in the past' }); 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 && isBackdated(gateOutTime)) { toast({ variant: 'destructive', title: 'Gate out time cannot be in the past' }); 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 ( } color="orange" variant="light"> {isExitStep ? ( 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. ) : ( 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. )} {totalTrucks > 1 && ( } color="blue" variant="light"> {totalTrucks} trucks on this booking — each is weighed in and out separately. {arrivedTrucks}/{totalTrucks} arrived {leftTrucks}/{totalTrucks} left )} {hasTruckLeft && ( } color="green" variant="light"> Truck {truckPlateNumber} has already left — its exit record is locked. Pick another truck to continue the remaining arrivals and exits. )} setReference(e.currentTarget.value)} readOnly={referenceLocked} /> {noTruckAssigned && ( }> Truck is not assigned yet — assign a last-mile or customer truck, or enter the plate manually below. )} {truckSelectOptions.length > 0 && (