Files
edr-platform/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx
hager 076884623e feat(warehouses): load trains from the warehouse, mirroring the schedule
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.
2026-09-03 04:38:46 +00:00

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&apos;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} />;
}