Files
edr-platform/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx
Hagernesh 7ab5e9edd5 feat(warehouses): handover-driven approve delivery, weigh-skip, 5-min sign reminders
Approve delivery now tracks the handover lifecycle exactly: the button appears
(detail page + portal dashboard) the moment a handover is generated and
disappears when the customer signs. The bookings list attaches the
handoverAwaitingSignature flag via one batched query per page; status
heuristics (COMPLETED / TRUCK_ASSIGNED+arrived) are gone.

- generate the arrival handover for ANY self-haul truck: portal-assigned OR
  walk-in registered at the gate (isSelfHaulBooking: assigned_at set, or no
  EDR last-mile leg). Same rule now guards the exit paper.
- remind every 5 minutes (in-app + SMS + email) until the handover is signed
  (@Cron in HandoverService; one reminder per booking per tick)
- Truck Leaving no longer opens blank: the import queue mapper now carries
  inv.notes, so the saved arrival renders read-only with only gate-out time
  and gross weight editable
- container bookings get "Weigh truck? Yes/No": No skips tare/gross and the
  container weight match (weighingSkipped on ReleaseOrderDto, decision made at
  arrival sticks for the exit via the Weighing: SKIPPED note). Bulk always
  weighs, unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 15:37:06 +00:00

489 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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());
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<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>
);
}