mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 05:30:55 +00:00
Truck loading: - loadTruck enforces max 2 containers / one 40ft (two 20ft) and auto-marks an assigned truck arrived on load; container-items payload + modal expose container size with a client-side selection cap. - Show "#x containers pending assignment" in the portal truck card and the backoffice container modal. Last-mile: - create() is idempotent — return the existing record for a booking instead of inserting a duplicate delivery row (fixed the same booking showing twice in Assign-Mile). - setVehicles/update reject a truck with no assigned driver; the Assign toast now surfaces the reason. - New GET /last-mile/booking/:id/arrival-trucks returns assigned EDR trucks with driver details; ReleaseOrderModal fetches and auto-fills them so an assigned EDR truck no longer reads as "not assigned yet". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
539 lines
25 KiB
TypeScript
539 lines
25 KiB
TypeScript
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<WarehouseInventoryItem['booking']>) =>
|
||
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());
|
||
// 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);
|
||
|
||
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);
|
||
|
||
// Opened from the warehouse flow (no truckPrefill prop): once the last-mile
|
||
// truck query resolves, auto-fill the first assigned EDR truck — without
|
||
// overwriting anything the operator typed or the locked exit-step values.
|
||
useEffect(() => {
|
||
if (!opened || truckPrefill || isExitStep) return;
|
||
const first = lastMileTrucks[0];
|
||
if (!first) return;
|
||
setTruckPlateNumber((p) => p || first.truckPlateNumber || '');
|
||
setTrailerPlateNumber((p) => p || first.trailerPlateNumber || '');
|
||
setDriverName((p) => p || first.driverName || '');
|
||
setDriverLicense((p) => p || first.driverLicense || '');
|
||
setDriverPhone((p) => p || first.driverPhone || '');
|
||
setTruckType((p) => p || first.truckType || '');
|
||
setContainerNumbers((prev) =>
|
||
prev.length === 1 && !prev[0] && first.containerNumber ? [first.containerNumber] : prev,
|
||
);
|
||
}, [opened, truckPrefill, isExitStep, lastMileTrucks]);
|
||
|
||
// 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,
|
||
})),
|
||
...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 ?? '',
|
||
})),
|
||
];
|
||
// 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(),
|
||
];
|
||
// 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]),
|
||
);
|
||
// 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(),
|
||
];
|
||
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 (
|
||
<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}
|
||
/>
|
||
{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="Assigned first / last-mile truck"
|
||
placeholder="Select the assigned truck"
|
||
searchable
|
||
clearable
|
||
// Enabled at arrival so the operator picks which assigned truck came;
|
||
// only locked on the exit (leaving) step once identity is captured.
|
||
disabled={isEntranceLocked}
|
||
data={truckSelectOptions}
|
||
value={truckSelectOptions.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null}
|
||
onChange={(value) => {
|
||
const truck = truckSelectOptions.find((row) => row.value === value);
|
||
setTruckPlateNumber(truck?.value ?? '');
|
||
setTrailerPlateNumber(truck?.trailerPlate ?? '');
|
||
if (truck?.driverName) setDriverName(truck.driverName);
|
||
if (truck?.driverPhone) setDriverPhone(truck.driverPhone);
|
||
if (truck?.truckType) setTruckType(truck.truckType);
|
||
}}
|
||
/>
|
||
)}
|
||
<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 : [''])}
|
||
/>
|
||
) : (
|
||
<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 — container passes without tare/gross.</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} />
|
||
<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} />
|
||
</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>
|
||
);
|
||
}
|