Truck assiggnment and per truc

This commit is contained in:
hagiye
2026-09-02 01:41:08 +03:00
parent 6d6b32feae
commit 031efce92b
12 changed files with 769 additions and 97 deletions

View File

@@ -58,6 +58,7 @@ import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
import { useToast } from '@/hooks/use-toast';
import { useAllWarehouseYards, useAllWarehouseZones, useInventoryInquiry } from '@/hooks/useWarehouses';
import { firstMileService } from '@/services/first-mile.service';
import { bookingsService } from '@/services/bookings.service';
import { warehouseService } from '@/services/warehouse.service';
import type {
EligibleBooking,
@@ -858,6 +859,8 @@ function EligibleTab({
const [truckForm, setTruckForm] = useState<TruckEntranceFormState>(emptyTruckEntrance());
const [lockedTruckFields, setLockedTruckFields] = useState<LockedTruckEntranceFields>({});
const [packagingFreightType, setPackagingFreightType] = useState<PackagingFreightType>('MIXED');
const [selectedCustomerTruckId, setSelectedCustomerTruckId] = useState<string | null>(null);
const [selectedContainerNumbers, setSelectedContainerNumbers] = useState<string[]>([]);
const locationReady = Boolean(location.warehouseId && location.yardId && location.zoneId);
const canReceiveBooking = (row: EligibleBooking) =>
@@ -944,6 +947,105 @@ function EligibleTab({
const pendingHasFirstMile = pendingReceiveRows.some((row) => row.hasFirstMile);
const pendingUsesFirstMile =
pendingReceiveRows.length > 0 && pendingReceiveRows.every((row) => row.hasFirstMile);
const pendingContainerBooking =
pendingReceiveRows.length === 1 && pendingReceiveRows[0]?.freightType === 'CONTAINER'
? pendingReceiveRows[0]
: null;
const { data: assignedCustomerTrucks = [] } = useQuery({
queryKey: ['receive-customer-trucks', pendingContainerBooking?.id],
queryFn: () => warehouseService.getCustomerTrucks(pendingContainerBooking?.id as string),
enabled: truckOpen && Boolean(pendingContainerBooking) && !pendingUsesFirstMile,
});
const pendingContainerUnits = (pendingContainerBooking?.containerUnits ?? []).filter(
(unit) => !unit.received,
);
const selectedCustomerTruck = assignedCustomerTrucks.find(
(truck) => truck.id === selectedCustomerTruckId,
);
const assignedNumbersForSelectedTruck = new Set(
(selectedCustomerTruck?.containers ?? []).map((container) => container.containerNumber.toUpperCase()),
);
const selectableContainerUnits = pendingContainerUnits.filter(
(unit) =>
assignedNumbersForSelectedTruck.size === 0 ||
assignedNumbersForSelectedTruck.has(unit.containerNumber.toUpperCase()),
);
const selectedContainerUnits = pendingContainerUnits.filter((unit) =>
selectedContainerNumbers.includes(unit.containerNumber),
);
const selectedContainerWeight = selectedContainerUnits.reduce(
(total, unit) => total + Number(unit.weightTons || 0),
0,
);
const containerCapacityError =
selectedContainerNumbers.length > 2
? 'A truck carries no more than 2 containers.'
: selectedContainerNumbers.length > 1 &&
selectedContainerUnits.some((unit) => !String(unit.containerSize ?? '').includes('20'))
? 'Truck capacity is either 1 x 40ft container or up to 2 x 20ft containers.'
: null;
useEffect(() => {
if (!truckOpen || pendingUsesFirstMile || !pendingContainerBooking) return;
if (selectedCustomerTruckId || assignedCustomerTrucks.length === 0) return;
const pendingNumbers = new Set(
pendingContainerUnits.map((unit) => unit.containerNumber.toUpperCase()),
);
const truck =
assignedCustomerTrucks.find(
(candidate) =>
!candidate.arrivedAt &&
(candidate.containers ?? []).some((container) =>
pendingNumbers.has(container.containerNumber.toUpperCase()),
),
) ?? assignedCustomerTrucks[0];
const truckContainers = (truck.containers ?? [])
.map((container) => container.containerNumber.toUpperCase())
.filter((number) => pendingNumbers.has(number));
setSelectedCustomerTruckId(truck.id);
setSelectedContainerNumbers(truckContainers);
setTruckForm((current) => ({
...current,
truckPlateNumber: truck.plateNumber,
driverName: truck.driverName,
truckType: truck.truckType,
assignedEquipmentNumber: truckContainers.join(', '),
unitCount: truckContainers.length,
netWeightKg: pendingContainerUnits
.filter((unit) => truckContainers.includes(unit.containerNumber.toUpperCase()))
.reduce((total, unit) => total + Number(unit.weightTons || 0), 0),
}));
setLockedTruckFields((current) => ({
...current,
truckPlateNumber: true,
driverName: true,
truckType: true,
assignedEquipmentNumber: true,
unitCount: true,
}));
}, [
assignedCustomerTrucks,
pendingContainerBooking,
pendingContainerUnits,
pendingUsesFirstMile,
selectedCustomerTruckId,
truckOpen,
]);
useEffect(() => {
if (!truckOpen || !pendingContainerBooking) return;
setTruckForm((current) => ({
...current,
assignedEquipmentNumber: selectedContainerNumbers.join(', '),
unitCount: selectedContainerNumbers.length,
netWeightKg: selectedContainerWeight,
}));
}, [
pendingContainerBooking,
selectedContainerNumbers,
selectedContainerWeight,
truckOpen,
]);
const toggleAll = () =>
setSelected(allSelected ? new Set() : new Set(selectableRows.map((r) => r.id)));
@@ -954,29 +1056,60 @@ function EligibleTab({
return next;
});
const receiveBookings = async (bookingIds: string[], truckEntrance?: TruckEntrancePayload) => {
const receiveBookings = async (
bookingIds: string[],
truckEntrance?: TruckEntrancePayload,
containerNumbers?: string[],
) => {
const documentBookingId = direction === 'EXPORT' && bookingIds.length === 1 ? bookingIds[0] : null;
const grnWindow = documentBookingId ? window.open('', '_blank') : null;
const acceptanceWindow = documentBookingId ? window.open('', '_blank') : null;
try {
const r = await bulkReceive.mutateAsync({
direction,
...location,
bookingIds,
...(containerNumbers?.length ? { containerNumbers } : {}),
...(truckEntrance ? { truckEntrance } : {}),
});
const receivedProgress = r.results.find(
(item) => item.receivedContainers != null && item.remainingContainers != null,
);
toast({
title: `${r.receivedCount} received at ${direction === 'EXPORT' ? 'facility' : 'warehouse'}`,
description: r.results.find((item) => item.grnNumber)?.grnNumber ?? skippedSummary(r.skippedCount, r.results),
description: receivedProgress
? `${containerNumbers?.length ?? 0} container(s) arrived. ${receivedProgress.remainingContainers} container(s) left. GRN ${receivedProgress.grnNumber}.`
: r.results.find((item) => item.grnNumber)?.grnNumber ?? skippedSummary(r.skippedCount, r.results),
});
const firstGrn = r.results.find((item) => item.inventoryId && item.grnNumber);
if (focusedBookingId && firstGrn?.inventoryId && firstGrn.grnNumber) {
const pdfWindow = window.open('', '_blank');
if (documentBookingId && firstGrn?.inventoryId && firstGrn.grnNumber) {
try {
const response = await warehouseService.downloadGrnDocument(firstGrn.inventoryId);
const opened = openPdfBlob(response.data, `grn-${firstGrn.grnNumber}.pdf`, pdfWindow);
const opened = openPdfBlob(response.data, `grn-${firstGrn.grnNumber}.pdf`, grnWindow);
toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' });
} catch (error) {
pdfWindow?.close();
grnWindow?.close();
toast({ variant: 'destructive', title: 'GRN document failed', description: await extractDownloadErrorMessage(error) });
}
try {
const acceptance = await bookingsService.downloadCarriageAcceptanceSheet(documentBookingId);
const opened = openPdfBlob(
acceptance,
`carriage-acceptance-${pendingReceiveRows[0]?.reference ?? documentBookingId}.pdf`,
acceptanceWindow,
);
toast({ title: opened ? 'Carriage acceptance sheet opened' : 'Carriage acceptance sheet downloaded' });
} catch (error) {
acceptanceWindow?.close();
toast({
variant: 'destructive',
title: 'Carriage acceptance sheet failed',
description: await extractDownloadErrorMessage(error),
});
}
} else {
grnWindow?.close();
acceptanceWindow?.close();
}
setSelected(new Set());
setTruckOpen(false);
@@ -984,8 +1117,12 @@ function EligibleTab({
setReceivedAt(null);
setLockedTruckFields({});
setPackagingFreightType('MIXED');
setSelectedCustomerTruckId(null);
setSelectedContainerNumbers([]);
onChanged?.();
} catch (error) {
grnWindow?.close();
acceptanceWindow?.close();
toast({ variant: 'destructive', title: 'Receive failed', description: extractErrorMessage(error) });
}
};
@@ -1012,6 +1149,18 @@ function EligibleTab({
void receiveBookings(filteredIds);
return;
}
if (
direction === 'EXPORT' &&
selectedRows.some((row) => row.freightType === 'CONTAINER') &&
selectedRows.length !== 1
) {
toast({
variant: 'destructive',
title: 'Receive one container booking per truck',
description: 'Select the arriving truck and its 1 x 40ft or up to 2 x 20ft containers.',
});
return;
}
const hasFirstMileRows = selectedRows.some((row) => row.hasFirstMile);
const hasCustomerTruckRows = selectedRows.some((row) => !row.hasFirstMile);
if (hasFirstMileRows && hasCustomerTruckRows) {
@@ -1041,6 +1190,8 @@ function EligibleTab({
...form,
};
setPendingReceiveIds(filteredIds);
setSelectedCustomerTruckId(null);
setSelectedContainerNumbers([]);
setReceivedAt(new Date().toISOString());
setTruckForm(normalizedForm);
setLockedTruckFields({
@@ -1073,7 +1224,70 @@ function EligibleTab({
toast({ variant: 'destructive', title: 'Gross weight and exit tare weight are required when weighing is Yes' });
return;
}
await receiveBookings(pendingReceiveIds, toTruckEntrancePayload(truckForm));
if (pendingContainerBooking && selectedContainerNumbers.length === 0) {
toast({ variant: 'destructive', title: 'Select the containers arriving on this truck' });
return;
}
if (containerCapacityError) {
toast({ variant: 'destructive', title: 'Truck capacity exceeded', description: containerCapacityError });
return;
}
await receiveBookings(
pendingReceiveIds,
toTruckEntrancePayload({
...truckForm,
...(pendingContainerBooking
? {
assignedEquipmentNumber: selectedContainerNumbers.join(', '),
unitCount: selectedContainerNumbers.length,
netWeightKg: selectedContainerWeight,
}
: {}),
}),
pendingContainerBooking ? selectedContainerNumbers : undefined,
);
};
const chooseCustomerTruck = (truckId: string | null) => {
setSelectedCustomerTruckId(truckId);
const truck = assignedCustomerTrucks.find((candidate) => candidate.id === truckId);
if (!truck) {
setSelectedContainerNumbers([]);
setLockedTruckFields((current) => ({
...current,
truckPlateNumber: false,
driverName: false,
truckType: false,
}));
return;
}
const pendingNumbers = new Set(
pendingContainerUnits.map((unit) => unit.containerNumber.toUpperCase()),
);
const containers = (truck.containers ?? [])
.map((container) => container.containerNumber.toUpperCase())
.filter((number) => pendingNumbers.has(number));
const weight = pendingContainerUnits
.filter((unit) => containers.includes(unit.containerNumber.toUpperCase()))
.reduce((total, unit) => total + Number(unit.weightTons || 0), 0);
setSelectedContainerNumbers(containers);
setTruckForm((current) => ({
...current,
truckPlateNumber: truck.plateNumber,
driverName: truck.driverName,
truckType: truck.truckType,
assignedEquipmentNumber: containers.join(', '),
unitCount: containers.length,
netWeightKg: weight,
}));
setLockedTruckFields((current) => ({
...current,
truckPlateNumber: true,
driverName: true,
truckType: true,
assignedEquipmentNumber: true,
unitCount: true,
}));
};
@@ -1296,6 +1510,76 @@ function EligibleTab({
: 'Register the customer or third-party truck and driver before export receiving and GRN.'}
</Text>
</Alert>
{pendingContainerBooking && (
<Stack gap="sm">
<Alert icon={<PackageCheck size={16} />} color="teal" variant="light">
<Group gap="xs">
<Text size="sm" fw={600}>
{pendingContainerBooking.receivedContainerCount + selectedContainerNumbers.length} containers arrived
</Text>
<Text size="sm">
· {Math.max(
0,
pendingContainerBooking.remainingContainerCount - selectedContainerNumbers.length,
)} left after this receipt
</Text>
<Badge variant="light" color="blue">
This truck: {selectedContainerNumbers.length}
</Badge>
</Group>
</Alert>
{assignedCustomerTrucks.length > 0 && !pendingUsesFirstMile && (
<Select
label="Arriving assigned truck"
description="Choose the physical truck at the gate; its assigned containers are selected below."
placeholder="Select truck"
data={assignedCustomerTrucks.map((truck) => ({
value: truck.id,
label: `${truck.plateNumber} · ${truck.driverName} · ${(truck.containers ?? [])
.map((container) => container.containerNumber)
.join(', ') || 'no containers'}`,
}))}
value={selectedCustomerTruckId}
onChange={chooseCustomerTruck}
searchable
required
/>
)}
<MultiSelect
label="Containers arriving on this truck"
description="Required: select either 1 x 40ft container or up to 2 x 20ft containers."
placeholder="Select the containers physically arriving"
data={selectableContainerUnits.map((unit) => {
const selected = selectedContainerNumbers.includes(unit.containerNumber);
const selectedHasNon20 = selectedContainerUnits.some(
(selectedUnit) => !String(selectedUnit.containerSize ?? '').includes('20'),
);
const candidateIs20 = String(unit.containerSize ?? '').includes('20');
return {
value: unit.containerNumber,
label: `${unit.containerNumber} · ${unit.containerSize ?? 'size unknown'} · ${Number(
unit.weightTons || 0,
).toLocaleString()} t`,
disabled:
!selected &&
(selectedContainerNumbers.length >= 2 ||
(selectedContainerNumbers.length === 1 &&
(selectedHasNon20 || !candidateIs20))),
};
})}
value={selectedContainerNumbers}
onChange={setSelectedContainerNumbers}
maxValues={2}
searchable
required
/>
{containerCapacityError && (
<Alert color="red" variant="light">
{containerCapacityError}
</Alert>
)}
</Stack>
)}
<Table.ScrollContainer minWidth={900}>
<Table withTableBorder highlightOnHover verticalSpacing="xs">
<Table.Thead>
@@ -1354,7 +1638,9 @@ function EligibleTab({
Cancel
</Button>
<Button leftSection={<Truck size={16} />} loading={bulkReceive.isPending} onClick={receive}>
Register Arrival & Generate GRN
{pendingContainerBooking
? 'Receive Selected Containers & Generate CAS + GRN'
: 'Register Arrival & Generate GRN'}
</Button>
</Group>
</Stack>

View File

@@ -387,6 +387,9 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
const containerWeightByNumber = new Map(
containerWeights.map((c) => [c.containerNumber.toUpperCase(), Number(c.weightTons) || 0]),
);
const containerSizeByNumber = new Map(
containerWeights.map((c) => [c.containerNumber.toUpperCase(), c.containerSize ?? '']),
);
// A truck may only carry out its OWN assigned containers — when the selected
// truck has an assigned load, other trucks' containers are not offered.
const assignedLoad = (selectedOption?.containerNumbers ?? []).map((n) => n.toUpperCase());
@@ -398,7 +401,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
c.containerNumber,
{
value: c.containerNumber,
label: `${c.containerNumber} · ${(Number(c.weightTons) || 0).toLocaleString()} t`,
label: `${c.containerNumber} · ${c.containerSize ?? 'size unknown'} · ${(Number(c.weightTons) || 0).toLocaleString()} t`,
},
]),
).values(),
@@ -409,6 +412,15 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
containerNumbers.some((n) => n.trim().toUpperCase() === option.value.toUpperCase()),
);
const selectedContainerNumbers = containerNumbers.map((n) => n.trim()).filter(Boolean);
const selectedContainerSizes = selectedContainerNumbers.map(
(number) => containerSizeByNumber.get(number.toUpperCase()) ?? '',
);
const containerCapacityError =
selectedContainerNumbers.length > 2
? 'A truck carries no more than 2 containers.'
: selectedContainerNumbers.length > 1 && selectedContainerSizes.some((size) => !size.includes('20'))
? 'Truck capacity is either 1 x 40ft container or up to 2 x 20ft containers.'
: null;
const selectedCargoWeight = Number(
selectedContainerNumbers
.reduce((sum, n) => sum + (containerWeightByNumber.get(n.toUpperCase()) ?? 0), 0)
@@ -470,6 +482,10 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
toast({ variant: 'destructive', title: 'Select the containers loaded on this truck' });
return;
}
if (isExitStep && containerCapacityError) {
toast({ variant: 'destructive', title: 'Truck capacity exceeded', description: containerCapacityError });
return;
}
if (isExitStep && !skipWeighing && systemNetWeight === '') {
toast({ variant: 'destructive', title: 'System recorded net weight is missing' });
return;
@@ -636,14 +652,15 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
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.'
? 'Select what is leaving: 1 x 40ft or up to 2 x 20ft. Their cargo weight must match gross - tare.'
: 'Truck capacity: 1 x 40ft container or up to 2 x 20ft containers.'
}
placeholder="Select containers"
searchable
data={containerSelectData}
value={selectedContainerNumbers}
onChange={(values) => setContainerNumbers(values.length ? values : [''])}
maxValues={2}
disabled={hasTruckLeft}
/>
) : (
@@ -708,6 +725,11 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
</Text>
</Alert>
)}
{containerCapacityError && (
<Alert icon={<Info size={16} />} color="red" variant="light">
<Text size="sm">{containerCapacityError}</Text>
</Alert>
)}
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={onClose} disabled={releaseMutation.isPending || downloading}>
Cancel

View File

@@ -206,7 +206,7 @@ export const warehouseService = {
/** A booking's containers with VGM cargo weight (tonnes) for exit weighing. */
getContainerWeights: async (
bookingId: string,
): Promise<Array<{ containerNumber: string; weightTons: number }>> => {
): Promise<Array<{ containerNumber: string; weightTons: number; containerSize: string | null }>> => {
const { data } = await apiClient.get(
`/warehouse-inventory/bookings/${bookingId}/container-weights`,
);

View File

@@ -575,6 +575,15 @@ export interface EligibleBooking {
customerTruckType: string | null;
customerTruckContainerNumber: string | null;
customerTruckAssignedAt: string | null;
containerUnits: Array<{
containerNumber: string;
containerSize: string | null;
weightTons: number;
received: boolean;
grnNumber: string | null;
}>;
receivedContainerCount: number;
remainingContainerCount: number;
}
export interface BulkReceivePayload {
@@ -583,13 +592,23 @@ export interface BulkReceivePayload {
yardId: string;
zoneId: string;
bookingIds: string[];
containerNumbers?: string[];
truckEntrance?: TruckEntrancePayload;
}
export interface BulkReceiveResult {
receivedCount: number;
skippedCount: number;
results: { bookingId: string; status: string; inventoryId?: string; grnNumber?: string; reason?: string }[];
results: {
bookingId: string;
status: string;
inventoryId?: string;
inventoryIds?: string[];
grnNumber?: string;
receivedContainers?: number;
remainingContainers?: number;
reason?: string;
}[];
}
export interface TruckEntrancePayload {