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 { useMutation, useQuery } 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 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 assignedTruckValue = (item: WarehouseInventoryItem | null, key: keyof NonNullable) => 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; 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] ?? ''); }; 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 ?? ''), }; }; export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: ReleaseOrderModalProps) { const { toast } = useToast(); const releaseMutation = useMutation(api.warehouses.release.mutationOptions()); const bookingId = item?.booking?.id; // 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), }); // 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); 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); // Registered trucks for THIS booking, from both sources: EDR last-mile // (truckPrefill) and the customer portal (customer_truck_assignments). const assignedTruckOptions = [ ...(truckPrefill?.truckPlateNumber ? [ { value: truckPrefill.truckPlateNumber, label: `Last-mile · ${truckPrefill.truckPlateNumber}`, trailerPlate: truckPrefill.trailerPlateNumber ?? '', driverName: truckPrefill.driverName ?? '', driverPhone: truckPrefill.driverPhone ?? '', truckType: truckPrefill.truckType ?? '', }, ] : []), ...customerTrucks.map((t) => ({ value: t.plateNumber, label: `Customer · ${t.plateNumber} — ${t.driverName}`, trailerPlate: '', driverName: t.driverName, driverPhone: '', truckType: t.truckType, })), ]; // 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. const truckSelectOptions = assignedTruckOptions; // 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); // 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]), ); const containerSelectData = containerWeights.map((c) => ({ value: c.containerNumber, label: `${c.containerNumber} · ${(Number(c.weightTons) || 0).toLocaleString()} t`, })); 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'; const useContainerNet = hasContainerWeights && selectedContainerNumbers.length > 0 && !skipWeighing; 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 (!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), netWeight: !skipWeighing && isExitStep && systemNetWeight !== '' ? Number(systemNetWeight) : undefined, gateOutTime: isExitStep ? toIsoDateTime(gateOutTime) : undefined, }, }); if (!isExitStep) { toast({ title: 'Truck arrival saved', 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); 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.', }); 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. )} setReference(e.currentTarget.value)} readOnly={isEntranceLocked} /> {noTruckAssigned && ( }> Truck is not assigned yet — assign a last-mile or customer truck, or enter the plate manually below. )} {truckSelectOptions.length > 0 && (