mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-05 22:23:38 +00:00
Ready to Load listed inventory and offered an auto-load picker, but the warehouse floor works train by train: a train stands at the yard, its bookings board wagon by wagon, it rolls. That view existed only on the train schedule workspace, which the warehouse staff do not run. Ready to Load now has an Items / By train switch. By train is a mirror of the schedule's own column, placed where the loading actually happens. Nothing in it has its own rules. Train position, the per-yard loading and unloading windows, and the per-booking Load / Wagons / Unload actions all come from the train-scheduling endpoints the schedule workspace already calls, so a Load that would be refused there is disabled here with the same reason and the two surfaces cannot disagree. No train-scheduling code is touched. What the warehouse adds is what the schedule cannot see: which of the train's bookings are physically in the shed, with their GRN and inspection state, laid out along the flow the staff follow -- receive and GRN, inspect, ready, open the loading window, train at yard, load per wagon, dispatch, unload at port. Wagons - The wagon modal calls the same per-wagon journey endpoints, so every server gate (train at the yard, window started, PAID, GRN) is the schedule's own. - Wagons go one at a time in order: the server flips the booking to IN_TRANSIT or ARRIVED on whichever call clears the last wagon, so sequential is required, not merely tidy. A failure stops the run, the wagons already sent stay done, and the toast says how many, so a retry only resends the rest. - Deliberately not mirrored: cancelling wagons that will not ride, and the direct truck-to-train handover. Both are commercial decisions (fees, credits, GRN waiver) that belong to the schedule workspace. loadable-trains takes includeDispatched. Loading follows the train after it rolls, since a mid-corridor warehouse boards its cargo when the train stands at its yard, and the train-centric view needs the same set the schedule offers Load on. The default stays pre-dispatch only, so the existing auto-load picker is unchanged. Also fixes the backoffice build: ReceiveInventoryModal used MultiSelect without importing it, left behind by the self-haul assignment work.
4006 lines
154 KiB
TypeScript
4006 lines
154 KiB
TypeScript
import { Fragment, useEffect, useMemo, useState } from 'react';
|
|
import {
|
|
ActionIcon,
|
|
Alert,
|
|
Badge,
|
|
Button,
|
|
Card,
|
|
Checkbox,
|
|
CopyButton,
|
|
Group,
|
|
Loader,
|
|
Menu,
|
|
Modal,
|
|
MultiSelect,
|
|
NumberInput,
|
|
ScrollArea,
|
|
SegmentedControl,
|
|
Select,
|
|
SimpleGrid,
|
|
Stack,
|
|
Table,
|
|
Tabs,
|
|
Text,
|
|
Textarea,
|
|
TextInput,
|
|
ThemeIcon,
|
|
Tooltip,
|
|
} from '@mantine/core';
|
|
import {
|
|
ArrowRightLeft,
|
|
Calendar,
|
|
Check,
|
|
CheckCheck,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
ClipboardCheck,
|
|
Copy,
|
|
Eye,
|
|
FileText,
|
|
History,
|
|
Info,
|
|
Layers,
|
|
MapPin,
|
|
MoreHorizontal,
|
|
PackageCheck,
|
|
PackageOpen,
|
|
PackageSearch,
|
|
Send,
|
|
Search,
|
|
Train,
|
|
Truck,
|
|
} from 'lucide-react';
|
|
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
|
|
import { useAuth } from '@/auth/useAuth';
|
|
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from '@/lib/permissions';
|
|
import { api } from '@/services/api';
|
|
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,
|
|
InventoryInquiryFilter,
|
|
InventoryStatus,
|
|
InventoryInquiryResult,
|
|
ImportTrain,
|
|
ImportTrainItem,
|
|
ImportUnloadedItem,
|
|
ReadyToLoadRow,
|
|
ReceiveInventoryPayload,
|
|
TruckEntrancePayload,
|
|
Warehouse,
|
|
WarehouseInventoryItem,
|
|
WarehouseYard,
|
|
WarehouseZone,
|
|
} from '@/types/warehouse';
|
|
import { InventoryStatusBadge } from './badges';
|
|
import { BookingSelect } from './BookingSelect';
|
|
import { DeliverInventoryModal } from './DeliverInventoryModal';
|
|
import { ContainerItemsModal } from './ContainerItemsModal';
|
|
import { FeePreviewModal } from './FeePreviewModal';
|
|
import { GrnDocumentButton } from './GrnDocumentButton';
|
|
import { InspectionReportModal } from './InspectionReportModal';
|
|
import { InventoryDetailModal } from './InventoryDetailModal';
|
|
import { InventoryHistoryModal } from './InventoryHistoryModal';
|
|
import { InventoryWorkbench } from './InventoryWorkbench';
|
|
import { InventoryInquiryDetailModal } from './InventoryInquiryDetailModal';
|
|
import { MoveInventoryModal } from './MoveInventoryModal';
|
|
import { ReleaseOrderModal } from './ReleaseOrderModal';
|
|
import { StoreInventoryModal } from './StoreInventoryModal';
|
|
import { WarehouseInquiryTable } from './WarehouseInquiryTable';
|
|
import { TrainLoadingWorkspace } from './TrainLoadingWorkspace';
|
|
import { YardLoadingWindows } from './YardLoadingWindows';
|
|
import { extractDownloadErrorMessage, extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions, warehousesAtStation, yardsForBooking } from './options';
|
|
import { openPdfBlob } from './pdf';
|
|
import ListControls from '@/components/common/ListControls';
|
|
import RuleEngineListFooter from '@/components/ruleEngine/RuleEngineListFooter';
|
|
import { useListControls } from '@/hooks/useListControls';
|
|
import '@/components/overview/overview.css';
|
|
|
|
type ImportUnloadAssignment = { bookingId: string; warehouseId: string; yardId: string; zoneId: string };
|
|
type ImportUnloadAssignmentDraft = Partial<Omit<ImportUnloadAssignment, 'bookingId'>>;
|
|
|
|
interface ReceiveInventoryModalProps {
|
|
opened: boolean;
|
|
onClose: () => void;
|
|
/** When supplied the modal locks to a single booking (legacy single-receive). */
|
|
bookingId?: string;
|
|
bookingLabel?: string;
|
|
mode?: 'single' | 'bulk';
|
|
direction?: WarehouseFlowDirection;
|
|
onReceived?: () => void;
|
|
}
|
|
|
|
|
|
interface Location {
|
|
warehouseId: string;
|
|
yardId: string;
|
|
zoneId: string;
|
|
}
|
|
|
|
interface TruckEntranceFormState {
|
|
ownerName: string;
|
|
consigneeDetails: string;
|
|
edrDigitalBookingId: string;
|
|
tin: string;
|
|
customerPhone: string;
|
|
truckPlateNumber: string;
|
|
trailerPlateNumber: string;
|
|
assignedEquipmentNumber: string;
|
|
customsSealNumber: string;
|
|
declarationNumber: string;
|
|
itemDescription: string;
|
|
packagingType: string;
|
|
unitCount: number | '';
|
|
grossWeightKg: number | '';
|
|
weighingRequired: boolean | null;
|
|
netWeightKg: number | '';
|
|
volumeDimensions: string;
|
|
conditionAtReceipt: string;
|
|
damagedRejectedQuantity: number | '';
|
|
driverName: string;
|
|
driverPhone: string;
|
|
driverLicenseNumber: string;
|
|
truckType: string;
|
|
entranceTareWeightKg: number | '';
|
|
exitTareWeightKg: number | '';
|
|
driverSignatoryName: string;
|
|
warehouseManagerName: string;
|
|
}
|
|
|
|
interface LockedTruckEntranceFields {
|
|
ownerName?: boolean;
|
|
consigneeDetails?: boolean;
|
|
tin?: boolean;
|
|
edrDigitalBookingId?: boolean;
|
|
customerPhone?: boolean;
|
|
truckPlateNumber?: boolean;
|
|
trailerPlateNumber?: boolean;
|
|
assignedEquipmentNumber?: boolean;
|
|
itemDescription?: boolean;
|
|
packagingType?: boolean;
|
|
unitCount?: boolean;
|
|
grossWeightKg?: boolean;
|
|
driverName?: boolean;
|
|
driverPhone?: boolean;
|
|
driverLicenseNumber?: boolean;
|
|
truckType?: boolean;
|
|
}
|
|
|
|
type PackagingFreightType = 'CONTAINER' | 'BULK' | 'MIXED';
|
|
|
|
const emptyTruckEntrance = (): TruckEntranceFormState => ({
|
|
ownerName: '',
|
|
consigneeDetails: '',
|
|
edrDigitalBookingId: '',
|
|
tin: '',
|
|
customerPhone: '',
|
|
truckPlateNumber: '',
|
|
trailerPlateNumber: '',
|
|
assignedEquipmentNumber: '',
|
|
customsSealNumber: '',
|
|
declarationNumber: '',
|
|
itemDescription: '',
|
|
packagingType: '',
|
|
unitCount: '',
|
|
grossWeightKg: '',
|
|
weighingRequired: null,
|
|
netWeightKg: '',
|
|
volumeDimensions: '',
|
|
conditionAtReceipt: '',
|
|
damagedRejectedQuantity: '',
|
|
driverName: '',
|
|
driverPhone: '',
|
|
driverLicenseNumber: '',
|
|
truckType: '',
|
|
entranceTareWeightKg: '',
|
|
exitTareWeightKg: '',
|
|
driverSignatoryName: '',
|
|
warehouseManagerName: '',
|
|
});
|
|
|
|
const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayload => ({
|
|
ownerName: form.ownerName.trim() || undefined,
|
|
consigneeDetails: form.consigneeDetails.trim() || undefined,
|
|
edrDigitalBookingId: form.edrDigitalBookingId.trim() || undefined,
|
|
tin: form.tin.trim() || undefined,
|
|
customerPhone: form.customerPhone.trim() || undefined,
|
|
truckPlateNumber: form.truckPlateNumber.trim(),
|
|
trailerPlateNumber: form.trailerPlateNumber.trim() || undefined,
|
|
assignedEquipmentNumber: form.assignedEquipmentNumber.trim() || undefined,
|
|
customsSealNumber: form.customsSealNumber.trim() || undefined,
|
|
declarationNumber: form.declarationNumber.trim() || undefined,
|
|
itemDescription: form.itemDescription.trim() || undefined,
|
|
packagingType: form.packagingType.trim() || undefined,
|
|
unitCount: form.unitCount === '' ? undefined : Number(form.unitCount),
|
|
weighingRequired: form.weighingRequired ?? undefined,
|
|
grossWeightKg: form.weighingRequired && form.grossWeightKg !== '' ? Number(form.grossWeightKg) : undefined,
|
|
netWeightKg: form.netWeightKg === '' ? undefined : Number(form.netWeightKg),
|
|
volumeDimensions: form.volumeDimensions.trim() || undefined,
|
|
conditionAtReceipt: form.conditionAtReceipt.trim() || undefined,
|
|
damagedRejectedQuantity: form.damagedRejectedQuantity === '' ? undefined : Number(form.damagedRejectedQuantity),
|
|
driverName: form.driverName.trim(),
|
|
driverPhone: form.driverPhone.trim(),
|
|
driverLicenseNumber: form.driverLicenseNumber.trim() || undefined,
|
|
truckType: form.truckType.trim() || undefined,
|
|
entranceTareWeightKg:
|
|
form.entranceTareWeightKg === ''
|
|
? undefined
|
|
: Number(form.entranceTareWeightKg),
|
|
exitTareWeightKg: form.weighingRequired && form.exitTareWeightKg !== '' ? Number(form.exitTareWeightKg) : undefined,
|
|
driverSignatoryName: form.driverSignatoryName.trim() || undefined,
|
|
warehouseManagerName: form.warehouseManagerName.trim() || undefined,
|
|
});
|
|
|
|
|
|
|
|
|
|
const SUB_STAGE_COLOR: Record<string, string> = {
|
|
PENDING: 'gray',
|
|
RECEIVED: 'blue',
|
|
GRN: 'teal',
|
|
ASSIGNED: 'indigo',
|
|
LOADED: 'grape',
|
|
LEFT: 'orange',
|
|
DELIVERED: 'green',
|
|
};
|
|
|
|
/**
|
|
* Expanded booking row: the booking's containers / bulk items with their
|
|
* lifecycle stage. Shares the ['container-items', bookingId] cache with
|
|
* ContainerItemsModal, so expanding after using the modal is instant.
|
|
*/
|
|
function BookingItemsExpansion({
|
|
bookingId,
|
|
colSpan,
|
|
bulkFallback,
|
|
}: {
|
|
bookingId: string | null;
|
|
colSpan: number;
|
|
bulkFallback?: string;
|
|
}) {
|
|
const { data: items = [], isLoading } = useQuery({
|
|
queryKey: ['container-items', bookingId],
|
|
queryFn: () => warehouseService.getContainerItems(bookingId as string),
|
|
enabled: Boolean(bookingId),
|
|
});
|
|
|
|
return (
|
|
<Table.Tr>
|
|
<Table.Td colSpan={colSpan} bg="var(--mantine-color-gray-0)">
|
|
{isLoading ? (
|
|
<Group justify="center" py="sm">
|
|
<Loader size="xs" />
|
|
</Group>
|
|
) : items.length === 0 ? (
|
|
<Text size="xs" c="dimmed" py={6}>
|
|
{bulkFallback ?? 'No container units recorded on this booking.'}
|
|
</Text>
|
|
) : (
|
|
<Table verticalSpacing={4} fz="xs" withTableBorder>
|
|
<Table.Thead>
|
|
<Table.Tr>
|
|
<Table.Th>Container #</Table.Th>
|
|
<Table.Th>Goods</Table.Th>
|
|
<Table.Th>Stage</Table.Th>
|
|
<Table.Th>Truck</Table.Th>
|
|
<Table.Th>GRN</Table.Th>
|
|
</Table.Tr>
|
|
</Table.Thead>
|
|
<Table.Tbody>
|
|
{items.map((i) => (
|
|
<Table.Tr key={i.containerNumber}>
|
|
<Table.Td>
|
|
<Text size="xs" fw={600}>{i.containerNumber}</Text>
|
|
</Table.Td>
|
|
<Table.Td>{i.goods ?? '—'}</Table.Td>
|
|
<Table.Td>
|
|
<Badge size="xs" variant="light" color={SUB_STAGE_COLOR[i.stage] ?? 'gray'}>
|
|
{i.stage}
|
|
</Badge>
|
|
</Table.Td>
|
|
<Table.Td>{i.truckPlate ?? '—'}</Table.Td>
|
|
<Table.Td>{i.grnNumber ?? '—'}</Table.Td>
|
|
</Table.Tr>
|
|
))}
|
|
</Table.Tbody>
|
|
</Table>
|
|
)}
|
|
</Table.Td>
|
|
</Table.Tr>
|
|
);
|
|
}
|
|
|
|
type ConfirmAction = { title: string; message: string; confirmLabel: string; run: () => void };
|
|
|
|
/** One-click bulk actions are irreversible — make the click deliberate. */
|
|
function ConfirmActionModal({
|
|
action,
|
|
onClose,
|
|
}: {
|
|
action: ConfirmAction | null;
|
|
onClose: () => void;
|
|
}) {
|
|
return (
|
|
<Modal opened={Boolean(action)} onClose={onClose} title={action?.title ?? ''} centered size="sm">
|
|
<Stack gap="md">
|
|
<Text size="sm">{action?.message}</Text>
|
|
<Group justify="flex-end">
|
|
<Button variant="default" onClick={onClose}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={() => {
|
|
action?.run();
|
|
onClose();
|
|
}}
|
|
>
|
|
{action?.confirmLabel}
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
/** "3 skipped — Booking not PAID" instead of a bare count. */
|
|
const skippedSummary = (
|
|
skippedCount: number,
|
|
results: Array<{ reason?: string; message?: string }>,
|
|
): string | undefined => {
|
|
if (!skippedCount) return undefined;
|
|
const reason = results.find((x) => x.reason || x.message);
|
|
return `${skippedCount} skipped${reason ? ` — ${reason.reason ?? reason.message}` : ''}`;
|
|
};
|
|
|
|
const commonNonEmptyValue = (values: Array<string | null | undefined>) => {
|
|
const unique = [...new Set(values.map((value) => value?.trim()).filter(Boolean))] as string[];
|
|
return unique.length === 1 ? unique[0] : '';
|
|
};
|
|
|
|
const truckEntranceFromBookings = (bookings: EligibleBooking[]): {
|
|
form: TruckEntranceFormState;
|
|
lockedFields: LockedTruckEntranceFields;
|
|
packagingFreightType: PackagingFreightType;
|
|
} => {
|
|
const ownerName = commonNonEmptyValue(bookings.map((booking) => booking.customer));
|
|
const tin = commonNonEmptyValue(bookings.map((booking) => booking.customerTin));
|
|
const customerPhone = commonNonEmptyValue(bookings.map((booking) => booking.customerPhone));
|
|
const consigneeDetails = commonNonEmptyValue(bookings.map((booking) => booking.customer));
|
|
const assignedEquipmentNumber = commonNonEmptyValue(
|
|
bookings.map((booking) => booking.customerTruckContainerNumber || booking.containerNumber),
|
|
);
|
|
const itemDescription = commonNonEmptyValue(bookings.map((booking) => booking.cargoDescription ?? booking.cargo));
|
|
const packagingType = commonNonEmptyValue(bookings.map((booking) => booking.containerPackagingType));
|
|
const truckPlateNumber = commonNonEmptyValue(
|
|
bookings.map((booking) => booking.firstMileTruckPlateNumber || booking.customerTruckPlateNumber),
|
|
);
|
|
const trailerPlateNumber = commonNonEmptyValue(bookings.map((booking) => booking.firstMileTrailerPlateNumber));
|
|
const driverName = commonNonEmptyValue(
|
|
bookings.map((booking) => booking.firstMileDriverName || booking.customerTruckDriverName),
|
|
);
|
|
const driverPhone = commonNonEmptyValue(bookings.map((booking) => booking.firstMileDriverPhone));
|
|
const driverLicenseNumber = commonNonEmptyValue(bookings.map((booking) => booking.firstMileDriverLicenseNumber));
|
|
const truckType = commonNonEmptyValue(
|
|
bookings.map((booking) => booking.firstMileTruckType || booking.customerTruckType),
|
|
);
|
|
const customsSealNumber = commonNonEmptyValue(bookings.map((booking) => booking.sealNumbers));
|
|
// Booking's declared cargo weight (tonnes) — the receive-time net until re-weighed.
|
|
const bookingWeight = bookings.length === 1 ? Number(bookings[0]?.weight ?? '') : NaN;
|
|
const netWeightKg: number | '' = Number.isFinite(bookingWeight) && bookingWeight > 0 ? bookingWeight : '';
|
|
const edrDigitalBookingId =
|
|
bookings.length === 1
|
|
? bookings[0]?.reference ?? bookings[0]?.id ?? ''
|
|
: commonNonEmptyValue(bookings.map((booking) => booking.reference));
|
|
const unitCount =
|
|
bookings.length === 1 && bookings[0]?.containerQuantity != null
|
|
? Number(bookings[0].containerQuantity)
|
|
: '';
|
|
const freightTypes = [...new Set(bookings.map((booking) => booking.freightType).filter(Boolean))];
|
|
const packagingFreightType =
|
|
freightTypes.length === 1 && freightTypes[0] === 'CONTAINER'
|
|
? 'CONTAINER'
|
|
: freightTypes.length === 1 && freightTypes[0] === 'BULK'
|
|
? 'BULK'
|
|
: 'MIXED';
|
|
|
|
return {
|
|
form: {
|
|
...emptyTruckEntrance(),
|
|
ownerName,
|
|
consigneeDetails,
|
|
tin,
|
|
customerPhone,
|
|
edrDigitalBookingId,
|
|
assignedEquipmentNumber,
|
|
customsSealNumber,
|
|
itemDescription,
|
|
packagingType,
|
|
unitCount,
|
|
netWeightKg,
|
|
grossWeightKg: '',
|
|
truckPlateNumber,
|
|
trailerPlateNumber,
|
|
driverName,
|
|
driverPhone,
|
|
driverLicenseNumber,
|
|
truckType,
|
|
driverSignatoryName: driverName,
|
|
},
|
|
lockedFields: {
|
|
ownerName: Boolean(ownerName),
|
|
consigneeDetails: Boolean(consigneeDetails),
|
|
tin: Boolean(tin),
|
|
edrDigitalBookingId: Boolean(edrDigitalBookingId),
|
|
customerPhone: Boolean(customerPhone),
|
|
assignedEquipmentNumber: Boolean(assignedEquipmentNumber),
|
|
itemDescription: Boolean(itemDescription),
|
|
packagingType: Boolean(packagingType),
|
|
unitCount: unitCount !== '',
|
|
grossWeightKg: false,
|
|
truckPlateNumber: Boolean(truckPlateNumber),
|
|
trailerPlateNumber: Boolean(trailerPlateNumber),
|
|
driverName: Boolean(driverName),
|
|
driverPhone: Boolean(driverPhone),
|
|
driverLicenseNumber: Boolean(driverLicenseNumber),
|
|
truckType: Boolean(truckType),
|
|
},
|
|
packagingFreightType,
|
|
};
|
|
};
|
|
|
|
const BULK_PACKAGING_TYPE_OPTIONS = [
|
|
{ value: 'BAG', label: 'Bag' },
|
|
{ value: 'SACK', label: 'Sack' },
|
|
{ value: 'BALE', label: 'Bale' },
|
|
{ value: 'CARTON', label: 'Carton' },
|
|
{ value: 'CRATE', label: 'Crate' },
|
|
{ value: 'DRUM', label: 'Drum' },
|
|
{ value: 'BARREL', label: 'Barrel' },
|
|
{ value: 'PALLET', label: 'Pallet' },
|
|
{ value: 'LOOSE_BULK', label: 'Loose bulk' },
|
|
{ value: 'OTHER', label: 'Other' },
|
|
];
|
|
|
|
const CONTAINER_PACKAGING_TYPE_OPTIONS = [
|
|
{ value: 'CONTAINER_20FT', label: '20 ft container' },
|
|
{ value: 'CONTAINER_40FT', label: '40 ft container' },
|
|
{ value: 'CONTAINER_45FT', label: '45 ft container' },
|
|
{ value: 'REEFER_CONTAINER', label: 'Reefer container' },
|
|
{ value: 'TANK_CONTAINER', label: 'Tank container' },
|
|
{ value: 'FLAT_RACK_CONTAINER', label: 'Flat rack container' },
|
|
{ value: 'OPEN_TOP_CONTAINER', label: 'Open top container' },
|
|
{ value: 'OTHER_CONTAINER', label: 'Other container' },
|
|
];
|
|
|
|
const packagingOptionsFor = (freightType: PackagingFreightType) =>
|
|
freightType === 'CONTAINER'
|
|
? CONTAINER_PACKAGING_TYPE_OPTIONS
|
|
: freightType === 'BULK'
|
|
? BULK_PACKAGING_TYPE_OPTIONS
|
|
: [...CONTAINER_PACKAGING_TYPE_OPTIONS, ...BULK_PACKAGING_TYPE_OPTIONS];
|
|
|
|
function TruckEntranceFields({
|
|
value,
|
|
onChange,
|
|
lockedFields,
|
|
packagingFreightType = 'MIXED',
|
|
allowTruckWeighing = true,
|
|
}: {
|
|
value: TruckEntranceFormState;
|
|
onChange: (next: TruckEntranceFormState) => void;
|
|
lockedFields?: LockedTruckEntranceFields;
|
|
packagingFreightType?: PackagingFreightType;
|
|
allowTruckWeighing?: boolean;
|
|
}) {
|
|
const packagingOptions = packagingOptionsFor(packagingFreightType);
|
|
const quantityLabel =
|
|
packagingFreightType === 'CONTAINER'
|
|
? 'Container quantity'
|
|
: packagingFreightType === 'BULK'
|
|
? 'Unit count'
|
|
: 'Quantity';
|
|
|
|
return (
|
|
<Stack gap="sm">
|
|
<Text size="sm" fw={600}>Customer and cargo ownership</Text>
|
|
<Group grow>
|
|
<TextInput
|
|
label="Owner's name"
|
|
value={value.ownerName}
|
|
readOnly={lockedFields?.ownerName}
|
|
onChange={(e) => onChange({ ...value, ownerName: e.currentTarget.value })}
|
|
/>
|
|
<TextInput
|
|
label="Consignee details"
|
|
value={value.consigneeDetails}
|
|
readOnly={lockedFields?.consigneeDetails}
|
|
onChange={(e) => onChange({ ...value, consigneeDetails: e.currentTarget.value })}
|
|
/>
|
|
</Group>
|
|
<Group grow>
|
|
<TextInput
|
|
label="EDR digital booking ID"
|
|
value={value.edrDigitalBookingId}
|
|
readOnly={lockedFields?.edrDigitalBookingId}
|
|
onChange={(e) => onChange({ ...value, edrDigitalBookingId: e.currentTarget.value })}
|
|
/>
|
|
<TextInput
|
|
label="TIN"
|
|
value={value.tin}
|
|
readOnly={lockedFields?.tin}
|
|
onChange={(e) => onChange({ ...value, tin: e.currentTarget.value })}
|
|
/>
|
|
</Group>
|
|
<TextInput
|
|
label="Customer phone"
|
|
value={value.customerPhone}
|
|
readOnly={lockedFields?.customerPhone}
|
|
onChange={(e) => onChange({ ...value, customerPhone: e.currentTarget.value })}
|
|
/>
|
|
|
|
<Text size="sm" fw={600} mt="xs">Transport and equipment tracking</Text>
|
|
<Group grow>
|
|
<TextInput
|
|
label="Truck plate number"
|
|
required
|
|
value={value.truckPlateNumber}
|
|
readOnly={lockedFields?.truckPlateNumber}
|
|
onChange={(e) => onChange({ ...value, truckPlateNumber: e.currentTarget.value })}
|
|
/>
|
|
<TextInput
|
|
label="Trailer plate number"
|
|
value={value.trailerPlateNumber}
|
|
readOnly={lockedFields?.trailerPlateNumber}
|
|
onChange={(e) => onChange({ ...value, trailerPlateNumber: e.currentTarget.value })}
|
|
/>
|
|
</Group>
|
|
<Group grow>
|
|
<TextInput
|
|
label="Assigned wagon / container number"
|
|
value={value.assignedEquipmentNumber}
|
|
readOnly={lockedFields?.assignedEquipmentNumber}
|
|
onChange={(e) => onChange({ ...value, assignedEquipmentNumber: e.currentTarget.value })}
|
|
/>
|
|
<TextInput
|
|
label="Customs seal number"
|
|
value={value.customsSealNumber}
|
|
onChange={(e) => onChange({ ...value, customsSealNumber: e.currentTarget.value })}
|
|
/>
|
|
</Group>
|
|
<Group grow>
|
|
<TextInput
|
|
label="Driver name"
|
|
required
|
|
value={value.driverName}
|
|
readOnly={lockedFields?.driverName}
|
|
onChange={(e) => onChange({ ...value, driverName: e.currentTarget.value })}
|
|
/>
|
|
<TextInput
|
|
label="Driver phone"
|
|
required
|
|
value={value.driverPhone}
|
|
readOnly={lockedFields?.driverPhone}
|
|
onChange={(e) => onChange({ ...value, driverPhone: e.currentTarget.value })}
|
|
/>
|
|
</Group>
|
|
<Group grow>
|
|
<TextInput
|
|
label="Driver license number"
|
|
value={value.driverLicenseNumber}
|
|
readOnly={lockedFields?.driverLicenseNumber}
|
|
onChange={(e) => onChange({ ...value, driverLicenseNumber: e.currentTarget.value })}
|
|
/>
|
|
<TextInput
|
|
label="Truck type"
|
|
value={value.truckType}
|
|
readOnly={lockedFields?.truckType}
|
|
onChange={(e) => onChange({ ...value, truckType: e.currentTarget.value })}
|
|
/>
|
|
</Group>
|
|
{allowTruckWeighing ? (
|
|
<>
|
|
<Select
|
|
label="Weighing"
|
|
required
|
|
data={[
|
|
{ value: 'YES', label: 'Yes' },
|
|
{ value: 'NO', label: 'No' },
|
|
]}
|
|
value={value.weighingRequired == null ? null : value.weighingRequired ? 'YES' : 'NO'}
|
|
onChange={(next) =>
|
|
onChange({
|
|
...value,
|
|
weighingRequired: next === 'YES' ? true : next === 'NO' ? false : null,
|
|
grossWeightKg: next === 'YES' ? value.grossWeightKg : '',
|
|
exitTareWeightKg: next === 'YES' ? value.exitTareWeightKg : '',
|
|
})
|
|
}
|
|
/>
|
|
{value.weighingRequired && (
|
|
<Group grow>
|
|
<NumberInput
|
|
label="Gross weight (t)"
|
|
required
|
|
min={0}
|
|
value={value.grossWeightKg}
|
|
onChange={(v) => onChange({ ...value, grossWeightKg: v === '' ? '' : Number(v) })}
|
|
/>
|
|
<NumberInput
|
|
label="Exit tare weight (t)"
|
|
required
|
|
min={0}
|
|
value={value.exitTareWeightKg}
|
|
onChange={(v) => onChange({ ...value, exitTareWeightKg: v === '' ? '' : Number(v) })}
|
|
/>
|
|
</Group>
|
|
)}
|
|
</>
|
|
) : (
|
|
<Alert icon={<Info size={16} />} color="green" variant="light">
|
|
<Text size="sm">
|
|
Truck weighing is not required for a received first-mile arrival. The GRN uses the booking weight.
|
|
</Text>
|
|
</Alert>
|
|
)}
|
|
|
|
<Text size="sm" fw={600} mt="xs">Customs and compliance</Text>
|
|
<TextInput
|
|
label="Declaration / Bill of Entry number"
|
|
value={value.declarationNumber}
|
|
onChange={(e) => onChange({ ...value, declarationNumber: e.currentTarget.value })}
|
|
/>
|
|
|
|
<Text size="sm" fw={600} mt="xs">Physical cargo specifications</Text>
|
|
<TextInput
|
|
label="Item description"
|
|
value={value.itemDescription}
|
|
readOnly={lockedFields?.itemDescription}
|
|
onChange={(e) => onChange({ ...value, itemDescription: e.currentTarget.value })}
|
|
/>
|
|
<Group grow>
|
|
<Select
|
|
label="Packaging type"
|
|
data={packagingOptions}
|
|
clearable
|
|
searchable
|
|
readOnly={lockedFields?.packagingType}
|
|
value={value.packagingType}
|
|
onChange={(v) => onChange({ ...value, packagingType: v ?? '' })}
|
|
/>
|
|
<NumberInput
|
|
label={quantityLabel}
|
|
min={0}
|
|
value={value.unitCount}
|
|
readOnly={lockedFields?.unitCount}
|
|
onChange={(v) => onChange({ ...value, unitCount: v === '' ? '' : Number(v) })}
|
|
/>
|
|
</Group>
|
|
<NumberInput
|
|
label="Net weight (t)"
|
|
min={0}
|
|
value={value.netWeightKg}
|
|
onChange={(v) => onChange({ ...value, netWeightKg: v === '' ? '' : Number(v) })}
|
|
/>
|
|
<TextInput
|
|
label="Volume / dimensions"
|
|
value={value.volumeDimensions}
|
|
onChange={(e) => onChange({ ...value, volumeDimensions: e.currentTarget.value })}
|
|
/>
|
|
|
|
<Text size="sm" fw={600} mt="xs">Quality and inspection</Text>
|
|
<Group grow>
|
|
<TextInput
|
|
label="Condition at receipt"
|
|
value={value.conditionAtReceipt}
|
|
onChange={(e) => onChange({ ...value, conditionAtReceipt: e.currentTarget.value })}
|
|
/>
|
|
<NumberInput
|
|
label="Damaged / rejected quantity"
|
|
min={0}
|
|
value={value.damagedRejectedQuantity}
|
|
onChange={(v) => onChange({ ...value, damagedRejectedQuantity: v === '' ? '' : Number(v) })}
|
|
/>
|
|
</Group>
|
|
<Group grow>
|
|
<TextInput
|
|
label="Driver signatory"
|
|
value={value.driverSignatoryName}
|
|
onChange={(e) => onChange({ ...value, driverSignatoryName: e.currentTarget.value })}
|
|
/>
|
|
<TextInput
|
|
label="EDR warehouse manager"
|
|
value={value.warehouseManagerName}
|
|
onChange={(e) => onChange({ ...value, warehouseManagerName: e.currentTarget.value })}
|
|
/>
|
|
</Group>
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
/** Cascading Warehouse → Yard → Zone selectors (ACTIVE only). */
|
|
function LocationSelects({
|
|
value,
|
|
onChange,
|
|
allowedYardTypes,
|
|
allowedZoneTypes,
|
|
}: {
|
|
value: Location;
|
|
onChange: (next: Location) => void;
|
|
/** When non-empty, only yards of these types are offered (matched to freight). */
|
|
allowedYardTypes?: string[];
|
|
/** When non-empty, only zones of these types are offered. */
|
|
allowedZoneTypes?: string[];
|
|
}) {
|
|
const warehousesQuery = useQuery(
|
|
api.warehouses.list.queryOptions({ input: { filter: { status: 'ACTIVE' } } }),
|
|
);
|
|
const yardsQuery = useQuery(
|
|
api.warehouses.listYards.queryOptions({
|
|
input: { warehouseId: value.warehouseId ?? '' },
|
|
enabled: Boolean(value.warehouseId),
|
|
}),
|
|
);
|
|
const zonesQuery = useQuery(
|
|
api.warehouses.listZones.queryOptions({
|
|
input: { yardId: value.yardId ?? '' },
|
|
enabled: Boolean(value.yardId),
|
|
}),
|
|
);
|
|
|
|
const warehouseOptions = useMemo(
|
|
() => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })),
|
|
[warehousesQuery.data],
|
|
);
|
|
const yardOptions = useMemo(
|
|
() =>
|
|
(yardsQuery.data ?? [])
|
|
.filter((y) => y.status === 'ACTIVE')
|
|
.filter((y) => !allowedYardTypes?.length || allowedYardTypes.includes((y as { type?: string }).type ?? ''))
|
|
.map((y) => ({ value: y.id, label: `${y.name} (${y.code})` })),
|
|
[yardsQuery.data, allowedYardTypes],
|
|
);
|
|
const zoneOptions = useMemo(
|
|
() =>
|
|
(zonesQuery.data ?? [])
|
|
.filter((z) => z.status === 'ACTIVE')
|
|
.filter((z) => !allowedZoneTypes?.length || allowedZoneTypes.includes((z as { type?: string }).type ?? ''))
|
|
.map((z) => ({ value: z.id, label: `${z.name} (${z.code})` })),
|
|
[zonesQuery.data, allowedZoneTypes],
|
|
);
|
|
|
|
return (
|
|
<Group grow align="flex-end">
|
|
<Select
|
|
label="Warehouse"
|
|
placeholder={warehousesQuery.isLoading ? 'Loading…' : 'Select warehouse'}
|
|
required
|
|
searchable
|
|
data={warehouseOptions}
|
|
value={value.warehouseId || null}
|
|
onChange={(v) => onChange({ warehouseId: v ?? '', yardId: '', zoneId: '' })}
|
|
/>
|
|
<Select
|
|
label="Yard"
|
|
placeholder={!value.warehouseId ? 'Select warehouse first' : 'Select yard'}
|
|
required
|
|
searchable
|
|
disabled={!value.warehouseId}
|
|
data={yardOptions}
|
|
value={value.yardId || null}
|
|
onChange={(v) => onChange({ ...value, yardId: v ?? '', zoneId: '' })}
|
|
/>
|
|
<Select
|
|
label="Zone"
|
|
placeholder={!value.yardId ? 'Select yard first' : 'Select zone'}
|
|
required
|
|
searchable
|
|
disabled={!value.yardId}
|
|
data={zoneOptions}
|
|
value={value.zoneId || null}
|
|
onChange={(v) => onChange({ ...value, zoneId: v ?? '' })}
|
|
/>
|
|
</Group>
|
|
);
|
|
}
|
|
|
|
/** One tab: eligible PAID bookings for a direction, with bulk receive (+ export load). */
|
|
function EligibleTab({
|
|
direction,
|
|
location,
|
|
enabled,
|
|
onChanged,
|
|
focusedBookingId,
|
|
focusedBookingLabel,
|
|
}: {
|
|
direction: 'IMPORT' | 'EXPORT';
|
|
location: Location;
|
|
enabled: boolean;
|
|
onChanged?: () => void;
|
|
focusedBookingId?: string;
|
|
focusedBookingLabel?: string;
|
|
}) {
|
|
const { toast } = useToast();
|
|
const qc = useQueryClient();
|
|
const { data: allRows = [], isLoading } = useQuery(
|
|
api.warehouses.eligibleBookings.queryOptions({
|
|
input: { direction },
|
|
enabled,
|
|
}),
|
|
);
|
|
const rows = useMemo(
|
|
() =>
|
|
allRows.filter(
|
|
(r) =>
|
|
r.direction === direction &&
|
|
(!focusedBookingId || r.id === focusedBookingId),
|
|
),
|
|
[allRows, direction, focusedBookingId],
|
|
);
|
|
const bulkReceive = useMutation(api.warehouses.bulkReceive.mutationOptions());
|
|
const requestFirstMile = useMutation({
|
|
mutationFn: (reference: string) => firstMileService.accept(reference),
|
|
onSuccess: () => {
|
|
void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
|
|
void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.ROOT });
|
|
toast({ title: 'First mile requested', description: 'Booking was added to the existing First Mile workflow.' });
|
|
},
|
|
onError: (error) => {
|
|
toast({ variant: 'destructive', title: 'First mile request failed', description: extractErrorMessage(error) });
|
|
},
|
|
});
|
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
const [statusTab, setStatusTab] = useState('ALL');
|
|
const [truckOpen, setTruckOpen] = useState(false);
|
|
const [pendingReceiveIds, setPendingReceiveIds] = useState<string[]>([]);
|
|
const [receivedAt, setReceivedAt] = useState<string | null>(null);
|
|
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) =>
|
|
!(direction === 'EXPORT' && row.hasFirstMile && row.firstMileStatus !== 'RECEIVED_TO_PORT');
|
|
const statusOptions = useMemo(() => {
|
|
const base = [{ value: 'ALL', label: 'All bookings' }];
|
|
if (direction === 'EXPORT') {
|
|
return [
|
|
...base,
|
|
{ value: 'DIRECT', label: 'Direct truck' },
|
|
{ value: 'FIRST_MILE', label: 'First mile' },
|
|
{ value: 'FIRST_MILE_READY', label: 'First mile arrived' },
|
|
{ value: 'AWAITING_FIRST_MILE', label: 'Awaiting first mile' },
|
|
];
|
|
}
|
|
return [
|
|
...base,
|
|
{ value: 'READY_TO_RECEIVE', label: 'Ready to receive' },
|
|
{ value: 'PAID', label: 'Paid' },
|
|
];
|
|
}, [direction]);
|
|
const statusFilteredRows = useMemo(
|
|
() =>
|
|
rows.filter((row) => {
|
|
switch (statusTab) {
|
|
case 'DIRECT':
|
|
return !row.hasFirstMile;
|
|
case 'FIRST_MILE':
|
|
return row.hasFirstMile;
|
|
case 'FIRST_MILE_READY':
|
|
return row.hasFirstMile && row.firstMileStatus === 'RECEIVED_TO_PORT';
|
|
case 'AWAITING_FIRST_MILE':
|
|
return row.hasFirstMile && row.firstMileStatus !== 'RECEIVED_TO_PORT';
|
|
case 'READY_TO_RECEIVE':
|
|
return canReceiveBooking(row);
|
|
case 'PAID':
|
|
return row.paymentStatus === 'PAID';
|
|
default:
|
|
return true;
|
|
}
|
|
}),
|
|
[rows, statusTab],
|
|
);
|
|
const statusCounts = useMemo(
|
|
() =>
|
|
Object.fromEntries(
|
|
statusOptions.map((option) => [
|
|
option.value,
|
|
rows.filter((row) => {
|
|
switch (option.value) {
|
|
case 'DIRECT':
|
|
return !row.hasFirstMile;
|
|
case 'FIRST_MILE':
|
|
return row.hasFirstMile;
|
|
case 'FIRST_MILE_READY':
|
|
return row.hasFirstMile && row.firstMileStatus === 'RECEIVED_TO_PORT';
|
|
case 'AWAITING_FIRST_MILE':
|
|
return row.hasFirstMile && row.firstMileStatus !== 'RECEIVED_TO_PORT';
|
|
case 'READY_TO_RECEIVE':
|
|
return canReceiveBooking(row);
|
|
case 'PAID':
|
|
return row.paymentStatus === 'PAID';
|
|
default:
|
|
return true;
|
|
}
|
|
}).length,
|
|
]),
|
|
),
|
|
[rows, statusOptions],
|
|
);
|
|
const controls = useListControls(statusFilteredRows, {
|
|
searchKeys: ['reference', 'customer', 'origin', 'destination', 'containerNumber', 'cargo', 'cargoDescription'],
|
|
});
|
|
const selectableRows = controls.filteredRows.filter(canReceiveBooking);
|
|
const allSelected = selectableRows.length > 0 && selected.size === selectableRows.length;
|
|
const someSelected = selected.size > 0 && !allSelected;
|
|
const pendingReceiveRows = useMemo(
|
|
() =>
|
|
pendingReceiveIds
|
|
.map((id) => rows.find((item) => item.id === id))
|
|
.filter(Boolean) as EligibleBooking[],
|
|
[pendingReceiveIds, rows],
|
|
);
|
|
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)));
|
|
const toggleOne = (id: string) =>
|
|
setSelected((prev) => {
|
|
const next = new Set(prev);
|
|
next.has(id) ? next.delete(id) : next.add(id);
|
|
return next;
|
|
});
|
|
|
|
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: 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 (documentBookingId && firstGrn?.inventoryId && firstGrn.grnNumber) {
|
|
try {
|
|
const response = await warehouseService.downloadGrnDocument(firstGrn.inventoryId);
|
|
const opened = openPdfBlob(response.data, `grn-${firstGrn.grnNumber}.pdf`, grnWindow);
|
|
toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' });
|
|
} catch (error) {
|
|
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);
|
|
setPendingReceiveIds([]);
|
|
setReceivedAt(null);
|
|
setLockedTruckFields({});
|
|
setPackagingFreightType('MIXED');
|
|
setSelectedCustomerTruckId(null);
|
|
setSelectedContainerNumbers([]);
|
|
onChanged?.();
|
|
} catch (error) {
|
|
grnWindow?.close();
|
|
acceptanceWindow?.close();
|
|
toast({ variant: 'destructive', title: 'Receive failed', description: extractErrorMessage(error) });
|
|
}
|
|
};
|
|
|
|
const openTruckReceive = (bookingIds: string[]) => {
|
|
if (!locationReady) {
|
|
toast({ variant: 'destructive', title: 'Select warehouse, yard and zone first' });
|
|
return;
|
|
}
|
|
if (bookingIds.length === 0) {
|
|
toast({ variant: 'destructive', title: 'Select at least one booking' });
|
|
return;
|
|
}
|
|
const allowedIds = new Set(selectableRows.map((row) => row.id));
|
|
const filteredIds = bookingIds.filter((id) => allowedIds.has(id));
|
|
if (filteredIds.length === 0) {
|
|
toast({ variant: 'destructive', title: 'No selected booking is ready to receive' });
|
|
return;
|
|
}
|
|
const selectedRows = filteredIds
|
|
.map((id) => rows.find((item) => item.id === id))
|
|
.filter(Boolean) as EligibleBooking[];
|
|
if (direction === 'IMPORT') {
|
|
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) {
|
|
toast({
|
|
variant: 'destructive',
|
|
title: 'Receive separately',
|
|
description: 'First-mile arrivals and customer-truck arrivals use different truck evidence. Select one group at a time.',
|
|
});
|
|
return;
|
|
}
|
|
const { form, lockedFields, packagingFreightType: nextPackagingFreightType } = truckEntranceFromBookings(selectedRows);
|
|
const totalContainerQuantity = selectedRows.reduce(
|
|
(sum, row) => sum + Number(row.containerQuantity ?? 0),
|
|
0,
|
|
);
|
|
const usesFirstMile = selectedRows.length > 0 && selectedRows.every((row) => row.hasFirstMile);
|
|
const usesCustomerAssignedTruck =
|
|
selectedRows.length > 0 &&
|
|
selectedRows.every((row) => !row.hasFirstMile && Boolean(row.customerTruckAssignedAt));
|
|
const normalizedForm =
|
|
nextPackagingFreightType === 'CONTAINER' && totalContainerQuantity > 0
|
|
? {
|
|
...form,
|
|
unitCount: totalContainerQuantity,
|
|
}
|
|
: {
|
|
...form,
|
|
};
|
|
setPendingReceiveIds(filteredIds);
|
|
setSelectedCustomerTruckId(null);
|
|
setSelectedContainerNumbers([]);
|
|
setReceivedAt(new Date().toISOString());
|
|
setTruckForm(normalizedForm);
|
|
setLockedTruckFields({
|
|
...lockedFields,
|
|
unitCount: nextPackagingFreightType === 'CONTAINER' && totalContainerQuantity > 0,
|
|
assignedEquipmentNumber: usesCustomerAssignedTruck
|
|
? lockedFields.assignedEquipmentNumber
|
|
: lockedFields.assignedEquipmentNumber,
|
|
truckPlateNumber: (usesFirstMile || usesCustomerAssignedTruck) && lockedFields.truckPlateNumber,
|
|
trailerPlateNumber: usesFirstMile && lockedFields.trailerPlateNumber,
|
|
driverName: (usesFirstMile || usesCustomerAssignedTruck) && lockedFields.driverName,
|
|
driverPhone: usesFirstMile && lockedFields.driverPhone,
|
|
driverLicenseNumber: usesFirstMile && lockedFields.driverLicenseNumber,
|
|
truckType: (usesFirstMile || usesCustomerAssignedTruck) && lockedFields.truckType,
|
|
});
|
|
setPackagingFreightType(nextPackagingFreightType);
|
|
setTruckOpen(true);
|
|
};
|
|
|
|
const receive = async () => {
|
|
if (!truckForm.truckPlateNumber.trim() || !truckForm.driverName.trim() || !truckForm.driverPhone.trim()) {
|
|
toast({ variant: 'destructive', title: 'Truck and driver information are required' });
|
|
return;
|
|
}
|
|
if (!pendingUsesFirstMile && truckForm.weighingRequired == null) {
|
|
toast({ variant: 'destructive', title: 'Select whether customer truck weighing is required' });
|
|
return;
|
|
}
|
|
if (truckForm.weighingRequired && (truckForm.grossWeightKg === '' || truckForm.exitTareWeightKg === '')) {
|
|
toast({ variant: 'destructive', title: 'Gross weight and exit tare weight are required when weighing is Yes' });
|
|
return;
|
|
}
|
|
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,
|
|
}));
|
|
};
|
|
|
|
|
|
return (
|
|
<Stack gap="sm" mt="sm">
|
|
<Tabs value={statusTab} onChange={(v) => setStatusTab(v ?? 'ALL')}>
|
|
<Tabs.List>
|
|
{statusOptions.map((option) => (
|
|
<Tabs.Tab key={option.value} value={option.value}>
|
|
{option.label} ({statusCounts[option.value] ?? 0})
|
|
</Tabs.Tab>
|
|
))}
|
|
</Tabs.List>
|
|
</Tabs>
|
|
|
|
<Group justify="space-between">
|
|
<Text size="sm" c="dimmed">
|
|
Selected: <b>{selected.size}</b> / {controls.filteredRows.length} eligible
|
|
</Text>
|
|
<Group gap="xs">
|
|
<Button
|
|
size="compact-sm"
|
|
color={direction === 'EXPORT' ? 'edr-green' : undefined}
|
|
variant={direction === 'EXPORT' ? 'filled' : 'default'}
|
|
leftSection={direction === 'EXPORT' ? <Truck size={14} /> : undefined}
|
|
disabled={!locationReady || selectableRows.length === 0}
|
|
loading={bulkReceive.isPending}
|
|
onClick={() => openTruckReceive(selected.size > 0 ? [...selected] : selectableRows.map((r) => r.id))}
|
|
>
|
|
{direction === 'EXPORT' ? 'Receive All for Loading' : 'Receive All to Warehouse'}
|
|
</Button>
|
|
<Button
|
|
size="compact-sm"
|
|
variant="default"
|
|
disabled={!locationReady || selected.size === 0}
|
|
loading={bulkReceive.isPending}
|
|
onClick={() => openTruckReceive([...selected])}
|
|
>
|
|
{direction === 'EXPORT' ? 'Receive Selected for Loading' : 'Receive Selected'}
|
|
</Button>
|
|
</Group>
|
|
</Group>
|
|
|
|
{!locationReady && (
|
|
<Alert icon={<Info size={16} />} color="orange" variant="light">
|
|
<Text size="sm">Select a warehouse, yard and zone above before receiving.</Text>
|
|
</Alert>
|
|
)}
|
|
|
|
{isLoading ? (
|
|
<Group justify="center" py="lg">
|
|
<Loader size="sm" />
|
|
</Group>
|
|
) : statusFilteredRows.length === 0 ? (
|
|
<Text c="dimmed" ta="center" py="lg" size="sm">
|
|
{focusedBookingLabel
|
|
? `${focusedBookingLabel} is not eligible for warehouse receiving yet.`
|
|
: `No eligible PAID ${direction.toLowerCase()} bookings to receive.`}
|
|
</Text>
|
|
) : (
|
|
<Stack gap="sm">
|
|
<ListControls
|
|
search={controls.search}
|
|
onSearchChange={controls.setSearch}
|
|
searchPlaceholder="Booking, customer, route, container, cargo…"
|
|
dateFrom={controls.dateFrom}
|
|
onDateFromChange={controls.setDateFrom}
|
|
dateTo={controls.dateTo}
|
|
onDateToChange={controls.setDateTo}
|
|
hasFilters={controls.hasFilters}
|
|
onReset={controls.reset}
|
|
showDateRange={false}
|
|
/>
|
|
<Table.ScrollContainer minWidth={1350}>
|
|
<Table highlightOnHover verticalSpacing="xs" striped>
|
|
<Table.Thead>
|
|
<Table.Tr>
|
|
<Table.Th w={40}>
|
|
<Checkbox
|
|
aria-label="Select all"
|
|
checked={allSelected}
|
|
indeterminate={someSelected}
|
|
onChange={toggleAll}
|
|
/>
|
|
</Table.Th>
|
|
<Table.Th>Booking Ref</Table.Th>
|
|
<Table.Th>Customer Name</Table.Th>
|
|
<Table.Th>Origin</Table.Th>
|
|
<Table.Th>Destination</Table.Th>
|
|
<Table.Th>Route</Table.Th>
|
|
<Table.Th>Container / Cargo Items</Table.Th>
|
|
<Table.Th>Cargo Type</Table.Th>
|
|
<Table.Th>Weight</Table.Th>
|
|
<Table.Th>Payment</Table.Th>
|
|
<Table.Th>Current Status</Table.Th>
|
|
<Table.Th>Inspection</Table.Th>
|
|
{direction === 'EXPORT' && <Table.Th>First Mile</Table.Th>}
|
|
<Table.Th ta="right">Actions</Table.Th>
|
|
</Table.Tr>
|
|
</Table.Thead>
|
|
<Table.Tbody>
|
|
{controls.pagedRows.map((r) => {
|
|
const canReceive = canReceiveBooking(r);
|
|
return (
|
|
<Table.Tr key={r.id}>
|
|
<Table.Td>
|
|
<Checkbox
|
|
aria-label={`Select ${r.reference}`}
|
|
checked={selected.has(r.id)}
|
|
disabled={!canReceive}
|
|
onChange={() => toggleOne(r.id)}
|
|
/>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Text size="sm" fw={600}>
|
|
{r.reference}
|
|
</Text>
|
|
</Table.Td>
|
|
<Table.Td>{r.customer ?? '—'}</Table.Td>
|
|
<Table.Td>{r.origin ?? '—'}</Table.Td>
|
|
<Table.Td>{r.destination ?? '—'}</Table.Td>
|
|
<Table.Td>
|
|
{r.origin || r.destination ? `${r.origin ?? '?'} → ${r.destination ?? '?'}` : '—'}
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Stack gap={2}>
|
|
<Text size="sm">{r.containerNumber ?? r.cargoDescription ?? r.cargo ?? '—'}</Text>
|
|
<Text size="xs" c="dimmed">
|
|
{[
|
|
r.containerQuantity != null ? `${r.containerQuantity} unit(s)` : null,
|
|
r.containerPackagingType,
|
|
].filter(Boolean).join(' / ') || 'Item details from booking'}
|
|
</Text>
|
|
</Stack>
|
|
</Table.Td>
|
|
<Table.Td>{r.cargo ?? '—'}</Table.Td>
|
|
<Table.Td>{formatNumber(Number(r.weight))}</Table.Td>
|
|
<Table.Td>
|
|
<Badge color="green" variant="light" size="sm">
|
|
{r.paymentStatus}
|
|
</Badge>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Badge color="gray" variant="light" size="sm">
|
|
{r.status ?? '—'}
|
|
</Badge>
|
|
</Table.Td>
|
|
<Table.Td>—</Table.Td>
|
|
{direction === 'EXPORT' && (
|
|
<Table.Td>
|
|
{r.hasFirstMile ? (
|
|
<Stack gap={2}>
|
|
<Badge
|
|
color={r.firstMileStatus === 'RECEIVED_TO_PORT' ? 'green' : r.firstMileRequestId ? 'blue' : 'orange'}
|
|
variant="light"
|
|
size="sm"
|
|
>
|
|
{r.firstMileStatus ?? 'Request needed'}
|
|
</Badge>
|
|
<Text size="xs" c="dimmed">
|
|
{[r.firstMileTruckPlateNumber, r.firstMileTrailerPlateNumber].filter(Boolean).join(' / ') || 'Truck not assigned'}
|
|
</Text>
|
|
</Stack>
|
|
) : (
|
|
<Badge color="gray" variant="light" size="sm">Direct arrival</Badge>
|
|
)}
|
|
</Table.Td>
|
|
)}
|
|
<Table.Td ta="right">
|
|
{direction === 'EXPORT' && r.hasFirstMile && !r.firstMileRequestId ? (
|
|
<Button
|
|
size="compact-xs"
|
|
variant="light"
|
|
color="orange"
|
|
loading={requestFirstMile.isPending}
|
|
onClick={() => requestFirstMile.mutate(r.reference)}
|
|
>
|
|
Request First Mile
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
size="compact-xs"
|
|
variant="light"
|
|
disabled={!locationReady || !canReceive}
|
|
loading={bulkReceive.isPending}
|
|
onClick={() => openTruckReceive([r.id])}
|
|
>
|
|
{canReceive ? (direction === 'EXPORT' ? 'Receive for Loading' : 'Receive to Warehouse') : 'Await First Mile'}
|
|
</Button>
|
|
)}
|
|
</Table.Td>
|
|
</Table.Tr>
|
|
);
|
|
})}
|
|
</Table.Tbody>
|
|
</Table>
|
|
</Table.ScrollContainer>
|
|
<RuleEngineListFooter
|
|
pagination={controls.pagination}
|
|
pageCount={controls.pageCount}
|
|
totalCount={controls.totalCount}
|
|
itemLabel="bookings"
|
|
onPaginationChange={controls.setPagination}
|
|
/>
|
|
</Stack>
|
|
)}
|
|
|
|
<Modal
|
|
opened={truckOpen}
|
|
onClose={() => setTruckOpen(false)}
|
|
title={direction === 'EXPORT' ? 'Receive for Loading' : 'Receive to Warehouse'}
|
|
centered
|
|
size="lg"
|
|
>
|
|
<Stack gap="md">
|
|
<Alert icon={<Truck size={16} />} color={pendingHasFirstMile ? 'green' : 'blue'} variant="light">
|
|
<Text size="sm">
|
|
{pendingUsesFirstMile
|
|
? 'Received first-mile truck and driver details are pulled from the first-mile record. GRN uses booking cargo, quantity and weight.'
|
|
: '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>
|
|
<Table.Tr>
|
|
<Table.Th>Booking</Table.Th>
|
|
<Table.Th>Customer</Table.Th>
|
|
<Table.Th>TIN / Phone</Table.Th>
|
|
<Table.Th>Container / Cargo</Table.Th>
|
|
<Table.Th>Qty / Package</Table.Th>
|
|
<Table.Th>Weight</Table.Th>
|
|
<Table.Th>Received at</Table.Th>
|
|
</Table.Tr>
|
|
</Table.Thead>
|
|
<Table.Tbody>
|
|
{pendingReceiveRows.map((booking) => (
|
|
<Table.Tr key={booking.id}>
|
|
<Table.Td>
|
|
<Text size="sm" fw={600}>{booking.reference}</Text>
|
|
<Text size="xs" c="dimmed">{booking.id.slice(0, 8)}...</Text>
|
|
</Table.Td>
|
|
<Table.Td>{booking.customer ?? '-'}</Table.Td>
|
|
<Table.Td>
|
|
<Stack gap={0}>
|
|
<Text size="xs">{booking.customerTin ?? '-'}</Text>
|
|
<Text size="xs" c="dimmed">{booking.customerPhone ?? '-'}</Text>
|
|
</Stack>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Stack gap={0}>
|
|
<Text size="xs">{booking.containerNumber ?? booking.cargoDescription ?? booking.cargo ?? '-'}</Text>
|
|
<Text size="xs" c="dimmed">{booking.freightType ?? '-'}</Text>
|
|
</Stack>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
{[
|
|
booking.containerQuantity != null ? `${booking.containerQuantity} unit(s)` : null,
|
|
booking.containerPackagingType,
|
|
].filter(Boolean).join(' / ') || '-'}
|
|
</Table.Td>
|
|
<Table.Td>{formatNumber(Number(booking.weight))}</Table.Td>
|
|
<Table.Td>{formatDate(receivedAt)}</Table.Td>
|
|
</Table.Tr>
|
|
))}
|
|
</Table.Tbody>
|
|
</Table>
|
|
</Table.ScrollContainer>
|
|
<TruckEntranceFields
|
|
value={truckForm}
|
|
onChange={setTruckForm}
|
|
lockedFields={lockedTruckFields}
|
|
packagingFreightType={packagingFreightType}
|
|
allowTruckWeighing={!pendingUsesFirstMile}
|
|
/>
|
|
<Group justify="flex-end">
|
|
<Button variant="default" onClick={() => setTruckOpen(false)} disabled={bulkReceive.isPending}>
|
|
Cancel
|
|
</Button>
|
|
<Button leftSection={<Truck size={16} />} loading={bulkReceive.isPending} onClick={receive}>
|
|
{pendingContainerBooking
|
|
? 'Receive Selected Containers & Generate CAS + GRN'
|
|
: 'Register Arrival & Generate GRN'}
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Modal>
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
/** Export received items awaiting inspection before loading. */
|
|
function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged?: () => void }) {
|
|
const { toast } = useToast();
|
|
const { data: rows = [], isLoading } = useQuery(
|
|
api.warehouses.receivedExport.queryOptions({ enabled }),
|
|
);
|
|
const inspectMutation = useMutation(
|
|
api.warehouses.bulkMarkInspected.mutationOptions(),
|
|
);
|
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
|
|
const [expandedRow, setExpandedRow] = useState<string | null>(null);
|
|
const [inspectId, setInspectId] = useState<string | null>(null);
|
|
|
|
const controls = useListControls(rows, {
|
|
searchKeys: ['bookingReference', 'customerName', 'containerNumber', 'cargoType', 'grnNumber', 'origin', 'destination'],
|
|
});
|
|
const pendingRows = controls.filteredRows.filter((r) => r.inspectionStatus !== 'PASSED');
|
|
const allSelected = pendingRows.length > 0 && selected.size === pendingRows.length;
|
|
const someSelected = selected.size > 0 && !allSelected;
|
|
const toggleAll = () =>
|
|
setSelected(allSelected ? new Set() : new Set(pendingRows.map((r) => r.id)));
|
|
const toggleOne = (id: string) =>
|
|
setSelected((prev) => {
|
|
const next = new Set(prev);
|
|
next.has(id) ? next.delete(id) : next.add(id);
|
|
return next;
|
|
});
|
|
|
|
const markInspected = async () => {
|
|
if (selected.size === 0) {
|
|
toast({ variant: 'destructive', title: 'Select at least one item' });
|
|
return;
|
|
}
|
|
try {
|
|
const r = await inspectMutation.mutateAsync({ inventoryIds: [...selected] });
|
|
toast({
|
|
title: `${r.inspectedCount} marked inspected`,
|
|
description: skippedSummary(r.skippedCount, r.results),
|
|
});
|
|
setSelected(new Set());
|
|
onChanged?.();
|
|
} catch (error) {
|
|
toast({ variant: 'destructive', title: 'Mark inspected failed', description: extractErrorMessage(error) });
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Stack gap="sm" mt="sm">
|
|
<Group justify="space-between">
|
|
<Text size="sm" c="dimmed">
|
|
Selected: <b>{selected.size}</b> / {pendingRows.length} received
|
|
</Text>
|
|
<Button
|
|
size="compact-sm"
|
|
color="indigo"
|
|
leftSection={<ClipboardCheck size={14} />}
|
|
disabled={selected.size === 0}
|
|
loading={inspectMutation.isPending}
|
|
onClick={() =>
|
|
setConfirmAction({
|
|
title: 'Mark inspected',
|
|
message: `Mark ${selected.size} selected item(s) as inspection PASSED?`,
|
|
confirmLabel: `Mark ${selected.size} inspected`,
|
|
run: markInspected,
|
|
})
|
|
}
|
|
>
|
|
Mark Selected as Inspected
|
|
</Button>
|
|
</Group>
|
|
|
|
{isLoading ? (
|
|
<Group justify="center" py="lg">
|
|
<Loader size="sm" />
|
|
</Group>
|
|
) : rows.length === 0 ? (
|
|
<Text c="dimmed" ta="center" py="lg" size="sm">
|
|
No received export items awaiting inspection.
|
|
</Text>
|
|
) : (
|
|
<Stack gap="sm">
|
|
<ListControls
|
|
search={controls.search}
|
|
onSearchChange={controls.setSearch}
|
|
searchPlaceholder="Booking, GRN, customer, container, cargo, route…"
|
|
dateFrom={controls.dateFrom}
|
|
onDateFromChange={controls.setDateFrom}
|
|
dateTo={controls.dateTo}
|
|
onDateToChange={controls.setDateTo}
|
|
hasFilters={controls.hasFilters}
|
|
onReset={controls.reset}
|
|
showDateRange={false}
|
|
/>
|
|
<Table.ScrollContainer minWidth={1350}>
|
|
<Table highlightOnHover verticalSpacing="xs" striped>
|
|
<Table.Thead>
|
|
<Table.Tr>
|
|
<Table.Th w={34} />
|
|
<Table.Th w={40}>
|
|
<Checkbox
|
|
aria-label="Select all"
|
|
checked={allSelected}
|
|
indeterminate={someSelected}
|
|
onChange={toggleAll}
|
|
/>
|
|
</Table.Th>
|
|
<Table.Th>Booking Ref</Table.Th>
|
|
<Table.Th>GRN</Table.Th>
|
|
<Table.Th>Customer Name</Table.Th>
|
|
<Table.Th>Container / Cargo Items</Table.Th>
|
|
<Table.Th>Cargo Type</Table.Th>
|
|
<Table.Th>Weight</Table.Th>
|
|
<Table.Th>Route</Table.Th>
|
|
<Table.Th>Inspection</Table.Th>
|
|
<Table.Th>Status</Table.Th>
|
|
<Table.Th ta="right">Actions</Table.Th>
|
|
</Table.Tr>
|
|
</Table.Thead>
|
|
<Table.Tbody>
|
|
{controls.pagedRows.map((r: ReadyToLoadRow) => {
|
|
const selectable = r.inspectionStatus !== 'PASSED';
|
|
return (
|
|
<Fragment key={r.id}>
|
|
<Table.Tr>
|
|
<Table.Td>
|
|
<ActionIcon
|
|
variant="subtle"
|
|
color="gray"
|
|
aria-label="Show containers"
|
|
onClick={() => setExpandedRow(expandedRow === r.id ? null : r.id)}
|
|
>
|
|
{expandedRow === r.id ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
|
</ActionIcon>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Checkbox
|
|
aria-label={`Select ${r.bookingReference ?? r.id}`}
|
|
checked={selected.has(r.id)}
|
|
disabled={!selectable}
|
|
onChange={() => toggleOne(r.id)}
|
|
/>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
|
|
</Table.Td>
|
|
<Table.Td>{r.customerName ?? '—'}</Table.Td>
|
|
<Table.Td>{r.containerNumber ?? '—'}</Table.Td>
|
|
<Table.Td>{r.cargoType ?? '—'}</Table.Td>
|
|
<Table.Td>{formatNumber(Number(r.weight))}</Table.Td>
|
|
<Table.Td>
|
|
{r.origin || r.destination ? `${r.origin ?? '?'} → ${r.destination ?? '?'}` : '—'}
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Badge color={r.inspectionStatus === 'PASSED' ? 'green' : 'orange'} variant="light" size="sm">
|
|
{r.inspectionStatus ?? 'PENDING'}
|
|
</Badge>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<InventoryStatusBadge status={r.status as InventoryStatus} />
|
|
</Table.Td>
|
|
<Table.Td ta="right">
|
|
<Button size="compact-xs" variant="light" color="orange" onClick={() => setInspectId(r.id)}>
|
|
Inspection / Report
|
|
</Button>
|
|
</Table.Td>
|
|
</Table.Tr>
|
|
{expandedRow === r.id && (
|
|
<BookingItemsExpansion
|
|
bookingId={r.bookingId}
|
|
colSpan={18}
|
|
bulkFallback={r.cargoType ? `Bulk cargo — ${r.cargoType}, ${formatNumber(Number(r.weight))} t` : undefined}
|
|
/>
|
|
)}
|
|
</Fragment>
|
|
);
|
|
})}
|
|
</Table.Tbody>
|
|
</Table>
|
|
</Table.ScrollContainer>
|
|
<RuleEngineListFooter
|
|
pagination={controls.pagination}
|
|
pageCount={controls.pageCount}
|
|
totalCount={controls.totalCount}
|
|
itemLabel="items"
|
|
onPaginationChange={controls.setPagination}
|
|
/>
|
|
</Stack>
|
|
)}
|
|
|
|
<InspectionReportModal
|
|
inventoryId={inspectId}
|
|
opened={Boolean(inspectId)}
|
|
onClose={() => setInspectId(null)}
|
|
/>
|
|
<ConfirmActionModal action={confirmAction} onClose={() => setConfirmAction(null)} />
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
/** Export items that passed inspection and are queued to be loaded onto a train. */
|
|
function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: () => void }) {
|
|
const { toast } = useToast();
|
|
const { user } = useAuth();
|
|
const canLoad = hasFreightPermission(user, FREIGHT_PERMS.warehouseInventory.load);
|
|
const { data: rows = [], isLoading } = useQuery(
|
|
api.warehouses.readyToLoadExport.queryOptions({ enabled }),
|
|
);
|
|
const qc = useQueryClient();
|
|
// "Items" is the inventory list with the auto-load picker; "By train" mirrors
|
|
// the train schedule's per-booking Load / Wagons / Unload workspace here.
|
|
const [view, setView] = useState<'items' | 'train'>('items');
|
|
const [trainPickerOpen, setTrainPickerOpen] = useState(false);
|
|
const [expandedRow, setExpandedRow] = useState<string | null>(null);
|
|
const [targetScheduleId, setTargetScheduleId] = useState<string | null>(null);
|
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
|
|
const controls = useListControls(rows, {
|
|
searchKeys: ['bookingReference', 'customerName', 'containerNumber', 'cargoType', 'grnNumber', 'origin', 'destination'],
|
|
});
|
|
const allSelected = controls.filteredRows.length > 0 && selected.size === controls.filteredRows.length;
|
|
const someSelected = selected.size > 0 && !allSelected;
|
|
const toggleAll = () => setSelected(allSelected ? new Set() : new Set(controls.filteredRows.map((r) => r.id)));
|
|
const toggleOne = (id: string) =>
|
|
setSelected((prev) => {
|
|
const next = new Set(prev);
|
|
next.has(id) ? next.delete(id) : next.add(id);
|
|
return next;
|
|
});
|
|
|
|
// Loading is always onto a SPECIFIC pre-dispatch train. No train -> no auto-load.
|
|
const { data: trains = [], isLoading: trainsLoading } = useQuery({
|
|
queryKey: ['warehouse-inventory', 'loadable-trains'],
|
|
queryFn: () => warehouseService.getLoadableTrains(),
|
|
enabled: enabled && trainPickerOpen,
|
|
});
|
|
const { data: pickerItems = [] } = useQuery({
|
|
queryKey: ['train-loadable-items', targetScheduleId],
|
|
queryFn: () => warehouseService.getTrainLoadableItems(targetScheduleId!),
|
|
enabled: Boolean(targetScheduleId) && trainPickerOpen,
|
|
});
|
|
const loadOntoTrain = useMutation({
|
|
mutationFn: async ({ scheduleId, onlyIds }: { scheduleId: string; onlyIds: string[] }) => {
|
|
const items = await warehouseService.getTrainLoadableItems(scheduleId);
|
|
let loadableIds = items.filter((i) => i.loadable).map((i) => i.id);
|
|
// When rows are checked, load only those; otherwise load every loadable item.
|
|
if (onlyIds.length) {
|
|
const picked = new Set(onlyIds);
|
|
loadableIds = loadableIds.filter((id) => picked.has(id));
|
|
}
|
|
if (!loadableIds.length) {
|
|
const scope = onlyIds.length
|
|
? items.filter((i) => onlyIds.includes(i.id))
|
|
: items.filter((i) => i.status === 'READY_FOR_LOADING');
|
|
// A closed loading window is the blocker staff hit most, and the old
|
|
// wagon-only message sent them to fix the wrong thing.
|
|
const shut = scope.find((i) => !i.loadingWindowStarted);
|
|
if (shut) {
|
|
throw new Error(
|
|
`Start loading at ${shut.originYardLabel ?? 'the boarding yard'} first — the loading time window has not been started`,
|
|
);
|
|
}
|
|
throw new Error(
|
|
onlyIds.length
|
|
? 'None of the selected items have an allocated wagon on this train'
|
|
: 'No ready items with an allocated wagon on this train',
|
|
);
|
|
}
|
|
return warehouseService.loadItemsOntoTrain(scheduleId, loadableIds);
|
|
},
|
|
onSuccess: () => {
|
|
void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
|
|
void qc.invalidateQueries({ queryKey: ['warehouse-loadings'] });
|
|
},
|
|
});
|
|
|
|
|
|
const confirmLoad = async () => {
|
|
if (!targetScheduleId) {
|
|
toast({ variant: 'destructive', title: 'Select a train to load onto' });
|
|
return;
|
|
}
|
|
try {
|
|
const r = await loadOntoTrain.mutateAsync({
|
|
scheduleId: targetScheduleId,
|
|
onlyIds: [...selected],
|
|
});
|
|
const train = trains.find((t) => t.scheduleId === targetScheduleId);
|
|
toast({
|
|
title: `${r.loadedCount} item(s) loaded onto train ${train?.trainNumber ?? ''}`.trim(),
|
|
description: r.skippedCount
|
|
? `${r.skippedCount} skipped — ${r.results.find((x) => x.reason)?.reason ?? 'see results'}`
|
|
: undefined,
|
|
});
|
|
setTrainPickerOpen(false);
|
|
setTargetScheduleId(null);
|
|
setSelected(new Set());
|
|
onChanged?.();
|
|
} catch (error) {
|
|
toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(error) });
|
|
}
|
|
};
|
|
|
|
if (view === 'train') {
|
|
return (
|
|
<Stack gap="sm" mt="sm">
|
|
<Group justify="space-between" wrap="wrap">
|
|
<SegmentedControl
|
|
size="xs"
|
|
value={view}
|
|
onChange={(v) => setView(v as 'items' | 'train')}
|
|
data={[
|
|
{ value: 'items', label: 'Items' },
|
|
{ value: 'train', label: 'By train' },
|
|
]}
|
|
/>
|
|
<Text size="xs" c="dimmed">
|
|
Per-booking Load, wagon-by-wagon loading and unloading — the train schedule's own
|
|
actions, run from the warehouse.
|
|
</Text>
|
|
</Group>
|
|
<TrainLoadingWorkspace enabled={enabled} onChanged={onChanged} />
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Stack gap="sm" mt="sm">
|
|
<Group justify="space-between" wrap="wrap">
|
|
<Group gap="sm" wrap="wrap">
|
|
<SegmentedControl
|
|
size="xs"
|
|
value={view}
|
|
onChange={(v) => setView(v as 'items' | 'train')}
|
|
data={[
|
|
{ value: 'items', label: 'Items' },
|
|
{ value: 'train', label: 'By train' },
|
|
]}
|
|
/>
|
|
<Text size="sm" c="dimmed">
|
|
{selected.size > 0 ? (
|
|
<><b>{selected.size}</b> of {controls.filteredRows.length} selected</>
|
|
) : (
|
|
<><b>{controls.filteredRows.length}</b> item{controls.filteredRows.length !== 1 ? 's' : ''} ready to load</>
|
|
)}
|
|
</Text>
|
|
</Group>
|
|
<Tooltip label={canLoad ? undefined : "You don't have permission to load cargo"} disabled={canLoad} withArrow>
|
|
<Button
|
|
size="compact-sm"
|
|
variant="filled"
|
|
color="teal"
|
|
leftSection={<Truck size={14} />}
|
|
disabled={rows.length === 0 || !canLoad}
|
|
onClick={() => setTrainPickerOpen(true)}
|
|
>
|
|
{selected.size > 0 ? `Load Selected (${selected.size})` : 'Auto Load Ready Items'}
|
|
</Button>
|
|
</Tooltip>
|
|
</Group>
|
|
|
|
<Modal
|
|
opened={trainPickerOpen}
|
|
onClose={() => setTrainPickerOpen(false)}
|
|
title="Load ready items onto a train"
|
|
centered
|
|
size="lg"
|
|
>
|
|
<Stack gap="md">
|
|
{trainsLoading ? (
|
|
<Group justify="center" py="md"><Loader size="sm" /></Group>
|
|
) : trains.length === 0 ? (
|
|
<Alert color="yellow" variant="light" icon={<Info size={16} />}>
|
|
No train available. Auto-loading needs a scheduled (not yet dispatched) train with
|
|
these bookings assigned — schedule the train and allocate wagons first.
|
|
</Alert>
|
|
) : (
|
|
<Select
|
|
label="Available trains"
|
|
placeholder="Select the train to load onto"
|
|
data={trains.map((t) => ({
|
|
value: t.scheduleId,
|
|
label: `${t.trainNumber ?? t.scheduleId.slice(0, 8)} · ${t.origin ?? '?'} → ${t.destination ?? '?'} · dep ${t.departureTime ? formatDate(t.departureTime) : '—'} · ${t.readyCount} ready`,
|
|
}))}
|
|
value={targetScheduleId}
|
|
onChange={setTargetScheduleId}
|
|
searchable
|
|
/>
|
|
)}
|
|
{targetScheduleId ? (
|
|
<YardLoadingWindows
|
|
scheduleId={targetScheduleId}
|
|
items={pickerItems}
|
|
logs={trains.find((t) => t.scheduleId === targetScheduleId)?.stationWorkLogs}
|
|
/>
|
|
) : null}
|
|
<Group justify="flex-end">
|
|
<Button variant="default" onClick={() => setTrainPickerOpen(false)} disabled={loadOntoTrain.isPending}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
color="teal"
|
|
leftSection={<Truck size={14} />}
|
|
loading={loadOntoTrain.isPending}
|
|
disabled={!targetScheduleId}
|
|
onClick={confirmLoad}
|
|
>
|
|
Load onto this train
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Modal>
|
|
|
|
{isLoading ? (
|
|
<Group justify="center" py="lg">
|
|
<Loader size="sm" />
|
|
</Group>
|
|
) : rows.length === 0 ? (
|
|
<Text c="dimmed" ta="center" py="lg" size="sm">
|
|
No EXPORT items with inspection PASSED waiting to be loaded.
|
|
</Text>
|
|
) : (
|
|
<Stack gap="sm">
|
|
<ListControls
|
|
search={controls.search}
|
|
onSearchChange={controls.setSearch}
|
|
searchPlaceholder="Booking, GRN, customer, container, cargo, route…"
|
|
dateFrom={controls.dateFrom}
|
|
onDateFromChange={controls.setDateFrom}
|
|
dateTo={controls.dateTo}
|
|
onDateToChange={controls.setDateTo}
|
|
hasFilters={controls.hasFilters}
|
|
onReset={controls.reset}
|
|
showDateRange={false}
|
|
/>
|
|
<Table.ScrollContainer minWidth={1200}>
|
|
<Table highlightOnHover verticalSpacing="xs" striped>
|
|
<Table.Thead>
|
|
<Table.Tr>
|
|
<Table.Th w={40}>
|
|
<Checkbox
|
|
aria-label="Select all"
|
|
checked={allSelected}
|
|
indeterminate={someSelected}
|
|
onChange={toggleAll}
|
|
/>
|
|
</Table.Th>
|
|
<Table.Th w={34} />
|
|
<Table.Th>Booking Ref</Table.Th>
|
|
<Table.Th>GRN</Table.Th>
|
|
<Table.Th>Customer Name</Table.Th>
|
|
<Table.Th>Container #</Table.Th>
|
|
<Table.Th>Cargo Type</Table.Th>
|
|
<Table.Th>Weight</Table.Th>
|
|
<Table.Th>Route</Table.Th>
|
|
<Table.Th>Inspection</Table.Th>
|
|
<Table.Th>Status</Table.Th>
|
|
</Table.Tr>
|
|
</Table.Thead>
|
|
<Table.Tbody>
|
|
{controls.pagedRows.map((r: ReadyToLoadRow) => (
|
|
<Fragment key={r.id}>
|
|
<Table.Tr>
|
|
<Table.Td>
|
|
<Checkbox
|
|
aria-label={`Select ${r.bookingReference ?? r.id}`}
|
|
checked={selected.has(r.id)}
|
|
onChange={() => toggleOne(r.id)}
|
|
/>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<ActionIcon
|
|
variant="subtle"
|
|
color="gray"
|
|
aria-label="Show containers"
|
|
onClick={() => setExpandedRow(expandedRow === r.id ? null : r.id)}
|
|
>
|
|
{expandedRow === r.id ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
|
</ActionIcon>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
|
|
</Table.Td>
|
|
<Table.Td>{r.customerName ?? '—'}</Table.Td>
|
|
<Table.Td>{r.containerNumber ?? '—'}</Table.Td>
|
|
<Table.Td>{r.cargoType ?? '—'}</Table.Td>
|
|
<Table.Td>{formatNumber(Number(r.weight))}</Table.Td>
|
|
<Table.Td>
|
|
{r.origin || r.destination ? `${r.origin ?? '?'} → ${r.destination ?? '?'}` : '—'}
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Badge color="green" variant="light" size="sm">
|
|
{r.inspectionStatus ?? '—'}
|
|
</Badge>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<InventoryStatusBadge status={r.status as InventoryStatus} />
|
|
</Table.Td>
|
|
</Table.Tr>
|
|
{expandedRow === r.id && (
|
|
<BookingItemsExpansion
|
|
bookingId={r.bookingId}
|
|
colSpan={11}
|
|
bulkFallback={r.cargoType ? `Bulk cargo — ${r.cargoType}, ${formatNumber(Number(r.weight))} t` : undefined}
|
|
/>
|
|
)}
|
|
</Fragment>
|
|
))}
|
|
</Table.Tbody>
|
|
</Table>
|
|
</Table.ScrollContainer>
|
|
<RuleEngineListFooter
|
|
pagination={controls.pagination}
|
|
pageCount={controls.pageCount}
|
|
totalCount={controls.totalCount}
|
|
itemLabel="items"
|
|
onPaginationChange={controls.setPagination}
|
|
/>
|
|
</Stack>
|
|
)}
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Export items that are LOADED onto a wagon. Serves both the "Loaded" tab (read-only)
|
|
* and the "Dispatch Queue" tab (dispatchable=true → selection + Dispatch actions).
|
|
*/
|
|
function LoadedExportTab({
|
|
enabled,
|
|
dispatchable,
|
|
onChanged,
|
|
}: {
|
|
enabled: boolean;
|
|
dispatchable: boolean;
|
|
onChanged?: () => void;
|
|
}) {
|
|
const { toast } = useToast();
|
|
const { data: rows = [], isLoading } = useQuery(
|
|
api.warehouses.loadedExport.queryOptions({ enabled }),
|
|
);
|
|
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
|
|
const [expandedRow, setExpandedRow] = useState<string | null>(null);
|
|
const bulkDispatch = useMutation(
|
|
api.warehouses.bulkDispatchExport.mutationOptions(),
|
|
);
|
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
|
|
const controls = useListControls(rows, {
|
|
searchKeys: ['bookingReference', 'customerName', 'containerNumber', 'cargoType', 'grnNumber', 'origin', 'destination'],
|
|
});
|
|
const allSelected = controls.filteredRows.length > 0 && selected.size === controls.filteredRows.length;
|
|
const someSelected = selected.size > 0 && !allSelected;
|
|
const toggleAll = () => setSelected(allSelected ? new Set() : new Set(controls.filteredRows.map((r) => r.id)));
|
|
const toggleOne = (id: string) =>
|
|
setSelected((prev) => {
|
|
const next = new Set(prev);
|
|
next.has(id) ? next.delete(id) : next.add(id);
|
|
return next;
|
|
});
|
|
|
|
const dispatch = async (inventoryIds: string[]) => {
|
|
if (inventoryIds.length === 0) {
|
|
toast({ variant: 'destructive', title: 'Select at least one item' });
|
|
return;
|
|
}
|
|
try {
|
|
const r = await bulkDispatch.mutateAsync(inventoryIds);
|
|
toast({
|
|
title: `${r.dispatchedCount} dispatched`,
|
|
description: skippedSummary(r.skippedCount, r.results),
|
|
});
|
|
setSelected(new Set());
|
|
onChanged?.();
|
|
} catch (error) {
|
|
toast({ variant: 'destructive', title: 'Dispatch failed', description: extractErrorMessage(error) });
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Stack gap="sm" mt="sm">
|
|
<Group justify="space-between">
|
|
<Text size="sm" c="dimmed">
|
|
{dispatchable ? (
|
|
<>
|
|
Selected: <b>{selected.size}</b> / {controls.filteredRows.length} loaded
|
|
</>
|
|
) : (
|
|
<>
|
|
<b>{controls.filteredRows.length}</b> item{controls.filteredRows.length !== 1 ? 's' : ''} loaded
|
|
</>
|
|
)}
|
|
</Text>
|
|
{dispatchable && (
|
|
<Group gap="xs">
|
|
<Button
|
|
size="compact-sm"
|
|
variant="default"
|
|
disabled={rows.length === 0}
|
|
loading={bulkDispatch.isPending}
|
|
onClick={() =>
|
|
setConfirmAction({
|
|
title: 'Dispatch all',
|
|
message: `Dispatch all ${rows.length} loaded item(s)? They leave warehouse inventory for the train.`,
|
|
confirmLabel: `Dispatch ${rows.length}`,
|
|
run: () => dispatch(rows.map((r) => r.id)),
|
|
})
|
|
}
|
|
>
|
|
Dispatch All
|
|
</Button>
|
|
<Button
|
|
size="compact-sm"
|
|
color="green"
|
|
leftSection={<Truck size={14} />}
|
|
disabled={selected.size === 0}
|
|
loading={bulkDispatch.isPending}
|
|
onClick={() =>
|
|
setConfirmAction({
|
|
title: 'Dispatch selected',
|
|
message: `Dispatch ${selected.size} selected item(s)? They leave warehouse inventory for the train.`,
|
|
confirmLabel: `Dispatch ${selected.size}`,
|
|
run: () => dispatch([...selected]),
|
|
})
|
|
}
|
|
>
|
|
Dispatch Selected
|
|
</Button>
|
|
</Group>
|
|
)}
|
|
</Group>
|
|
|
|
{isLoading ? (
|
|
<Group justify="center" py="lg">
|
|
<Loader size="sm" />
|
|
</Group>
|
|
) : rows.length === 0 ? (
|
|
<Text c="dimmed" ta="center" py="lg" size="sm">
|
|
No LOADED export items {dispatchable ? 'waiting to dispatch' : 'yet'}.
|
|
</Text>
|
|
) : (
|
|
<Stack gap="sm">
|
|
<ListControls
|
|
search={controls.search}
|
|
onSearchChange={controls.setSearch}
|
|
searchPlaceholder="Booking, GRN, customer, container, cargo, route…"
|
|
dateFrom={controls.dateFrom}
|
|
onDateFromChange={controls.setDateFrom}
|
|
dateTo={controls.dateTo}
|
|
onDateToChange={controls.setDateTo}
|
|
hasFilters={controls.hasFilters}
|
|
onReset={controls.reset}
|
|
showDateRange={false}
|
|
/>
|
|
<Table.ScrollContainer minWidth={1200}>
|
|
<Table highlightOnHover verticalSpacing="xs" striped>
|
|
<Table.Thead>
|
|
<Table.Tr>
|
|
{dispatchable && (
|
|
<Table.Th w={40}>
|
|
<Checkbox
|
|
aria-label="Select all"
|
|
checked={allSelected}
|
|
indeterminate={someSelected}
|
|
onChange={toggleAll}
|
|
/>
|
|
</Table.Th>
|
|
)}
|
|
<Table.Th w={34} />
|
|
<Table.Th>Booking Ref</Table.Th>
|
|
<Table.Th>GRN</Table.Th>
|
|
<Table.Th>Customer Name</Table.Th>
|
|
<Table.Th>Container #</Table.Th>
|
|
<Table.Th>Cargo Type</Table.Th>
|
|
<Table.Th>Weight</Table.Th>
|
|
<Table.Th>Route</Table.Th>
|
|
<Table.Th>Status</Table.Th>
|
|
</Table.Tr>
|
|
</Table.Thead>
|
|
<Table.Tbody>
|
|
{controls.pagedRows.map((r: ReadyToLoadRow) => (
|
|
<Fragment key={r.id}>
|
|
<Table.Tr>
|
|
{dispatchable && (
|
|
<Table.Td>
|
|
<Checkbox
|
|
aria-label={`Select ${r.bookingReference ?? r.id}`}
|
|
checked={selected.has(r.id)}
|
|
onChange={() => toggleOne(r.id)}
|
|
/>
|
|
</Table.Td>
|
|
)}
|
|
<Table.Td>
|
|
<ActionIcon
|
|
variant="subtle"
|
|
color="gray"
|
|
aria-label="Show containers"
|
|
onClick={() => setExpandedRow(expandedRow === r.id ? null : r.id)}
|
|
>
|
|
{expandedRow === r.id ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
|
</ActionIcon>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
|
|
</Table.Td>
|
|
<Table.Td>{r.customerName ?? '—'}</Table.Td>
|
|
<Table.Td>{r.containerNumber ?? '—'}</Table.Td>
|
|
<Table.Td>{r.cargoType ?? '—'}</Table.Td>
|
|
<Table.Td>{formatNumber(Number(r.weight))}</Table.Td>
|
|
<Table.Td>
|
|
{r.origin || r.destination ? `${r.origin ?? '?'} → ${r.destination ?? '?'}` : '—'}
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<InventoryStatusBadge status={r.status as InventoryStatus} />
|
|
</Table.Td>
|
|
</Table.Tr>
|
|
{expandedRow === r.id && (
|
|
<BookingItemsExpansion
|
|
bookingId={r.bookingId}
|
|
colSpan={11}
|
|
bulkFallback={r.cargoType ? `Bulk cargo — ${r.cargoType}, ${formatNumber(Number(r.weight))} t` : undefined}
|
|
/>
|
|
)}
|
|
</Fragment>
|
|
))}
|
|
</Table.Tbody>
|
|
</Table>
|
|
</Table.ScrollContainer>
|
|
<RuleEngineListFooter
|
|
pagination={controls.pagination}
|
|
pageCount={controls.pageCount}
|
|
totalCount={controls.totalCount}
|
|
itemLabel="items"
|
|
onPaginationChange={controls.setPagination}
|
|
/>
|
|
</Stack>
|
|
)}
|
|
<ConfirmActionModal action={confirmAction} onClose={() => setConfirmAction(null)} />
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
const isImportContainerFreight = (freightType: string | null | undefined) =>
|
|
(freightType ?? '').toUpperCase() === 'CONTAINER';
|
|
|
|
/**
|
|
* Yard/zone types valid for the freight being received — used to filter the receive
|
|
* location pickers so the yard list matches the cargo. Container freight → container
|
|
* yards only; bulk / break-bulk → bulk, general-cargo, hazardous, or cold-storage.
|
|
* Union across the given freight types; empty input → no restriction (show all).
|
|
*/
|
|
const yardZoneTypesForFreights = (freightTypes: Array<string | null | undefined>) => {
|
|
const yardTypes = new Set<string>();
|
|
const zoneTypes = new Set<string>();
|
|
for (const freightType of freightTypes) {
|
|
const normalized = (freightType ?? '').toUpperCase();
|
|
if (!normalized) continue;
|
|
if (normalized === 'CONTAINER') {
|
|
yardTypes.add('CONTAINER_YARD');
|
|
zoneTypes.add('CONTAINER_ZONE');
|
|
} else {
|
|
['BULK_YARD', 'GENERAL_CARGO_YARD', 'HAZARDOUS_YARD', 'COLD_STORAGE_YARD'].forEach((t) => yardTypes.add(t));
|
|
['BULK_ZONE', 'GENERAL_CARGO_ZONE', 'HAZARDOUS_ZONE', 'COLD_STORAGE_ZONE'].forEach((t) => zoneTypes.add(t));
|
|
}
|
|
}
|
|
return { yardTypes: [...yardTypes], zoneTypes: [...zoneTypes] };
|
|
};
|
|
|
|
const isImportUnloadPending = (item: ImportTrainItem) =>
|
|
!item.currentStatus || item.currentStatus === 'RECEIVED';
|
|
|
|
/** Assigned bookings/items for an arrived import train with per-booking unload locations. */
|
|
function ImportTrainDetailTable({
|
|
train,
|
|
warehouses,
|
|
yards,
|
|
zones,
|
|
assignments,
|
|
onAssignmentChange,
|
|
onReadyChange,
|
|
}: {
|
|
train: ImportTrain;
|
|
warehouses: Warehouse[];
|
|
yards: WarehouseYard[];
|
|
zones: WarehouseZone[];
|
|
assignments: Record<string, ImportUnloadAssignmentDraft>;
|
|
onAssignmentChange: (bookingId: string, draft: ImportUnloadAssignmentDraft) => void;
|
|
onReadyChange: (ready: boolean) => void;
|
|
}) {
|
|
const { data: items = [], isLoading } = useQuery(
|
|
api.warehouses.importTrainItems.queryOptions({
|
|
input: { scheduleId: train.scheduleId },
|
|
enabled: Boolean(train.scheduleId),
|
|
}),
|
|
);
|
|
// A train only ever unloads at the warehouse actually sitting at its
|
|
// destination station — Indode's train never offers Sebeta's warehouse.
|
|
const scopedWarehouses = useMemo(
|
|
() => warehousesAtStation(warehouses, train.destinationStationId),
|
|
[warehouses, train.destinationStationId],
|
|
);
|
|
const warehouseOptions = useMemo(
|
|
() => scopedWarehouses.map((warehouse) => ({ value: warehouse.id, label: `${warehouse.name} (${warehouse.code})` })),
|
|
[scopedWarehouses],
|
|
);
|
|
// With exactly one warehouse at the station there is nothing to choose —
|
|
// pre-fill it so staff only has to pick yard/zone, not re-discover Indode.
|
|
useEffect(() => {
|
|
if (scopedWarehouses.length !== 1) return;
|
|
const onlyWarehouseId = scopedWarehouses[0].id;
|
|
items.filter(isImportUnloadPending).forEach((item) => {
|
|
if (!assignments[item.bookingId]?.warehouseId) {
|
|
onAssignmentChange(item.bookingId, { ...assignments[item.bookingId], warehouseId: onlyWarehouseId });
|
|
}
|
|
});
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [scopedWarehouses, items]);
|
|
|
|
// Once a booking's warehouse is known, its yard (and then zone) follow from
|
|
// what the cargo actually is — a Wheat booking only ever has one candidate
|
|
// yard (Dry Bulk) once Indode's real yard layout is configured, so staff
|
|
// never see a picker for something that isn't actually a choice.
|
|
useEffect(() => {
|
|
items.filter(isImportUnloadPending).forEach((item) => {
|
|
const draft = assignments[item.bookingId];
|
|
if (!draft?.warehouseId) return;
|
|
|
|
if (!draft.yardId) {
|
|
const candidateYards = yardsForBooking(yards, {
|
|
warehouseId: draft.warehouseId,
|
|
freightType: item.freightType,
|
|
tradeDirection: 'IMPORT',
|
|
cargoTypeCode: item.cargoTypeCode,
|
|
});
|
|
if (candidateYards.length === 1) {
|
|
onAssignmentChange(item.bookingId, { ...draft, yardId: candidateYards[0].id });
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!draft.zoneId) {
|
|
const candidateZones = zones.filter((zone) => zone.yardId === draft.yardId);
|
|
if (candidateZones.length === 1) {
|
|
onAssignmentChange(item.bookingId, { ...draft, zoneId: candidateZones[0].id });
|
|
}
|
|
}
|
|
});
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [assignments, items, yards, zones]);
|
|
|
|
useEffect(() => {
|
|
const pending = items.filter(isImportUnloadPending);
|
|
onReadyChange(
|
|
pending.length > 0 &&
|
|
pending.every((item) => {
|
|
const draft = assignments[item.bookingId];
|
|
return Boolean(draft?.warehouseId && draft.yardId && draft.zoneId);
|
|
}),
|
|
);
|
|
}, [assignments, items]);
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<Group justify="center" py="md">
|
|
<Loader size="sm" />
|
|
</Group>
|
|
);
|
|
}
|
|
if (items.length === 0) {
|
|
return (
|
|
<Text c="dimmed" ta="center" py="md" size="sm">
|
|
No assigned bookings on this train.
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Table withTableBorder verticalSpacing="xs" fz="xs">
|
|
<Table.Thead>
|
|
<Table.Tr>
|
|
<Table.Th>Wagon</Table.Th>
|
|
<Table.Th>Booking Ref</Table.Th>
|
|
<Table.Th>Customer Name</Table.Th>
|
|
<Table.Th>Container #</Table.Th>
|
|
<Table.Th>Cargo Type</Table.Th>
|
|
<Table.Th>Weight</Table.Th>
|
|
<Table.Th>Arrival</Table.Th>
|
|
<Table.Th>Warehouse</Table.Th>
|
|
<Table.Th>Yard</Table.Th>
|
|
<Table.Th>Zone</Table.Th>
|
|
<Table.Th>Inspection</Table.Th>
|
|
<Table.Th>Current Status</Table.Th>
|
|
<Table.Th>Last Mile</Table.Th>
|
|
<Table.Th>Pickup Option</Table.Th>
|
|
</Table.Tr>
|
|
</Table.Thead>
|
|
<Table.Tbody>
|
|
{items.map((it: ImportTrainItem) => {
|
|
const draft = assignments[it.bookingId] ?? {};
|
|
const yardOptions = yardsForBooking(yards, {
|
|
warehouseId: draft.warehouseId,
|
|
freightType: it.freightType,
|
|
tradeDirection: 'IMPORT',
|
|
cargoTypeCode: it.cargoTypeCode,
|
|
}).map((yard) => ({ value: yard.id, label: `${yard.name} (${yard.code})` }));
|
|
// The yard is already scoped to what this cargo can go into — a
|
|
// zone's own type always matches its parent yard's purpose (see the
|
|
// Indode seed migration), so no separate zone-type filter is needed.
|
|
const zoneOptions = zones
|
|
.filter((zone) => zone.yardId === draft.yardId)
|
|
.map((zone) => ({ value: zone.id, label: `${zone.name} (${zone.code})` }));
|
|
const pending = isImportUnloadPending(it);
|
|
|
|
return (
|
|
<Table.Tr key={`${it.bookingId}-${it.sequenceNo ?? 'wagon'}`}>
|
|
<Table.Td>
|
|
<Text size="xs" fw={600}>
|
|
{it.sequenceNo ? `#${it.sequenceNo}` : '-'} {it.wagonNumber ?? ''}
|
|
</Text>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Text size="xs" fw={600}>{it.bookingReference ?? '—'}</Text>
|
|
</Table.Td>
|
|
<Table.Td>{it.customerName ?? '—'}</Table.Td>
|
|
<Table.Td>{it.containerNumber ?? '—'}</Table.Td>
|
|
<Table.Td>{it.cargoType ?? '—'}</Table.Td>
|
|
<Table.Td>{formatNumber(Number(it.weight))}</Table.Td>
|
|
<Table.Td>{formatDate(it.arrivalTime)}</Table.Td>
|
|
<Table.Td>
|
|
<Select
|
|
placeholder="Warehouse"
|
|
data={warehouseOptions}
|
|
value={draft.warehouseId ?? null}
|
|
onChange={(value) => onAssignmentChange(it.bookingId, { warehouseId: value ?? undefined })}
|
|
searchable
|
|
disabled={!pending}
|
|
w={210}
|
|
/>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Select
|
|
placeholder={isImportContainerFreight(it.freightType) ? 'Container yard' : 'Bulk yard'}
|
|
data={yardOptions}
|
|
value={draft.yardId ?? null}
|
|
onChange={(value) =>
|
|
onAssignmentChange(it.bookingId, { ...draft, yardId: value ?? undefined, zoneId: undefined })
|
|
}
|
|
searchable
|
|
disabled={!pending || !draft.warehouseId}
|
|
w={190}
|
|
/>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Select
|
|
placeholder={isImportContainerFreight(it.freightType) ? 'Container zone' : 'Bulk zone'}
|
|
data={zoneOptions}
|
|
value={draft.zoneId ?? null}
|
|
onChange={(value) => onAssignmentChange(it.bookingId, { ...draft, zoneId: value ?? undefined })}
|
|
searchable
|
|
disabled={!pending || !draft.yardId}
|
|
w={190}
|
|
/>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Badge size="xs" variant="light" color={it.inspectionStatus === 'PASSED' ? 'green' : 'gray'}>
|
|
{it.inspectionStatus ?? 'Not inspected'}
|
|
</Badge>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Badge size="xs" variant="light" color="gray">{it.currentStatus ?? '—'}</Badge>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Badge size="xs" variant="light" color={it.lastMileRequested ? 'blue' : 'gray'}>
|
|
{it.lastMileRequested ? 'Yes' : 'No'}
|
|
</Badge>
|
|
</Table.Td>
|
|
<Table.Td>{it.pickupOption}</Table.Td>
|
|
</Table.Tr>
|
|
);
|
|
})}
|
|
</Table.Tbody>
|
|
</Table>
|
|
);
|
|
}
|
|
|
|
/** Import Arrive Queue: arrived IMPORT trains, with Open (detail) + Auto Unload per train. */
|
|
const getPendingUnloadBookings = (train: ImportTrain) =>
|
|
train.pendingUnloadBookings ?? train.totalBookings;
|
|
|
|
const isFullyUnloaded = (train: ImportTrain) =>
|
|
Boolean(train.fullyUnloaded) || (train.totalBookings > 0 && getPendingUnloadBookings(train) === 0);
|
|
|
|
export function ImportArriveQueueTab({
|
|
enabled,
|
|
onChanged,
|
|
}: {
|
|
enabled: boolean;
|
|
onChanged?: () => void;
|
|
}) {
|
|
const { toast } = useToast();
|
|
const { user } = useAuth();
|
|
const canUnload = hasFreightPermission(user, FREIGHT_PERMS.warehouseInventory.unload);
|
|
const { data: trains = [], isLoading } = useQuery(
|
|
api.warehouses.importArriveQueue.queryOptions({ enabled }),
|
|
);
|
|
const { data: warehouses = [], isLoading: warehousesLoading } = useQuery(
|
|
api.warehouses.list.queryOptions({ input: { filter: { status: 'ACTIVE' } }, enabled }),
|
|
);
|
|
const { data: yards = [] } = useAllWarehouseYards();
|
|
const { data: zones = [] } = useAllWarehouseZones();
|
|
const autoUnloadMutation = useMutation(
|
|
api.warehouses.autoUnloadArrivedBookings.mutationOptions(),
|
|
);
|
|
const [openId, setOpenId] = useState<string | null>(null);
|
|
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
|
|
const [busyId, setBusyId] = useState<string | null>(null);
|
|
const [assignmentsBySchedule, setAssignmentsBySchedule] = useState<
|
|
Record<string, Record<string, ImportUnloadAssignmentDraft>>
|
|
>({});
|
|
const [readyBySchedule, setReadyBySchedule] = useState<Record<string, boolean>>({});
|
|
|
|
const controls = useListControls(trains, {
|
|
searchKeys: ['trainNumber', 'route', 'origin', 'destination', 'status'],
|
|
dateKey: 'arrivalTime',
|
|
});
|
|
|
|
const autoUnload = async (train: ImportTrain) => {
|
|
const assignments = Object.entries(assignmentsBySchedule[train.scheduleId] ?? {})
|
|
.filter((entry): entry is [string, Required<ImportUnloadAssignmentDraft>] =>
|
|
Boolean(entry[1].warehouseId && entry[1].yardId && entry[1].zoneId),
|
|
)
|
|
.map(([bookingId, draft]) => ({
|
|
bookingId,
|
|
warehouseId: draft.warehouseId,
|
|
yardId: draft.yardId,
|
|
zoneId: draft.zoneId,
|
|
}));
|
|
|
|
if (!readyBySchedule[train.scheduleId] || assignments.length === 0) {
|
|
toast({
|
|
variant: 'destructive',
|
|
title: 'Assign locations',
|
|
description: 'Select warehouse, yard and zone for each pending booking before unloading.',
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (isFullyUnloaded(train)) {
|
|
toast({
|
|
title: 'Already unloaded',
|
|
description: `${train.trainNumber ?? 'This train'} has no remaining bookings to auto unload.`,
|
|
});
|
|
return;
|
|
}
|
|
|
|
setBusyId(train.scheduleId);
|
|
try {
|
|
const r = await autoUnloadMutation.mutateAsync({ scheduleId: train.scheduleId, assignments });
|
|
const alreadyUnloaded = r.unloadedCount === 0 && r.skippedCount > 0 && r.failedCount === 0;
|
|
const firstReason = r.results.find((item) => item.reason)?.reason;
|
|
const extra = [
|
|
skippedSummary(r.skippedCount, r.results) ?? '',
|
|
r.failedCount ? `${r.failedCount} failed` : '',
|
|
]
|
|
.filter(Boolean)
|
|
.join(', ');
|
|
toast({
|
|
title: alreadyUnloaded ? 'Already unloaded' : `${r.unloadedCount} unloaded`,
|
|
description: alreadyUnloaded ? firstReason ?? 'This train is already in warehouse inventory.' : extra || undefined,
|
|
});
|
|
onChanged?.();
|
|
} catch (error) {
|
|
toast({ variant: 'destructive', title: 'Auto unload failed', description: extractErrorMessage(error) });
|
|
} finally {
|
|
setBusyId(null);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Stack gap="sm" mt="sm">
|
|
{isLoading ? (
|
|
<Group justify="center" py="lg">
|
|
<Loader size="sm" />
|
|
</Group>
|
|
) : trains.length === 0 ? (
|
|
<Text c="dimmed" ta="center" py="lg" size="sm">
|
|
No arrived import trains. Trains appear here once their schedule status is ARRIVED.
|
|
</Text>
|
|
) : (
|
|
<Stack gap="sm">
|
|
<Group justify="space-between" align="flex-end" wrap="wrap">
|
|
<Text size="sm" c="dimmed">
|
|
<b>{controls.totalCount}</b> arrived import train{controls.totalCount !== 1 ? 's' : ''}
|
|
</Text>
|
|
<ListControls
|
|
search={controls.search}
|
|
onSearchChange={controls.setSearch}
|
|
searchPlaceholder="Train #, route, origin, destination…"
|
|
dateFrom={controls.dateFrom}
|
|
onDateFromChange={controls.setDateFrom}
|
|
dateTo={controls.dateTo}
|
|
onDateToChange={controls.setDateTo}
|
|
dateLabel="Arrival"
|
|
hasFilters={controls.hasFilters}
|
|
onReset={controls.reset}
|
|
/>
|
|
</Group>
|
|
|
|
<Table.ScrollContainer minWidth={1500}>
|
|
<Table highlightOnHover verticalSpacing="xs" striped>
|
|
<Table.Thead>
|
|
<Table.Tr>
|
|
<Table.Th>Schedule ID</Table.Th>
|
|
<Table.Th>Train #</Table.Th>
|
|
<Table.Th>Route</Table.Th>
|
|
<Table.Th>Origin</Table.Th>
|
|
<Table.Th>Destination</Table.Th>
|
|
<Table.Th>Arrival Time</Table.Th>
|
|
<Table.Th ta="center">Bookings</Table.Th>
|
|
<Table.Th ta="center">Containers</Table.Th>
|
|
<Table.Th ta="center">Cargoes</Table.Th>
|
|
<Table.Th>Status</Table.Th>
|
|
<Table.Th ta="right">Actions</Table.Th>
|
|
</Table.Tr>
|
|
</Table.Thead>
|
|
<Table.Tbody>
|
|
{controls.pagedRows.length === 0 ? (
|
|
<Table.Tr>
|
|
<Table.Td colSpan={11}>
|
|
<Text c="dimmed" ta="center" py="lg" size="sm">
|
|
No trains match the current filters.
|
|
</Text>
|
|
</Table.Td>
|
|
</Table.Tr>
|
|
) : (
|
|
controls.pagedRows.map((t: ImportTrain) => {
|
|
const isOpen = openId === t.scheduleId;
|
|
const fullyUnloaded = isFullyUnloaded(t);
|
|
const unloadedBookings = t.unloadedBookings ?? t.totalBookings - getPendingUnloadBookings(t);
|
|
return (
|
|
<Fragment key={t.scheduleId}>
|
|
<Table.Tr>
|
|
<Table.Td>
|
|
<Group gap={4} wrap="nowrap">
|
|
<Text size="xs" c="dimmed">{t.scheduleId.slice(0, 8)}…</Text>
|
|
<CopyButton value={t.scheduleId}>
|
|
{({ copied, copy }) => (
|
|
<Tooltip label={copied ? 'Copied' : 'Copy schedule ID'} withArrow>
|
|
<ActionIcon size="xs" variant="subtle" color={copied ? 'teal' : 'gray'} onClick={copy}>
|
|
{copied ? <CheckCheck size={12} /> : <Copy size={12} />}
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
)}
|
|
</CopyButton>
|
|
</Group>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Text size="sm" fw={600}>{t.trainNumber ?? '—'}</Text>
|
|
</Table.Td>
|
|
<Table.Td>{t.route ?? '—'}</Table.Td>
|
|
<Table.Td>{t.origin ?? '—'}</Table.Td>
|
|
<Table.Td>{t.destination ?? '—'}</Table.Td>
|
|
<Table.Td>{formatDate(t.arrivalTime)}</Table.Td>
|
|
<Table.Td ta="center">{t.totalBookings}</Table.Td>
|
|
<Table.Td ta="center">{t.totalContainers}</Table.Td>
|
|
<Table.Td ta="center">{t.totalCargoes}</Table.Td>
|
|
<Table.Td>
|
|
<Stack gap={2}>
|
|
<Badge color="indigo" variant="light" size="sm">
|
|
{t.status}
|
|
</Badge>
|
|
<Text size="xs" c="dimmed">
|
|
{Math.max(unloadedBookings, 0)}/{t.totalBookings} unloaded
|
|
</Text>
|
|
</Stack>
|
|
</Table.Td>
|
|
<Table.Td ta="right">
|
|
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
|
<Button
|
|
size="compact-xs"
|
|
variant="light"
|
|
leftSection={isOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
|
onClick={() => setOpenId(isOpen ? null : t.scheduleId)}
|
|
>
|
|
Open
|
|
</Button>
|
|
<Tooltip label={canUnload ? undefined : "You don't have permission to unload cargo"} disabled={canUnload} withArrow>
|
|
<Button
|
|
size="compact-xs"
|
|
color={fullyUnloaded ? 'gray' : 'indigo'}
|
|
leftSection={<Truck size={14} />}
|
|
loading={busyId === t.scheduleId}
|
|
disabled={fullyUnloaded || t.totalBookings === 0 || !readyBySchedule[t.scheduleId] || warehousesLoading || !canUnload}
|
|
onClick={() =>
|
|
setConfirmAction({
|
|
title: 'Auto unload train',
|
|
message: `Unload all arrived bookings from train ${t.trainNumber ?? t.scheduleId.slice(0, 8)} into their assigned warehouse locations?`,
|
|
confirmLabel: 'Unload train',
|
|
run: () => autoUnload(t),
|
|
})
|
|
}
|
|
>
|
|
{fullyUnloaded ? 'Already Unloaded' : 'Auto Unload Arrived Bookings'}
|
|
</Button>
|
|
</Tooltip>
|
|
</Group>
|
|
</Table.Td>
|
|
</Table.Tr>
|
|
{isOpen && (
|
|
<Table.Tr>
|
|
<Table.Td colSpan={11} bg="var(--mantine-color-gray-0)">
|
|
<ImportTrainDetailTable
|
|
train={t}
|
|
warehouses={warehouses}
|
|
yards={yards}
|
|
zones={zones}
|
|
assignments={assignmentsBySchedule[t.scheduleId] ?? {}}
|
|
onAssignmentChange={(bookingId, draft) =>
|
|
setAssignmentsBySchedule((current) => ({
|
|
...current,
|
|
[t.scheduleId]: {
|
|
...(current[t.scheduleId] ?? {}),
|
|
[bookingId]: draft.warehouseId ? draft : {},
|
|
},
|
|
}))
|
|
}
|
|
onReadyChange={(ready) =>
|
|
setReadyBySchedule((current) => ({ ...current, [t.scheduleId]: ready }))
|
|
}
|
|
/>
|
|
</Table.Td>
|
|
</Table.Tr>
|
|
)}
|
|
</Fragment>
|
|
);
|
|
}))}
|
|
</Table.Tbody>
|
|
</Table>
|
|
</Table.ScrollContainer>
|
|
|
|
<RuleEngineListFooter
|
|
pagination={controls.pagination}
|
|
pageCount={controls.pageCount}
|
|
totalCount={controls.totalCount}
|
|
itemLabel="trains"
|
|
onPaginationChange={controls.setPagination}
|
|
/>
|
|
</Stack>
|
|
)}
|
|
<ConfirmActionModal action={confirmAction} onClose={() => setConfirmAction(null)} />
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Import Unloaded Queue (Batch 9): all unloaded import items with the destination-inspection columns.
|
|
* Multi-select + Mark Selected as Inspected (import passed → READY_FOR_PICKUP), and the per-item
|
|
* Inspect / Report action stays for damage / images / weight-loss detail.
|
|
*/
|
|
function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
|
const { toast } = useToast();
|
|
const qc = useQueryClient();
|
|
const { data: rows = [], isLoading } = useQuery(
|
|
api.warehouses.importUnloadedQueue.queryOptions({ enabled }),
|
|
);
|
|
const inspectMutation = useMutation(
|
|
api.warehouses.bulkMarkInspected.mutationOptions(),
|
|
);
|
|
const readyMutation = useMutation(api.warehouses.markReadyForPickup.mutationOptions());
|
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
|
|
const [expandedRow, setExpandedRow] = useState<string | null>(null);
|
|
const [inspectId, setInspectId] = useState<string | null>(null);
|
|
const [busyId, setBusyId] = useState<string | null>(null);
|
|
const [viewItem, setViewItem] = useState<WarehouseInventoryItem | null>(null);
|
|
const [historyItem, setHistoryItem] = useState<WarehouseInventoryItem | null>(null);
|
|
const [feeItem, setFeeItem] = useState<WarehouseInventoryItem | null>(null);
|
|
const [releaseItem, setReleaseItem] = useState<WarehouseInventoryItem | null>(null);
|
|
const [deliverItem, setDeliverItem] = useState<WarehouseInventoryItem | null>(null);
|
|
const [containerItemsItem, setContainerItemsItem] = useState<WarehouseInventoryItem | null>(null);
|
|
const [storeItem, setStoreItem] = useState<WarehouseInventoryItem | null>(null);
|
|
const [moveItem, setMoveItem] = useState<WarehouseInventoryItem | null>(null);
|
|
|
|
const allSelected = rows.length > 0 && selected.size === rows.length;
|
|
const someSelected = selected.size > 0 && !allSelected;
|
|
const selectAll = () => setSelected(new Set(rows.map((r) => r.id)));
|
|
const unselectAll = () => setSelected(new Set());
|
|
const toggleOne = (id: string) =>
|
|
setSelected((prev) => {
|
|
const next = new Set(prev);
|
|
next.has(id) ? next.delete(id) : next.add(id);
|
|
return next;
|
|
});
|
|
|
|
const markInspected = async () => {
|
|
if (selected.size === 0) {
|
|
toast({ variant: 'destructive', title: 'Select at least one item' });
|
|
return;
|
|
}
|
|
try {
|
|
const r = await inspectMutation.mutateAsync({ inventoryIds: [...selected] });
|
|
toast({
|
|
title: `${r.inspectedCount} marked inspected`,
|
|
description: skippedSummary(r.skippedCount, r.results),
|
|
});
|
|
setSelected(new Set());
|
|
void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
|
|
} catch (error) {
|
|
toast({ variant: 'destructive', title: 'Mark inspected failed', description: extractErrorMessage(error) });
|
|
}
|
|
};
|
|
|
|
const toInventoryItem = (row: ImportUnloadedItem): WarehouseInventoryItem =>
|
|
({
|
|
id: row.id,
|
|
bookingId: row.bookingId,
|
|
quantity: 1,
|
|
weight: Number(row.weight) || 0,
|
|
grnNumber: row.grnNumber,
|
|
status: row.currentStatus,
|
|
arrivedAt: row.arrivalTime,
|
|
unloadedAt: row.arrivalTime,
|
|
inspectionStatus: row.inspectionStatus,
|
|
releaseDate: row.releaseDate,
|
|
releaseOrderReference: row.releaseOrderReference,
|
|
handoverDocumentReference: row.handoverDocumentReference,
|
|
handoverDocumentDate: row.handoverDocumentDate,
|
|
deliveredAt: row.deliveredAt,
|
|
// Carries the saved [Exit Inspection] block so Truck Leaving opens with the
|
|
// arrival details (plate, driver, tare, gate-in) read-only instead of blank.
|
|
notes: row.notes,
|
|
booking: row.bookingId
|
|
? {
|
|
id: row.bookingId,
|
|
reference: row.bookingReference ?? row.bookingId,
|
|
tradeDirection: 'IMPORT',
|
|
lastMileDeliveryAddress: row.lastMileRequested ? row.pickupOption : null,
|
|
customerTruckPlateNumber: row.customerTruckPlateNumber,
|
|
customerTruckDriverName: row.customerTruckDriverName,
|
|
customerTruckType: row.customerTruckType,
|
|
customerTruckContainerNumber: row.customerTruckContainerNumber,
|
|
customerTruckAssignedAt: row.customerTruckAssignedAt,
|
|
}
|
|
: null,
|
|
}) as unknown as WarehouseInventoryItem;
|
|
|
|
const runRowAction = async (row: ImportUnloadedItem, label: string, fn: () => Promise<unknown>) => {
|
|
setBusyId(row.id);
|
|
try {
|
|
await fn();
|
|
toast({ title: label });
|
|
void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
|
|
} catch (error) {
|
|
toast({ variant: 'destructive', title: 'Action failed', description: extractErrorMessage(error) });
|
|
} finally {
|
|
setBusyId(null);
|
|
}
|
|
};
|
|
|
|
const openHandoverDocument = async (row: ImportUnloadedItem) => {
|
|
setBusyId(row.id);
|
|
const pdfWindow = window.open('', '_blank');
|
|
try {
|
|
const response = await warehouseService.downloadHandoverDocument(row.id);
|
|
openPdfBlob(response.data, `handover-${row.bookingReference ?? row.id}.pdf`, pdfWindow);
|
|
void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
|
|
} catch (error) {
|
|
pdfWindow?.close();
|
|
toast({ variant: 'destructive', title: 'Handover document failed', description: await extractDownloadErrorMessage(error) });
|
|
} finally {
|
|
setBusyId(null);
|
|
}
|
|
};
|
|
|
|
const openReleaseDocument = async (row: ImportUnloadedItem) => {
|
|
setBusyId(row.id);
|
|
const pdfWindow = window.open('', '_blank');
|
|
try {
|
|
const response = await warehouseService.downloadReleaseDocument(row.id);
|
|
openPdfBlob(response.data, `release-${row.bookingReference ?? row.id}.pdf`, pdfWindow);
|
|
} catch (error) {
|
|
pdfWindow?.close();
|
|
toast({ variant: 'destructive', title: 'Exit paper failed', description: await extractDownloadErrorMessage(error) });
|
|
} finally {
|
|
setBusyId(null);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Stack gap="sm" mt="sm">
|
|
<Group justify="space-between">
|
|
<Text size="sm" c="dimmed">
|
|
Selected: <b>{selected.size}</b> / {rows.length} unloaded
|
|
</Text>
|
|
<Group gap="xs">
|
|
<Button size="compact-sm" variant="default" disabled={rows.length === 0} onClick={selectAll}>
|
|
Select All
|
|
</Button>
|
|
<Button size="compact-sm" variant="default" disabled={selected.size === 0} onClick={unselectAll}>
|
|
Unselect All
|
|
</Button>
|
|
<Button
|
|
size="compact-sm"
|
|
color="indigo"
|
|
leftSection={<ClipboardCheck size={14} />}
|
|
disabled={selected.size === 0}
|
|
loading={inspectMutation.isPending}
|
|
onClick={() =>
|
|
setConfirmAction({
|
|
title: 'Mark inspected',
|
|
message: `Mark ${selected.size} selected item(s) as inspection PASSED? Passed import items become ready for pickup.`,
|
|
confirmLabel: `Mark ${selected.size} inspected`,
|
|
run: markInspected,
|
|
})
|
|
}
|
|
>
|
|
Mark Selected as Inspected
|
|
</Button>
|
|
</Group>
|
|
</Group>
|
|
|
|
{isLoading ? (
|
|
<Group justify="center" py="lg">
|
|
<Loader size="sm" />
|
|
</Group>
|
|
) : rows.length === 0 ? (
|
|
<Text c="dimmed" ta="center" py="lg" size="sm">
|
|
No unloaded import items. Items appear here after Auto Unload on an arrived train.
|
|
</Text>
|
|
) : (
|
|
<Table.ScrollContainer minWidth={1650}>
|
|
<Table highlightOnHover verticalSpacing="xs" striped>
|
|
<Table.Thead>
|
|
<Table.Tr>
|
|
<Table.Th w={34} />
|
|
<Table.Th w={40}>
|
|
<Checkbox
|
|
aria-label="Select all"
|
|
checked={allSelected}
|
|
indeterminate={someSelected}
|
|
onChange={() => (allSelected ? unselectAll() : selectAll())}
|
|
/>
|
|
</Table.Th>
|
|
<Table.Th>Booking Ref</Table.Th>
|
|
<Table.Th>GRN</Table.Th>
|
|
<Table.Th>Customer Name</Table.Th>
|
|
<Table.Th>Arrival Time</Table.Th>
|
|
<Table.Th>Container #</Table.Th>
|
|
<Table.Th>Cargo Type</Table.Th>
|
|
<Table.Th>Weight</Table.Th>
|
|
<Table.Th>Train Schedule</Table.Th>
|
|
<Table.Th>Inspection</Table.Th>
|
|
<Table.Th>Pickup Option</Table.Th>
|
|
<Table.Th>Last Mile</Table.Th>
|
|
<Table.Th>Current Status</Table.Th>
|
|
<Table.Th ta="right">Actions</Table.Th>
|
|
</Table.Tr>
|
|
</Table.Thead>
|
|
<Table.Tbody>
|
|
{rows.map((r: ImportUnloadedItem) => (
|
|
<Fragment key={r.id}>
|
|
<Table.Tr>
|
|
<Table.Td>
|
|
<ActionIcon
|
|
variant="subtle"
|
|
color="gray"
|
|
aria-label="Show containers"
|
|
onClick={() => setExpandedRow(expandedRow === r.id ? null : r.id)}
|
|
>
|
|
{expandedRow === r.id ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
|
</ActionIcon>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Checkbox
|
|
aria-label={`Select ${r.bookingReference ?? r.id}`}
|
|
checked={selected.has(r.id)}
|
|
onChange={() => toggleOne(r.id)}
|
|
/>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
|
|
</Table.Td>
|
|
<Table.Td>{r.customerName ?? '—'}</Table.Td>
|
|
<Table.Td>{formatDate(r.arrivalTime)}</Table.Td>
|
|
<Table.Td>{r.containerNumber ?? '—'}</Table.Td>
|
|
<Table.Td>{r.cargoType ?? '—'}</Table.Td>
|
|
<Table.Td>{formatNumber(Number(r.weight))}</Table.Td>
|
|
<Table.Td>{r.trainSchedule ?? '—'}</Table.Td>
|
|
<Table.Td>
|
|
<Badge color={r.inspectionStatus === 'PASSED' ? 'green' : 'gray'} variant="light" size="sm">
|
|
{r.inspectionStatus ?? 'Not inspected'}
|
|
</Badge>
|
|
</Table.Td>
|
|
<Table.Td>{r.pickupOption}</Table.Td>
|
|
<Table.Td>
|
|
<Badge color={r.lastMileRequested ? 'blue' : 'gray'} variant="light" size="sm">
|
|
{r.lastMileRequested ? 'Yes' : 'No'}
|
|
</Badge>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<InventoryStatusBadge status={r.currentStatus as InventoryStatus} />
|
|
</Table.Td>
|
|
<Table.Td ta="right">
|
|
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
|
<Tooltip label="View details" withArrow>
|
|
<ActionIcon variant="subtle" color="gray" onClick={() => setContainerItemsItem(toInventoryItem(r))}>
|
|
<Eye size={16} />
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
{/* Primary stage action stays visible; the rest live under the kebab. */}
|
|
{/* Stays visible after the first exit — multi-truck bookings
|
|
weigh each truck in and out until all have left. */}
|
|
{r.currentStatus === 'READY_FOR_PICKUP' && r.hasAssignedTruck && (
|
|
<Button
|
|
size="compact-xs"
|
|
variant="light"
|
|
color="yellow"
|
|
leftSection={<Truck size={14} />}
|
|
onClick={() => setReleaseItem(toInventoryItem(r))}
|
|
>
|
|
{r.releaseOrderReference ? 'Truck Arrival / Leaving' : 'Truck Arrival'}
|
|
</Button>
|
|
)}
|
|
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
|
|
<Button
|
|
size="compact-xs"
|
|
variant="light"
|
|
color="orange"
|
|
leftSection={<FileText size={14} />}
|
|
loading={busyId === r.id}
|
|
onClick={() => openReleaseDocument(r)}
|
|
>
|
|
Exit Paper
|
|
</Button>
|
|
)}
|
|
<Menu shadow="md" width={240} position="bottom-end" withinPortal>
|
|
<Menu.Target>
|
|
<ActionIcon variant="subtle" color="gray" aria-label="More actions" loading={busyId === r.id}>
|
|
<MoreHorizontal size={16} />
|
|
</ActionIcon>
|
|
</Menu.Target>
|
|
<Menu.Dropdown>
|
|
{r.currentStatus === 'UNLOADED' && (
|
|
<Menu.Item leftSection={<MapPin size={14} />} onClick={() => setStoreItem(toInventoryItem(r))}>
|
|
Store…
|
|
</Menu.Item>
|
|
)}
|
|
{r.currentStatus !== 'UNLOADED' && (
|
|
<Menu.Item leftSection={<ArrowRightLeft size={14} />} onClick={() => setMoveItem(toInventoryItem(r))}>
|
|
Move…
|
|
</Menu.Item>
|
|
)}
|
|
{['UNLOADED', 'STORED'].includes(r.currentStatus) && r.inspectionStatus === 'PASSED' && (
|
|
<Menu.Item onClick={() => runRowAction(r, 'Ready for pickup', () => readyMutation.mutateAsync(r.id))}>
|
|
Ready for pickup
|
|
</Menu.Item>
|
|
)}
|
|
{r.currentStatus === 'READY_FOR_PICKUP' && (
|
|
<Menu.Item
|
|
leftSection={<Truck size={14} />}
|
|
disabled={!r.hasAssignedTruck}
|
|
onClick={() => setReleaseItem(toInventoryItem(r))}
|
|
>
|
|
{r.hasAssignedTruck
|
|
? r.releaseOrderReference
|
|
? 'Truck arrival / leaving'
|
|
: 'Truck arrival'
|
|
: 'Truck arrival — assign a truck first'}
|
|
</Menu.Item>
|
|
)}
|
|
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
|
|
<Menu.Item leftSection={<FileText size={14} />} onClick={() => openReleaseDocument(r)}>
|
|
Exit paper
|
|
</Menu.Item>
|
|
)}
|
|
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
|
|
<Menu.Item onClick={() => setDeliverItem(toInventoryItem(r))}>Deliver</Menu.Item>
|
|
)}
|
|
{r.inspectionStatus === 'PASSED' && (
|
|
<Menu.Item leftSection={<FileText size={14} />} onClick={() => openHandoverDocument(r)}>
|
|
{r.handoverDocumentReference ? 'View handover' : 'Handover'}
|
|
</Menu.Item>
|
|
)}
|
|
<Menu.Item onClick={() => setInspectId(r.id)}>Inspection / Report</Menu.Item>
|
|
{/* Double handling is decided once the goods are off
|
|
the wagon (every row here is unloaded) — Yes is
|
|
what makes the fee rule bill this booking. */}
|
|
<Menu.Divider />
|
|
<Menu.Label>
|
|
Double handling —{' '}
|
|
{r.doubleHandling == null ? 'not set' : r.doubleHandling ? 'Yes' : 'No'}
|
|
</Menu.Label>
|
|
<Menu.Item
|
|
leftSection={
|
|
r.doubleHandling === true ? <Check size={14} /> : <Layers size={14} />
|
|
}
|
|
disabled={!r.bookingId || r.doubleHandling === true}
|
|
onClick={() =>
|
|
runRowAction(r, 'Double handling: Yes — fee rule applies', () =>
|
|
warehouseService.setDoubleHandling(r.bookingId as string, true),
|
|
)
|
|
}
|
|
>
|
|
Yes — apply fee
|
|
</Menu.Item>
|
|
<Menu.Item
|
|
leftSection={
|
|
r.doubleHandling === false ? <Check size={14} /> : <Layers size={14} />
|
|
}
|
|
disabled={!r.bookingId || r.doubleHandling === false}
|
|
onClick={() =>
|
|
runRowAction(r, 'Double handling: No', () =>
|
|
warehouseService.setDoubleHandling(r.bookingId as string, false),
|
|
)
|
|
}
|
|
>
|
|
No
|
|
</Menu.Item>
|
|
<Menu.Divider />
|
|
<Menu.Item leftSection={<PackageCheck size={14} />} onClick={() => setFeeItem(toInventoryItem(r))}>
|
|
Storage / fee preview
|
|
</Menu.Item>
|
|
<Menu.Item leftSection={<History size={14} />} onClick={() => setHistoryItem(toInventoryItem(r))}>
|
|
History
|
|
</Menu.Item>
|
|
</Menu.Dropdown>
|
|
</Menu>
|
|
</Group>
|
|
</Table.Td>
|
|
</Table.Tr>
|
|
{expandedRow === r.id && (
|
|
<BookingItemsExpansion
|
|
bookingId={r.bookingId}
|
|
colSpan={16}
|
|
bulkFallback={r.cargoType ? `Bulk cargo — ${r.cargoType}, ${formatNumber(Number(r.weight))} t` : undefined}
|
|
/>
|
|
)}
|
|
</Fragment>
|
|
))}
|
|
</Table.Tbody>
|
|
</Table>
|
|
</Table.ScrollContainer>
|
|
)}
|
|
|
|
<InspectionReportModal
|
|
opened={Boolean(inspectId)}
|
|
onClose={() => setInspectId(null)}
|
|
inventoryId={inspectId}
|
|
/>
|
|
<InventoryDetailModal opened={Boolean(viewItem)} onClose={() => setViewItem(null)} item={viewItem} />
|
|
<InventoryHistoryModal opened={Boolean(historyItem)} onClose={() => setHistoryItem(null)} item={historyItem} />
|
|
<FeePreviewModal
|
|
opened={Boolean(feeItem)}
|
|
onClose={() => setFeeItem(null)}
|
|
inventoryId={feeItem?.id ?? null}
|
|
/>
|
|
<ReleaseOrderModal opened={Boolean(releaseItem)} onClose={() => setReleaseItem(null)} item={releaseItem} />
|
|
<StoreInventoryModal opened={Boolean(storeItem)} onClose={() => setStoreItem(null)} item={storeItem} />
|
|
<MoveInventoryModal opened={Boolean(moveItem)} onClose={() => setMoveItem(null)} item={moveItem} />
|
|
<DeliverInventoryModal opened={Boolean(deliverItem)} onClose={() => setDeliverItem(null)} item={deliverItem} />
|
|
<ContainerItemsModal
|
|
opened={Boolean(containerItemsItem)}
|
|
onClose={() => setContainerItemsItem(null)}
|
|
bookingId={containerItemsItem?.booking?.id ?? null}
|
|
bookingReference={containerItemsItem?.booking?.reference ?? null}
|
|
/>
|
|
<ConfirmActionModal action={confirmAction} onClose={() => setConfirmAction(null)} />
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Import Dispatch Queue (Batch 10): inspected import items that are PICKUP_READY (READY_FOR_PICKUP),
|
|
* awaiting Customer Pickup (release → deliver), Store, or Dispatch. Reuses InventoryWorkbench so all
|
|
* existing actions + modals stay intact; Last Mile shows only when the booking requested door delivery.
|
|
* Nothing is stored automatically — Store is an explicit operator action.
|
|
*/
|
|
function ImportDispatchQueueTab({ enabled }: { enabled: boolean }) {
|
|
const { toast } = useToast();
|
|
const { data: items = [], isLoading } = useQuery(
|
|
api.warehouses.listInventory.queryOptions({
|
|
input: { filter: enabled ? { status: 'READY_FOR_PICKUP' } : undefined },
|
|
}),
|
|
);
|
|
|
|
return (
|
|
<Stack gap="sm" mt="sm">
|
|
<Text size="sm" c="dimmed">
|
|
<b>{items.length}</b> pickup-ready item{items.length !== 1 ? 's' : ''}
|
|
</Text>
|
|
<InventoryWorkbench
|
|
items={items}
|
|
isLoading={isLoading}
|
|
onLastMile={(it) =>
|
|
toast({
|
|
title: 'Last mile delivery',
|
|
description: `Door delivery for ${it.booking?.reference ?? it.id} — handled in the next batch.`,
|
|
})
|
|
}
|
|
/>
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
type WarehouseFlowDirection = 'IMPORT' | 'EXPORT' | 'BOTH';
|
|
type ImportWarehouseTab = 'arrive-queue' | 'unloaded-queue' | 'dispatch-queue' | 'locate-booking';
|
|
type ExportWarehouseTab = 'receive-queue' | 'received' | 'ready-to-load' | 'loaded' | 'dispatch-queue' | 'locate-booking';
|
|
|
|
interface WarehouseQueueTab<TValue extends string> {
|
|
value: TValue;
|
|
label: string;
|
|
icon: React.ReactNode;
|
|
count?: number;
|
|
}
|
|
|
|
interface WarehouseFlowWorkbenchProps {
|
|
direction?: WarehouseFlowDirection;
|
|
enabled?: boolean;
|
|
onChanged?: () => void;
|
|
focusedBookingId?: string;
|
|
focusedBookingLabel?: string;
|
|
}
|
|
|
|
function WarehouseStatCard({
|
|
icon,
|
|
label,
|
|
value,
|
|
sub,
|
|
color,
|
|
}: {
|
|
icon: React.ReactNode;
|
|
label: string;
|
|
value: React.ReactNode;
|
|
sub: string;
|
|
color: string;
|
|
}) {
|
|
return (
|
|
<Card withBorder radius="md" padding="md">
|
|
<Group gap="sm" wrap="nowrap">
|
|
<ThemeIcon color={color} variant="light" size={40} radius="md">
|
|
{icon}
|
|
</ThemeIcon>
|
|
<Stack gap={0} style={{ minWidth: 0 }}>
|
|
<Text fw={800} fz={22} lh={1.1}>
|
|
{value}
|
|
</Text>
|
|
<Text size="sm" fw={600}>
|
|
{label}
|
|
</Text>
|
|
<Text size="xs" c="dimmed">
|
|
{sub}
|
|
</Text>
|
|
</Stack>
|
|
</Group>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function WarehouseQueueTabs<TValue extends string>({
|
|
value,
|
|
onChange,
|
|
tabs,
|
|
}: {
|
|
value: TValue;
|
|
onChange: (value: TValue) => void;
|
|
tabs: WarehouseQueueTab<TValue>[];
|
|
}) {
|
|
return (
|
|
<Tabs
|
|
value={value}
|
|
onChange={(next) => onChange((next as TValue) ?? value)}
|
|
variant="pills"
|
|
color="edr-green"
|
|
keepMounted={false}
|
|
classNames={{ list: 'ov-tablist', tab: 'ov-tab' }}
|
|
>
|
|
<ScrollArea type="auto" scrollbarSize={6} offsetScrollbars="x">
|
|
<Tabs.List style={{ flexWrap: 'nowrap', width: 'max-content' }}>
|
|
{tabs.map((tab) => {
|
|
const active = value === tab.value;
|
|
return (
|
|
<Tabs.Tab
|
|
key={tab.value}
|
|
value={tab.value}
|
|
leftSection={tab.icon}
|
|
rightSection={
|
|
tab.count !== undefined ? (
|
|
<Badge
|
|
size="sm"
|
|
radius="sm"
|
|
variant={active ? 'white' : 'light'}
|
|
color={active ? 'edr-green' : 'gray'}
|
|
styles={
|
|
active
|
|
? { root: { background: 'rgba(255,255,255,0.9)', color: '#15805f' } }
|
|
: undefined
|
|
}
|
|
>
|
|
{tab.count}
|
|
</Badge>
|
|
) : undefined
|
|
}
|
|
>
|
|
{tab.label}
|
|
</Tabs.Tab>
|
|
);
|
|
})}
|
|
</Tabs.List>
|
|
</ScrollArea>
|
|
</Tabs>
|
|
);
|
|
}
|
|
|
|
function LocateBookingTab({ enabled }: { enabled: boolean }) {
|
|
const [draft, setDraft] = useState<InventoryInquiryFilter>({});
|
|
const [applied, setApplied] = useState<InventoryInquiryFilter>({});
|
|
const [viewResult, setViewResult] = useState<InventoryInquiryResult | null>(null);
|
|
const hasSearch = Boolean(
|
|
applied.bookingReference ||
|
|
applied.containerNumber ||
|
|
applied.goodsName ||
|
|
applied.cargoType ||
|
|
applied.status,
|
|
);
|
|
const { data: results = [], isFetching } = useInventoryInquiry(applied, enabled && hasSearch);
|
|
const controls = useListControls(results);
|
|
|
|
const normalizeDraft = (): InventoryInquiryFilter => ({
|
|
bookingReference: draft.bookingReference?.trim() || undefined,
|
|
containerNumber: draft.containerNumber?.trim() || undefined,
|
|
goodsName: draft.goodsName?.trim() || undefined,
|
|
cargoType: draft.cargoType?.trim() || undefined,
|
|
status: draft.status,
|
|
});
|
|
|
|
const runSearch = () => setApplied(normalizeDraft());
|
|
const reset = () => {
|
|
setDraft({});
|
|
setApplied({});
|
|
};
|
|
|
|
return (
|
|
<Stack gap="md" mt="sm">
|
|
<Group gap="sm" wrap="wrap">
|
|
<TextInput
|
|
label="Booking reference"
|
|
placeholder="e.g. BK-2026-000051"
|
|
value={draft.bookingReference ?? ''}
|
|
onChange={(e) => setDraft((filter) => ({ ...filter, bookingReference: e.currentTarget.value || undefined }))}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') runSearch();
|
|
}}
|
|
w={230}
|
|
/>
|
|
<TextInput
|
|
label="Container number"
|
|
placeholder="e.g. MSKU1234567"
|
|
value={draft.containerNumber ?? ''}
|
|
onChange={(e) => setDraft((filter) => ({ ...filter, containerNumber: e.currentTarget.value || undefined }))}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') runSearch();
|
|
}}
|
|
w={220}
|
|
/>
|
|
<TextInput
|
|
label="Goods / cargo"
|
|
placeholder="Coffee, steel, etc."
|
|
value={draft.goodsName ?? draft.cargoType ?? ''}
|
|
onChange={(e) => {
|
|
const value = e.currentTarget.value || undefined;
|
|
setDraft((filter) => ({ ...filter, goodsName: value, cargoType: value }));
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') runSearch();
|
|
}}
|
|
w={200}
|
|
/>
|
|
<Select
|
|
label="Status"
|
|
placeholder="Any"
|
|
clearable
|
|
data={inventoryStatusOptions}
|
|
value={draft.status ?? null}
|
|
onChange={(value) => setDraft((filter) => ({ ...filter, status: value as InventoryInquiryFilter['status'] }))}
|
|
w={190}
|
|
/>
|
|
</Group>
|
|
<Group gap="xs">
|
|
<Button leftSection={<Search size={16} />} onClick={runSearch}>
|
|
Locate Booking
|
|
</Button>
|
|
<Button variant="default" onClick={reset}>
|
|
Reset
|
|
</Button>
|
|
</Group>
|
|
|
|
{isFetching ? (
|
|
<Group justify="center" py="lg">
|
|
<Loader size="sm" />
|
|
</Group>
|
|
) : !hasSearch ? (
|
|
<Text c="dimmed" ta="center" py="lg" size="sm">
|
|
Search by booking reference, container number, cargo or status to locate inventory.
|
|
</Text>
|
|
) : results.length === 0 ? (
|
|
<Text c="dimmed" ta="center" py="lg" size="sm">
|
|
No inventory found for the current filters.
|
|
</Text>
|
|
) : (
|
|
<Stack gap="sm">
|
|
<WarehouseInquiryTable results={controls.pagedRows} onView={setViewResult} />
|
|
<RuleEngineListFooter
|
|
pagination={controls.pagination}
|
|
pageCount={controls.pageCount}
|
|
totalCount={controls.totalCount}
|
|
itemLabel="results"
|
|
onPaginationChange={controls.setPagination}
|
|
/>
|
|
</Stack>
|
|
)}
|
|
|
|
<InventoryInquiryDetailModal
|
|
opened={Boolean(viewResult)}
|
|
onClose={() => setViewResult(null)}
|
|
result={viewResult}
|
|
/>
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
function ImportWarehouseTabs({ enabled, onChanged }: { enabled: boolean; onChanged?: () => void }) {
|
|
const [activeTab, setActiveTab] = useState<ImportWarehouseTab>('arrive-queue');
|
|
const { data: arriveRows = [] } = useQuery(api.warehouses.importArriveQueue.queryOptions({ enabled }));
|
|
const { data: unloadedRows = [] } = useQuery(api.warehouses.importUnloadedQueue.queryOptions({ enabled }));
|
|
const { data: dispatchRows = [] } = useQuery(api.warehouses.importPickupReadyQueue.queryOptions({ enabled }));
|
|
const totalBookings = arriveRows.reduce((sum, t) => sum + t.totalBookings, 0);
|
|
const tabs: WarehouseQueueTab<ImportWarehouseTab>[] = [
|
|
{
|
|
value: 'arrive-queue',
|
|
label: 'Arrival Queue',
|
|
icon: <PackageOpen size={17} strokeWidth={1.85} />,
|
|
count: arriveRows.length,
|
|
},
|
|
{
|
|
value: 'unloaded-queue',
|
|
label: 'Unloaded Queue',
|
|
icon: <ClipboardCheck size={17} strokeWidth={1.85} />,
|
|
count: unloadedRows.length,
|
|
},
|
|
{
|
|
value: 'dispatch-queue',
|
|
label: 'Dispatch Queue',
|
|
icon: <Send size={17} strokeWidth={1.85} />,
|
|
count: dispatchRows.length,
|
|
},
|
|
{
|
|
value: 'locate-booking',
|
|
label: 'Locate Booking',
|
|
icon: <Search size={17} strokeWidth={1.85} />,
|
|
},
|
|
];
|
|
|
|
return (
|
|
<Stack gap="md">
|
|
<SimpleGrid cols={{ base: 1, xs: 2, md: 4 }} spacing="sm">
|
|
<WarehouseStatCard icon={<PackageOpen size={18} />} label="Arrived" value={arriveRows.length} sub="Import trains" color="edr-green" />
|
|
<WarehouseStatCard icon={<ClipboardCheck size={18} />} label="Unloaded" value={unloadedRows.length} sub="Import trains" color="blue" />
|
|
<WarehouseStatCard icon={<Send size={18} />} label="Dispatch Ready" value={dispatchRows.length} sub="Import trains" color="violet" />
|
|
<WarehouseStatCard icon={<Calendar size={18} />} label="Total Bookings" value={totalBookings} sub="Across arrived trains" color="orange" />
|
|
</SimpleGrid>
|
|
|
|
<WarehouseQueueTabs value={activeTab} onChange={setActiveTab} tabs={tabs} />
|
|
|
|
{activeTab === 'arrive-queue' && (
|
|
<ImportArriveQueueTab enabled={enabled} onChanged={onChanged} />
|
|
)}
|
|
{activeTab === 'unloaded-queue' && (
|
|
<ImportUnloadedQueueTab enabled={enabled} />
|
|
)}
|
|
{activeTab === 'dispatch-queue' && (
|
|
<ImportDispatchQueueTab enabled={enabled} />
|
|
)}
|
|
{activeTab === 'locate-booking' && (
|
|
<LocateBookingTab enabled={enabled} />
|
|
)}
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
function ExportWarehouseTabs({
|
|
enabled,
|
|
location,
|
|
onChanged,
|
|
focusedBookingId,
|
|
focusedBookingLabel,
|
|
}: {
|
|
enabled: boolean;
|
|
location: Location;
|
|
onChanged?: () => void;
|
|
focusedBookingId?: string;
|
|
focusedBookingLabel?: string;
|
|
}) {
|
|
const [activeTab, setActiveTab] = useState<ExportWarehouseTab>('receive-queue');
|
|
const { data: eligibleRows = [] } = useQuery(
|
|
api.warehouses.eligibleBookings.queryOptions({
|
|
input: { direction: 'EXPORT' },
|
|
enabled,
|
|
}),
|
|
);
|
|
const { data: receivedRows = [] } = useQuery(api.warehouses.receivedExport.queryOptions({ enabled }));
|
|
const { data: readyRows = [] } = useQuery(api.warehouses.readyToLoadExport.queryOptions({ enabled }));
|
|
const { data: loadedRows = [] } = useQuery(api.warehouses.loadedExport.queryOptions({ enabled }));
|
|
const exportEligibleCount = useMemo(
|
|
() => eligibleRows.filter((row) => row.direction === 'EXPORT').length,
|
|
[eligibleRows],
|
|
);
|
|
const tabs: WarehouseQueueTab<ExportWarehouseTab>[] = [
|
|
{
|
|
value: 'receive-queue',
|
|
label: 'Receive to Warehouse',
|
|
icon: <Truck size={17} strokeWidth={1.85} />,
|
|
count: exportEligibleCount,
|
|
},
|
|
{
|
|
value: 'received',
|
|
label: 'Received',
|
|
icon: <ClipboardCheck size={17} strokeWidth={1.85} />,
|
|
count: receivedRows.length,
|
|
},
|
|
{
|
|
value: 'ready-to-load',
|
|
label: 'Ready To Load',
|
|
icon: <Train size={17} strokeWidth={1.85} />,
|
|
count: readyRows.length,
|
|
},
|
|
{
|
|
value: 'loaded',
|
|
label: 'Loaded',
|
|
icon: <PackageCheck size={17} strokeWidth={1.85} />,
|
|
count: loadedRows.length,
|
|
},
|
|
{
|
|
value: 'dispatch-queue',
|
|
label: 'Dispatch Queue',
|
|
icon: <Send size={17} strokeWidth={1.85} />,
|
|
count: loadedRows.length,
|
|
},
|
|
{
|
|
value: 'locate-booking',
|
|
label: 'Locate Booking',
|
|
icon: <Search size={17} strokeWidth={1.85} />,
|
|
},
|
|
];
|
|
|
|
return (
|
|
<Stack gap="md">
|
|
<SimpleGrid cols={{ base: 1, xs: 2, md: 5 }} spacing="sm">
|
|
<WarehouseStatCard icon={<Truck size={18} />} label="Eligible" value={exportEligibleCount} sub="Export bookings" color="edr-green" />
|
|
<WarehouseStatCard icon={<ClipboardCheck size={18} />} label="Received" value={receivedRows.length} sub="Export bookings" color="blue" />
|
|
<WarehouseStatCard icon={<Train size={18} />} label="Ready To Load" value={readyRows.length} sub="Export bookings" color="teal" />
|
|
<WarehouseStatCard icon={<PackageCheck size={18} />} label="Loaded" value={loadedRows.length} sub="Export bookings" color="indigo" />
|
|
<WarehouseStatCard icon={<Send size={18} />} label="Dispatch Ready" value={loadedRows.length} sub="Export bookings" color="violet" />
|
|
</SimpleGrid>
|
|
|
|
<WarehouseQueueTabs value={activeTab} onChange={setActiveTab} tabs={tabs} />
|
|
|
|
{activeTab === 'receive-queue' && (
|
|
<EligibleTab
|
|
direction="EXPORT"
|
|
location={location}
|
|
enabled={enabled}
|
|
onChanged={onChanged}
|
|
focusedBookingId={focusedBookingId}
|
|
focusedBookingLabel={focusedBookingLabel}
|
|
/>
|
|
)}
|
|
{activeTab === 'received' && (
|
|
<ExportReceivedTab enabled={enabled} onChanged={onChanged} />
|
|
)}
|
|
{activeTab === 'ready-to-load' && (
|
|
<ReadyToLoadTab enabled={enabled} onChanged={onChanged} />
|
|
)}
|
|
{activeTab === 'loaded' && (
|
|
<LoadedExportTab enabled={enabled} dispatchable={false} onChanged={onChanged} />
|
|
)}
|
|
{activeTab === 'dispatch-queue' && (
|
|
<LoadedExportTab enabled={enabled} dispatchable onChanged={onChanged} />
|
|
)}
|
|
{activeTab === 'locate-booking' && (
|
|
<LocateBookingTab enabled={enabled} />
|
|
)}
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
export function WarehouseFlowWorkbench({
|
|
direction = 'BOTH',
|
|
enabled = true,
|
|
onChanged,
|
|
focusedBookingId,
|
|
focusedBookingLabel,
|
|
}: WarehouseFlowWorkbenchProps) {
|
|
const [location, setLocation] = useState<Location>({ warehouseId: '', yardId: '', zoneId: '' });
|
|
const [tab, setTab] = useState<Exclude<WarehouseFlowDirection, 'BOTH'>>(
|
|
direction === 'EXPORT' ? 'EXPORT' : 'IMPORT',
|
|
);
|
|
const activeDirection = direction === 'BOTH' ? tab : direction;
|
|
|
|
// Match the yard/zone list to the freight being received (container → container
|
|
// yards, etc). Same query key as the export tab, so React Query dedupes it.
|
|
const { data: eligibleForLocation = [] } = useQuery(
|
|
api.warehouses.eligibleBookings.queryOptions({
|
|
input: { direction: activeDirection },
|
|
enabled: enabled && activeDirection === 'EXPORT',
|
|
}),
|
|
);
|
|
const { yardTypes: allowedYardTypes, zoneTypes: allowedZoneTypes } = useMemo(
|
|
() =>
|
|
yardZoneTypesForFreights(
|
|
eligibleForLocation
|
|
.filter((r) => r.direction === activeDirection)
|
|
.map((r) => r.freightType),
|
|
),
|
|
[eligibleForLocation, activeDirection],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (enabled) setLocation({ warehouseId: '', yardId: '', zoneId: '' });
|
|
}, [enabled, direction]);
|
|
|
|
return (
|
|
<Stack gap="md">
|
|
{activeDirection === 'EXPORT' && (
|
|
<LocationSelects
|
|
value={location}
|
|
onChange={setLocation}
|
|
allowedYardTypes={allowedYardTypes}
|
|
allowedZoneTypes={allowedZoneTypes}
|
|
/>
|
|
)}
|
|
|
|
{direction === 'BOTH' ? (
|
|
<Tabs value={tab} onChange={(v) => setTab((v as 'IMPORT' | 'EXPORT') ?? 'IMPORT')}>
|
|
<Tabs.List>
|
|
<Tabs.Tab value="IMPORT" leftSection={<PackageSearch size={16} />}>
|
|
Import
|
|
</Tabs.Tab>
|
|
<Tabs.Tab value="EXPORT" leftSection={<Truck size={16} />}>
|
|
Export
|
|
</Tabs.Tab>
|
|
</Tabs.List>
|
|
|
|
<Tabs.Panel value="IMPORT">
|
|
<ImportWarehouseTabs enabled={enabled} onChanged={onChanged} />
|
|
</Tabs.Panel>
|
|
<Tabs.Panel value="EXPORT">
|
|
<ExportWarehouseTabs
|
|
enabled={enabled}
|
|
location={location}
|
|
onChanged={onChanged}
|
|
focusedBookingId={focusedBookingId}
|
|
focusedBookingLabel={focusedBookingLabel}
|
|
/>
|
|
</Tabs.Panel>
|
|
</Tabs>
|
|
) : activeDirection === 'IMPORT' ? (
|
|
<ImportWarehouseTabs enabled={enabled} onChanged={onChanged} />
|
|
) : (
|
|
<ExportWarehouseTabs
|
|
enabled={enabled}
|
|
location={location}
|
|
onChanged={onChanged}
|
|
focusedBookingId={focusedBookingId}
|
|
focusedBookingLabel={focusedBookingLabel}
|
|
/>
|
|
)}
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
/** New bulk Receive: Import / Export tabs with eligible PAID bookings. */
|
|
function BulkReceiveModal({ opened, onClose, onReceived, bookingId, bookingLabel, direction = 'BOTH' }: ReceiveInventoryModalProps) {
|
|
return (
|
|
<Modal opened={opened} onClose={onClose} title="Receive at warehouse" centered size="80rem">
|
|
<Stack gap="md">
|
|
<WarehouseFlowWorkbench
|
|
enabled={opened}
|
|
direction={direction}
|
|
onChanged={onReceived}
|
|
focusedBookingId={bookingId}
|
|
focusedBookingLabel={bookingLabel}
|
|
/>
|
|
|
|
<Group justify="flex-end" mt="sm">
|
|
<Button variant="default" onClick={onClose}>
|
|
Close
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
interface SingleFormState {
|
|
warehouseId: string;
|
|
yardId: string;
|
|
zoneId: string;
|
|
quantity: number | '';
|
|
weight: number | '';
|
|
volume: number | '';
|
|
notes: string;
|
|
}
|
|
|
|
/** Legacy single-booking receive — used when a specific bookingId is supplied. */
|
|
function SingleBookingReceiveModal({
|
|
opened,
|
|
onClose,
|
|
bookingId,
|
|
bookingLabel,
|
|
onReceived,
|
|
}: ReceiveInventoryModalProps) {
|
|
const { toast } = useToast();
|
|
const receiveMutation = useMutation(
|
|
api.warehouses.receiveInventory.mutationOptions(),
|
|
);
|
|
const [selectedBooking, setSelectedBooking] = useState(bookingId ?? '');
|
|
const [form, setForm] = useState<SingleFormState>({
|
|
warehouseId: '',
|
|
yardId: '',
|
|
zoneId: '',
|
|
quantity: '',
|
|
weight: '',
|
|
volume: '',
|
|
notes: '',
|
|
});
|
|
const [truckForm, setTruckForm] = useState<TruckEntranceFormState>(emptyTruckEntrance());
|
|
|
|
useEffect(() => {
|
|
if (opened) {
|
|
setSelectedBooking(bookingId ?? '');
|
|
setForm({ warehouseId: '', yardId: '', zoneId: '', quantity: '', weight: '', volume: '', notes: '' });
|
|
setTruckForm(emptyTruckEntrance());
|
|
}
|
|
}, [opened, bookingId]);
|
|
|
|
const location: Location = { warehouseId: form.warehouseId, yardId: form.yardId, zoneId: form.zoneId };
|
|
|
|
const handleSubmit = async () => {
|
|
if (!selectedBooking.trim()) {
|
|
toast({ variant: 'destructive', title: 'Booking is required' });
|
|
return;
|
|
}
|
|
if (!form.warehouseId || !form.yardId || !form.zoneId) {
|
|
toast({ variant: 'destructive', title: 'Select warehouse, yard and zone' });
|
|
return;
|
|
}
|
|
if (!truckForm.truckPlateNumber.trim() || !truckForm.driverName.trim() || !truckForm.driverPhone.trim() || truckForm.entranceTareWeightKg === '') {
|
|
toast({ variant: 'destructive', title: 'Truck, driver and entrance tare weight are required' });
|
|
return;
|
|
}
|
|
const payload: ReceiveInventoryPayload = {
|
|
bookingId: selectedBooking.trim(),
|
|
warehouseId: form.warehouseId,
|
|
yardId: form.yardId,
|
|
zoneId: form.zoneId,
|
|
quantity: 0,
|
|
weight: 0,
|
|
volume: form.volume === '' ? undefined : Number(form.volume),
|
|
notes: form.notes.trim() || undefined,
|
|
truckEntrance: toTruckEntrancePayload(truckForm),
|
|
};
|
|
try {
|
|
await receiveMutation.mutateAsync(payload);
|
|
toast({ title: 'Inventory received' });
|
|
onReceived?.();
|
|
onClose();
|
|
} catch (error) {
|
|
toast({ variant: 'destructive', title: 'Receive failed', description: extractErrorMessage(error) });
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Modal opened={opened} onClose={onClose} title="Receive at warehouse" centered size="lg">
|
|
<Stack gap="md">
|
|
{bookingId ? (
|
|
<TextInput label="Booking" value={bookingLabel ?? bookingId} readOnly />
|
|
) : (
|
|
<BookingSelect label="Booking" value={selectedBooking} onChange={setSelectedBooking} />
|
|
)}
|
|
|
|
<LocationSelects value={location} onChange={(next) => setForm((f) => ({ ...f, ...next }))} />
|
|
|
|
<Alert icon={<Info size={16} />} color="blue" variant="light">
|
|
<Text size="sm">
|
|
Customer, TIN, phone, quantity and weight are pulled from the selected booking when the GRN is generated.
|
|
</Text>
|
|
</Alert>
|
|
|
|
<Group grow>
|
|
<NumberInput
|
|
label="Volume (m³)"
|
|
placeholder="Optional"
|
|
min={0}
|
|
value={form.volume}
|
|
onChange={(value) => setForm((f) => ({ ...f, volume: value === '' ? '' : Number(value) }))}
|
|
/>
|
|
</Group>
|
|
|
|
<TruckEntranceFields value={truckForm} onChange={setTruckForm} />
|
|
|
|
<Textarea
|
|
label="Notes"
|
|
placeholder="Optional notes"
|
|
autosize
|
|
minRows={2}
|
|
value={form.notes}
|
|
onChange={(e) => {
|
|
const v = e.currentTarget.value;
|
|
setForm((f) => ({ ...f, notes: v }));
|
|
}}
|
|
/>
|
|
|
|
<Group justify="flex-end" mt="sm">
|
|
<Button variant="default" onClick={onClose} disabled={receiveMutation.isPending}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={handleSubmit} loading={receiveMutation.isPending}>
|
|
Receive inventory and generate GRN
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
export function ReceiveInventoryModal(props: ReceiveInventoryModalProps) {
|
|
// Locked to a booking → legacy single receive; otherwise the Import/Export bulk flow.
|
|
return props.bookingId && props.mode !== 'bulk' ? <SingleBookingReceiveModal {...props} /> : <BulkReceiveModal {...props} />;
|
|
}
|