mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 14:50:57 +00:00
3382 lines
127 KiB
TypeScript
3382 lines
127 KiB
TypeScript
import { Fragment, useEffect, useMemo, useState, type MouseEvent } from 'react';
|
|
import {
|
|
ActionIcon,
|
|
Alert,
|
|
Badge,
|
|
Button,
|
|
Checkbox,
|
|
Group,
|
|
Loader,
|
|
Menu,
|
|
Modal,
|
|
NumberInput,
|
|
ScrollArea,
|
|
Select,
|
|
Stack,
|
|
Table,
|
|
Tabs,
|
|
Text,
|
|
Textarea,
|
|
TextInput,
|
|
Tooltip,
|
|
} from '@mantine/core';
|
|
import {
|
|
ArrowRightLeft,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
ClipboardCheck,
|
|
Eye,
|
|
FileText,
|
|
History,
|
|
Info,
|
|
MapPin,
|
|
MoreHorizontal,
|
|
PackageCheck,
|
|
PackageOpen,
|
|
PackageSearch,
|
|
Send,
|
|
Search,
|
|
Train,
|
|
Truck,
|
|
} from 'lucide-react';
|
|
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
|
|
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 { 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 { 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 { extractDownloadErrorMessage, extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions } from './options';
|
|
import { openPdfBlob } from './pdf';
|
|
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;
|
|
}
|
|
|
|
function GrnDocumentButton({ inventoryId, grnNumber }: { inventoryId: string; grnNumber?: string | null }) {
|
|
const { toast } = useToast();
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
const openDocument = async (event: MouseEvent<HTMLButtonElement>) => {
|
|
event.stopPropagation();
|
|
if (!grnNumber) {
|
|
toast({ variant: 'destructive', title: 'GRN document unavailable', description: 'This item has no GRN number yet.' });
|
|
return;
|
|
}
|
|
setLoading(true);
|
|
const pdfWindow = window.open('', '_blank');
|
|
try {
|
|
const response = await warehouseService.downloadGrnDocument(inventoryId);
|
|
const opened = openPdfBlob(response.data, `grn-${grnNumber}.pdf`, pdfWindow);
|
|
toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' });
|
|
} catch (error) {
|
|
pdfWindow?.close();
|
|
toast({ variant: 'destructive', title: 'GRN document failed', description: await extractDownloadErrorMessage(error) });
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Button
|
|
size="compact-xs"
|
|
variant="subtle"
|
|
color="teal"
|
|
leftSection={<FileText size={12} />}
|
|
disabled={!grnNumber}
|
|
loading={loading}
|
|
onClick={openDocument}
|
|
>
|
|
{grnNumber ?? 'No GRN'}
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
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 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 selectableRows = statusFilteredRows.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 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) => {
|
|
try {
|
|
const r = await bulkReceive.mutateAsync({
|
|
direction,
|
|
...location,
|
|
bookingIds,
|
|
...(truckEntrance ? { truckEntrance } : {}),
|
|
});
|
|
toast({
|
|
title: `${r.receivedCount} received at ${direction === 'EXPORT' ? 'facility' : 'warehouse'}`,
|
|
description: r.results.find((item) => item.grnNumber)?.grnNumber ?? skippedSummary(r.skippedCount, r.results),
|
|
});
|
|
const firstGrn = r.results.find((item) => item.inventoryId && item.grnNumber);
|
|
if (focusedBookingId && firstGrn?.inventoryId && firstGrn.grnNumber) {
|
|
const pdfWindow = window.open('', '_blank');
|
|
try {
|
|
const response = await warehouseService.downloadGrnDocument(firstGrn.inventoryId);
|
|
const opened = openPdfBlob(response.data, `grn-${firstGrn.grnNumber}.pdf`, pdfWindow);
|
|
toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' });
|
|
} catch (error) {
|
|
pdfWindow?.close();
|
|
toast({ variant: 'destructive', title: 'GRN document failed', description: await extractDownloadErrorMessage(error) });
|
|
}
|
|
}
|
|
setSelected(new Set());
|
|
setTruckOpen(false);
|
|
setPendingReceiveIds([]);
|
|
setReceivedAt(null);
|
|
setLockedTruckFields({});
|
|
setPackagingFreightType('MIXED');
|
|
onChanged?.();
|
|
} catch (error) {
|
|
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;
|
|
}
|
|
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);
|
|
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;
|
|
}
|
|
await receiveBookings(pendingReceiveIds, toTruckEntrancePayload(truckForm));
|
|
};
|
|
|
|
|
|
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> / {statusFilteredRows.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>
|
|
) : (
|
|
<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>
|
|
{statusFilteredRows.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>
|
|
)}
|
|
|
|
<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>
|
|
<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}>
|
|
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 pendingRows = rows.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>
|
|
) : (
|
|
<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>
|
|
{rows.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)}>
|
|
Inspect / 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>
|
|
)}
|
|
|
|
<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 { data: rows = [], isLoading } = useQuery(
|
|
api.warehouses.readyToLoadExport.queryOptions({ enabled }),
|
|
);
|
|
const qc = useQueryClient();
|
|
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 allSelected = rows.length > 0 && selected.size === rows.length;
|
|
const someSelected = selected.size > 0 && !allSelected;
|
|
const toggleAll = () => setSelected(allSelected ? new Set() : new Set(rows.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 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) {
|
|
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) });
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Stack gap="sm" mt="sm">
|
|
<Group justify="space-between">
|
|
<Text size="sm" c="dimmed">
|
|
{selected.size > 0 ? (
|
|
<><b>{selected.size}</b> of {rows.length} selected</>
|
|
) : (
|
|
<><b>{rows.length}</b> item{rows.length !== 1 ? 's' : ''} ready to load</>
|
|
)}
|
|
</Text>
|
|
<Button
|
|
size="compact-sm"
|
|
variant="filled"
|
|
color="teal"
|
|
leftSection={<Truck size={14} />}
|
|
disabled={rows.length === 0}
|
|
onClick={() => setTrainPickerOpen(true)}
|
|
>
|
|
{selected.size > 0 ? `Load Selected (${selected.size})` : 'Auto Load Ready Items'}
|
|
</Button>
|
|
</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
|
|
/>
|
|
)}
|
|
<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>
|
|
) : (
|
|
<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>
|
|
{rows.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>
|
|
)}
|
|
</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 allSelected = rows.length > 0 && selected.size === rows.length;
|
|
const someSelected = selected.size > 0 && !allSelected;
|
|
const toggleAll = () => setSelected(allSelected ? new Set() : new Set(rows.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> / {rows.length} loaded
|
|
</>
|
|
) : (
|
|
<>
|
|
<b>{rows.length}</b> item{rows.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>
|
|
) : (
|
|
<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>
|
|
{rows.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>
|
|
)}
|
|
<ConfirmActionModal action={confirmAction} onClose={() => setConfirmAction(null)} />
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
const importLocationTypesForFreight = (freightType: string | null | undefined) => {
|
|
const normalized = (freightType ?? '').toUpperCase();
|
|
if (normalized === 'CONTAINER') {
|
|
return { yardTypes: ['CONTAINER_YARD', 'GENERAL_CARGO_YARD'], zoneTypes: ['CONTAINER_ZONE', 'GENERAL_CARGO_ZONE'] };
|
|
}
|
|
return { yardTypes: ['BULK_YARD', 'GENERAL_CARGO_YARD'], zoneTypes: ['BULK_ZONE', 'GENERAL_CARGO_ZONE'] };
|
|
};
|
|
|
|
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),
|
|
}),
|
|
);
|
|
const warehouseOptions = useMemo(
|
|
() => warehouses.map((warehouse) => ({ value: warehouse.id, label: `${warehouse.name} (${warehouse.code})` })),
|
|
[warehouses],
|
|
);
|
|
|
|
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 { yardTypes, zoneTypes } = importLocationTypesForFreight(it.freightType);
|
|
const yardOptions = yards
|
|
.filter((yard) => yard.warehouseId === draft.warehouseId && yardTypes.includes(yard.type))
|
|
.map((yard) => ({ value: yard.id, label: `${yard.name} (${yard.code})` }));
|
|
const zoneOptions = zones
|
|
.filter((zone) => zone.yardId === draft.yardId && zoneTypes.includes(zone.type))
|
|
.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);
|
|
|
|
function ImportArriveQueueTab({
|
|
enabled,
|
|
onChanged,
|
|
}: {
|
|
enabled: boolean;
|
|
onChanged?: () => void;
|
|
}) {
|
|
const { toast } = useToast();
|
|
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 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">
|
|
<Text size="sm" c="dimmed">
|
|
<b>{trains.length}</b> arrived import train{trains.length !== 1 ? 's' : ''}
|
|
</Text>
|
|
|
|
{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>
|
|
) : (
|
|
<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>
|
|
{trains.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>
|
|
<Text size="xs" c="dimmed">{t.scheduleId.slice(0, 8)}…</Text>
|
|
</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>
|
|
<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}
|
|
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>
|
|
</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>
|
|
)}
|
|
<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. */}
|
|
{r.currentStatus === 'READY_FOR_PICKUP' && !r.releaseDate && r.hasAssignedTruck && (
|
|
<Button
|
|
size="compact-xs"
|
|
variant="light"
|
|
color="yellow"
|
|
leftSection={<Truck size={14} />}
|
|
onClick={() => setReleaseItem(toInventoryItem(r))}
|
|
>
|
|
{r.releaseOrderReference ? 'Truck 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' && !r.releaseDate && (
|
|
<Menu.Item
|
|
leftSection={<Truck size={14} />}
|
|
disabled={!r.hasAssignedTruck}
|
|
onClick={() => setReleaseItem(toInventoryItem(r))}
|
|
>
|
|
{r.hasAssignedTruck
|
|
? r.releaseOrderReference
|
|
? 'Truck 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)}>Inspect / report</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 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 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>
|
|
) : (
|
|
<WarehouseInquiryTable results={results} onView={setViewResult} />
|
|
)}
|
|
|
|
<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 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">
|
|
<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">
|
|
<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} />;
|
|
}
|