mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 23:28:11 +00:00
The modal read the booking id only from item.booking?.id, but inventory- workbench rows carry bookingId without the booking relation. Result: the container-weights and assigned-trucks queries never ran, so "Recorded net weight (system)" fell back to the inventory row's weight (often 0), the container MultiSelect never appeared, and the truck dropdown showed "not assigned" even when trucks existed - while the server correctly computed the cargo weight and rejected the mismatch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
491 lines
23 KiB
TypeScript
491 lines
23 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),
|
||
});
|
||
// 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);
|
||
|
||
// 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 (
|
||
<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>
|
||
);
|
||
}
|