mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 14:08:11 +00:00
354 lines
17 KiB
TypeScript
354 lines
17 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { Alert, Button, Group, Modal, NumberInput, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
|
|
import { Info, Scale } from 'lucide-react';
|
|
|
|
import { useMutation } 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;
|
|
}
|
|
|
|
const REGISTERED_FIRST_LAST_MILE_TRUCKS = [
|
|
['03-ET A45843', '43495'], ['03-ET A45866', '43470'], ['03-ET A45853', '43508'], ['03-ET A45849', '43414'],
|
|
['03-ET A45845', '43492'], ['03-ET A45820', '43487'], ['03-ET A45842', '43478'], ['03-ET A45841', '43515'],
|
|
['03-ET A45832', '43504'], ['03-ET A45856', '43510'], ['03-ET A45865', '43490'], ['03-ET A45855', '43485'],
|
|
['03-ET A45840', '43499'], ['03-ET A45867', '43493'], ['03-ET A45833', '43466'], ['03-ET A45858', '43496'],
|
|
['03-ET A45819', '43474'], ['03-ET A45834', '43502'], ['03-ET A45868', '43469'], ['03-ET A45831', '43488'],
|
|
['03-ET A45828', '43479'], ['03-ET A45850', '43505'], ['03-ET A45823', '43480'], ['03-ET A45838', '43472'],
|
|
['03-ET A45854', '43500'], ['03-ET A45839', '43486'], ['03-ET A45861', '43513'], ['03-ET A45830', '43501'],
|
|
['03-ET A45826', '43498'], ['03-ET A45836', '43467'], ['03-ET A45822', '43512'], ['03-ET A45821', '43210'],
|
|
['03-ET A45837', '43475'], ['03-ET A45860', '43497'], ['03-ET A45863', '43477'], ['03-ET A45825', '43483'],
|
|
['03-ET A45829', '43473'], ['03-ET A45824', '43491'], ['03-ET A45857', '43481'], ['03-ET A45851', '43509'],
|
|
['03-ET A45827', '43468'], ['03-ET A45859', '43887'], ['03-ET A45846', '43471'], ['03-ET A45847', '43511'],
|
|
['03-ET A45852', '43484'], ['03-ET A45844', '43476'], ['03-ET A45835', '43482'], ['03-ET A45864', '43503'],
|
|
['03-ET A45848', '43494'], ['03-ET A45862', '43465'], ['03-ET A39105', '41218'], ['03-ET A39098', '41220'],
|
|
['03-ET A29900', '41865'], ['03-ET A39097', '41226'], ['03-ET A39104', '41225'], ['03-ET A39103', '41223'],
|
|
['03-ET A39106', '41221'], ['03-ET A39107', '41215'], ['03-ET A39094', '41222'], ['03-ET A39099', '41216'],
|
|
['03-ET A39092', '41224'], ['03-ET A31801', '41214'],
|
|
].map(([powerPlate, trailerPlate], index) => ({
|
|
value: powerPlate,
|
|
label: `${index + 1}. ${powerPlate} / ${trailerPlate}`,
|
|
trailerPlate,
|
|
}));
|
|
|
|
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$/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] ?? '');
|
|
};
|
|
|
|
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')),
|
|
};
|
|
};
|
|
|
|
export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalProps) {
|
|
const { toast } = useToast();
|
|
const releaseMutation = useMutation(api.warehouses.release.mutationOptions());
|
|
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 | ''>('');
|
|
const [grossWeight, setGrossWeight] = useState<number | ''>('');
|
|
const [netWeight, setNetWeight] = useState<number | ''>('');
|
|
const [gateOutTime, setGateOutTime] = useState('');
|
|
const [downloading, setDownloading] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (opened) {
|
|
const inspection = parseInspectionNote(item?.notes);
|
|
setReference(item?.releaseOrderReference ?? generateReleaseReference(item));
|
|
setTruckPlateNumber(inspection.truckPlateNumber);
|
|
setTrailerPlateNumber(inspection.trailerPlateNumber);
|
|
setDriverName(inspection.driverName);
|
|
setDriverLicense(inspection.driverLicense);
|
|
setDriverPhone(inspection.driverPhone);
|
|
setTruckType(inspection.truckType);
|
|
setContainerNumbers(initialContainerNumbers(item, inspection.containerNumber));
|
|
setGateInTime(inspection.gateInTime);
|
|
setTareWeight(inspection.tareWeight);
|
|
setGrossWeight(inspection.grossWeight);
|
|
setNetWeight(item?.weight == null ? inspection.netWeight : Number(item.weight));
|
|
setGateOutTime(inspection.gateOutTime);
|
|
}
|
|
}, [opened, item]);
|
|
|
|
const savedInspection = parseInspectionNote(item?.notes);
|
|
const isExitStep = savedInspection.tareWeight !== '';
|
|
const isEntranceLocked = isExitStep;
|
|
const systemNetWeight = item?.weight == null ? netWeight : Number(item.weight);
|
|
const computedNetWeight =
|
|
tareWeight !== '' && grossWeight !== '' ? Number((Number(grossWeight) - Number(tareWeight)).toFixed(3)) : null;
|
|
const weightMismatch =
|
|
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 || tareWeight === '') {
|
|
toast({ variant: 'destructive', title: 'Gate in time and tare weight are required' });
|
|
return;
|
|
}
|
|
if (isExitStep && (!gateOutTime || grossWeight === '')) {
|
|
toast({ variant: 'destructive', title: 'Gate out time and gross weight are required' });
|
|
return;
|
|
}
|
|
if (isExitStep && 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),
|
|
tareWeight: Number(tareWeight),
|
|
grossWeight: grossWeight === '' ? undefined : Number(grossWeight),
|
|
netWeight: 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 (
|
|
<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>
|
|
<TextInput
|
|
label="Release document reference"
|
|
placeholder="e.g. REL-2026-001"
|
|
value={reference}
|
|
onChange={(e) => setReference(e.currentTarget.value)}
|
|
readOnly={isEntranceLocked}
|
|
/>
|
|
<Select
|
|
label="Registered first / last-mile truck"
|
|
placeholder="Select truck or type plate manually below"
|
|
searchable
|
|
clearable
|
|
data={REGISTERED_FIRST_LAST_MILE_TRUCKS}
|
|
disabled={isEntranceLocked}
|
|
value={REGISTERED_FIRST_LAST_MILE_TRUCKS.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null}
|
|
onChange={(value) => {
|
|
const truck = REGISTERED_FIRST_LAST_MILE_TRUCKS.find((row) => row.value === value);
|
|
setTruckPlateNumber(truck?.value ?? '');
|
|
setTrailerPlateNumber(truck?.trailerPlate ?? '');
|
|
}}
|
|
/>
|
|
<Group grow>
|
|
<TextInput
|
|
label="Truck plate number"
|
|
required
|
|
value={truckPlateNumber}
|
|
onChange={(e) => setTruckPlateNumber(e.currentTarget.value)}
|
|
readOnly={isEntranceLocked}
|
|
/>
|
|
<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={isEntranceLocked} />
|
|
<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={isEntranceLocked} />
|
|
</Group>
|
|
<Group grow>
|
|
<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={isEntranceLocked}
|
|
/>
|
|
))}
|
|
</SimpleGrid>
|
|
</Stack>
|
|
<TextInput label="Gate in time" type="datetime-local" value={gateInTime} onChange={(e) => setGateInTime(e.currentTarget.value)} readOnly={isEntranceLocked} />
|
|
</Group>
|
|
<Group grow>
|
|
<NumberInput label="Tare weight (kg)" required min={0} value={tareWeight} onChange={(v) => setTareWeight(v === '' ? '' : Number(v))} readOnly={isEntranceLocked} />
|
|
<NumberInput label="Gross weight (kg)" required={isExitStep} min={0} value={grossWeight} onChange={(v) => setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep} />
|
|
<NumberInput label="Recorded net weight (system kg)" min={0} value={systemNetWeight} readOnly />
|
|
</Group>
|
|
<Group justify="space-between">
|
|
<Text size="sm" c={weightMismatch ? 'red' : 'dimmed'}>
|
|
Computed net: <b>{computedNetWeight == null ? '-' : `${computedNetWeight.toLocaleString()} kg`}</b>
|
|
</Text>
|
|
<TextInput label="Gate out time" type="datetime-local" value={gateOutTime} onChange={(e) => setGateOutTime(e.currentTarget.value)} disabled={!isExitStep} />
|
|
</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}>
|
|
{isExitStep ? 'Save Truck Leaving & View Exit Paper' : 'Save Truck Arrival'}
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Modal>
|
|
);
|
|
}
|