mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 12:58:13 +00:00
GRN-<DIR>-<DATE>-<REF8> carried no owner, so a note couldn't be identified by who owns the cargo. Add an owner segment sourced from the booking's company at every generation point (import, export, facility, manual receive), keep REF8 for uniqueness, and label the GRN document row Owner's Name.
3467 lines
131 KiB
TypeScript
3467 lines
131 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,
|
|
Check,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
ClipboardCheck,
|
|
Eye,
|
|
FileText,
|
|
History,
|
|
Info,
|
|
Layers,
|
|
MapPin,
|
|
MoreHorizontal,
|
|
PackageCheck,
|
|
PackageOpen,
|
|
PackageSearch,
|
|
Send,
|
|
Search,
|
|
Train,
|
|
Truck,
|
|
} from 'lucide-react';
|
|
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
|
|
import { 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, warehousesAtStation, yardsForBooking } 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 isImportContainerFreight = (freightType: string | null | undefined) =>
|
|
(freightType ?? '').toUpperCase() === 'CONTAINER';
|
|
|
|
/**
|
|
* Yard/zone types valid for the freight being received — used to filter the receive
|
|
* location pickers so the yard list matches the cargo. Container freight → container
|
|
* yards only; bulk / break-bulk → bulk, general-cargo, hazardous, or cold-storage.
|
|
* Union across the given freight types; empty input → no restriction (show all).
|
|
*/
|
|
const yardZoneTypesForFreights = (freightTypes: Array<string | null | undefined>) => {
|
|
const yardTypes = new Set<string>();
|
|
const zoneTypes = new Set<string>();
|
|
for (const freightType of freightTypes) {
|
|
const normalized = (freightType ?? '').toUpperCase();
|
|
if (!normalized) continue;
|
|
if (normalized === 'CONTAINER') {
|
|
yardTypes.add('CONTAINER_YARD');
|
|
zoneTypes.add('CONTAINER_ZONE');
|
|
} else {
|
|
['BULK_YARD', 'GENERAL_CARGO_YARD', 'HAZARDOUS_YARD', 'COLD_STORAGE_YARD'].forEach((t) => yardTypes.add(t));
|
|
['BULK_ZONE', 'GENERAL_CARGO_ZONE', 'HAZARDOUS_ZONE', 'COLD_STORAGE_ZONE'].forEach((t) => zoneTypes.add(t));
|
|
}
|
|
}
|
|
return { yardTypes: [...yardTypes], zoneTypes: [...zoneTypes] };
|
|
};
|
|
|
|
const isImportUnloadPending = (item: ImportTrainItem) =>
|
|
!item.currentStatus || item.currentStatus === 'RECEIVED';
|
|
|
|
/** Assigned bookings/items for an arrived import train with per-booking unload locations. */
|
|
function ImportTrainDetailTable({
|
|
train,
|
|
warehouses,
|
|
yards,
|
|
zones,
|
|
assignments,
|
|
onAssignmentChange,
|
|
onReadyChange,
|
|
}: {
|
|
train: ImportTrain;
|
|
warehouses: Warehouse[];
|
|
yards: WarehouseYard[];
|
|
zones: WarehouseZone[];
|
|
assignments: Record<string, ImportUnloadAssignmentDraft>;
|
|
onAssignmentChange: (bookingId: string, draft: ImportUnloadAssignmentDraft) => void;
|
|
onReadyChange: (ready: boolean) => void;
|
|
}) {
|
|
const { data: items = [], isLoading } = useQuery(
|
|
api.warehouses.importTrainItems.queryOptions({
|
|
input: { scheduleId: train.scheduleId },
|
|
enabled: Boolean(train.scheduleId),
|
|
}),
|
|
);
|
|
// A train only ever unloads at the warehouse actually sitting at its
|
|
// destination station — Indode's train never offers Sebeta's warehouse.
|
|
const scopedWarehouses = useMemo(
|
|
() => warehousesAtStation(warehouses, train.destinationStationId),
|
|
[warehouses, train.destinationStationId],
|
|
);
|
|
const warehouseOptions = useMemo(
|
|
() => scopedWarehouses.map((warehouse) => ({ value: warehouse.id, label: `${warehouse.name} (${warehouse.code})` })),
|
|
[scopedWarehouses],
|
|
);
|
|
// With exactly one warehouse at the station there is nothing to choose —
|
|
// pre-fill it so staff only has to pick yard/zone, not re-discover Indode.
|
|
useEffect(() => {
|
|
if (scopedWarehouses.length !== 1) return;
|
|
const onlyWarehouseId = scopedWarehouses[0].id;
|
|
items.filter(isImportUnloadPending).forEach((item) => {
|
|
if (!assignments[item.bookingId]?.warehouseId) {
|
|
onAssignmentChange(item.bookingId, { ...assignments[item.bookingId], warehouseId: onlyWarehouseId });
|
|
}
|
|
});
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [scopedWarehouses, items]);
|
|
|
|
// Once a booking's warehouse is known, its yard (and then zone) follow from
|
|
// what the cargo actually is — a Wheat booking only ever has one candidate
|
|
// yard (Dry Bulk) once Indode's real yard layout is configured, so staff
|
|
// never see a picker for something that isn't actually a choice.
|
|
useEffect(() => {
|
|
items.filter(isImportUnloadPending).forEach((item) => {
|
|
const draft = assignments[item.bookingId];
|
|
if (!draft?.warehouseId) return;
|
|
|
|
if (!draft.yardId) {
|
|
const candidateYards = yardsForBooking(yards, {
|
|
warehouseId: draft.warehouseId,
|
|
freightType: item.freightType,
|
|
tradeDirection: 'IMPORT',
|
|
cargoTypeCode: item.cargoTypeCode,
|
|
});
|
|
if (candidateYards.length === 1) {
|
|
onAssignmentChange(item.bookingId, { ...draft, yardId: candidateYards[0].id });
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!draft.zoneId) {
|
|
const candidateZones = zones.filter((zone) => zone.yardId === draft.yardId);
|
|
if (candidateZones.length === 1) {
|
|
onAssignmentChange(item.bookingId, { ...draft, zoneId: candidateZones[0].id });
|
|
}
|
|
}
|
|
});
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [assignments, items, yards, zones]);
|
|
|
|
useEffect(() => {
|
|
const pending = items.filter(isImportUnloadPending);
|
|
onReadyChange(
|
|
pending.length > 0 &&
|
|
pending.every((item) => {
|
|
const draft = assignments[item.bookingId];
|
|
return Boolean(draft?.warehouseId && draft.yardId && draft.zoneId);
|
|
}),
|
|
);
|
|
}, [assignments, items]);
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<Group justify="center" py="md">
|
|
<Loader size="sm" />
|
|
</Group>
|
|
);
|
|
}
|
|
if (items.length === 0) {
|
|
return (
|
|
<Text c="dimmed" ta="center" py="md" size="sm">
|
|
No assigned bookings on this train.
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Table withTableBorder verticalSpacing="xs" fz="xs">
|
|
<Table.Thead>
|
|
<Table.Tr>
|
|
<Table.Th>Wagon</Table.Th>
|
|
<Table.Th>Booking Ref</Table.Th>
|
|
<Table.Th>Customer Name</Table.Th>
|
|
<Table.Th>Container #</Table.Th>
|
|
<Table.Th>Cargo Type</Table.Th>
|
|
<Table.Th>Weight</Table.Th>
|
|
<Table.Th>Arrival</Table.Th>
|
|
<Table.Th>Warehouse</Table.Th>
|
|
<Table.Th>Yard</Table.Th>
|
|
<Table.Th>Zone</Table.Th>
|
|
<Table.Th>Inspection</Table.Th>
|
|
<Table.Th>Current Status</Table.Th>
|
|
<Table.Th>Last Mile</Table.Th>
|
|
<Table.Th>Pickup Option</Table.Th>
|
|
</Table.Tr>
|
|
</Table.Thead>
|
|
<Table.Tbody>
|
|
{items.map((it: ImportTrainItem) => {
|
|
const draft = assignments[it.bookingId] ?? {};
|
|
const yardOptions = yardsForBooking(yards, {
|
|
warehouseId: draft.warehouseId,
|
|
freightType: it.freightType,
|
|
tradeDirection: 'IMPORT',
|
|
cargoTypeCode: it.cargoTypeCode,
|
|
}).map((yard) => ({ value: yard.id, label: `${yard.name} (${yard.code})` }));
|
|
// The yard is already scoped to what this cargo can go into — a
|
|
// zone's own type always matches its parent yard's purpose (see the
|
|
// Indode seed migration), so no separate zone-type filter is needed.
|
|
const zoneOptions = zones
|
|
.filter((zone) => zone.yardId === draft.yardId)
|
|
.map((zone) => ({ value: zone.id, label: `${zone.name} (${zone.code})` }));
|
|
const pending = isImportUnloadPending(it);
|
|
|
|
return (
|
|
<Table.Tr key={`${it.bookingId}-${it.sequenceNo ?? 'wagon'}`}>
|
|
<Table.Td>
|
|
<Text size="xs" fw={600}>
|
|
{it.sequenceNo ? `#${it.sequenceNo}` : '-'} {it.wagonNumber ?? ''}
|
|
</Text>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Text size="xs" fw={600}>{it.bookingReference ?? '—'}</Text>
|
|
</Table.Td>
|
|
<Table.Td>{it.customerName ?? '—'}</Table.Td>
|
|
<Table.Td>{it.containerNumber ?? '—'}</Table.Td>
|
|
<Table.Td>{it.cargoType ?? '—'}</Table.Td>
|
|
<Table.Td>{formatNumber(Number(it.weight))}</Table.Td>
|
|
<Table.Td>{formatDate(it.arrivalTime)}</Table.Td>
|
|
<Table.Td>
|
|
<Select
|
|
placeholder="Warehouse"
|
|
data={warehouseOptions}
|
|
value={draft.warehouseId ?? null}
|
|
onChange={(value) => onAssignmentChange(it.bookingId, { warehouseId: value ?? undefined })}
|
|
searchable
|
|
disabled={!pending}
|
|
w={210}
|
|
/>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Select
|
|
placeholder={isImportContainerFreight(it.freightType) ? 'Container yard' : 'Bulk yard'}
|
|
data={yardOptions}
|
|
value={draft.yardId ?? null}
|
|
onChange={(value) =>
|
|
onAssignmentChange(it.bookingId, { ...draft, yardId: value ?? undefined, zoneId: undefined })
|
|
}
|
|
searchable
|
|
disabled={!pending || !draft.warehouseId}
|
|
w={190}
|
|
/>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Select
|
|
placeholder={isImportContainerFreight(it.freightType) ? 'Container zone' : 'Bulk zone'}
|
|
data={zoneOptions}
|
|
value={draft.zoneId ?? null}
|
|
onChange={(value) => onAssignmentChange(it.bookingId, { ...draft, zoneId: value ?? undefined })}
|
|
searchable
|
|
disabled={!pending || !draft.yardId}
|
|
w={190}
|
|
/>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Badge size="xs" variant="light" color={it.inspectionStatus === 'PASSED' ? 'green' : 'gray'}>
|
|
{it.inspectionStatus ?? 'Not inspected'}
|
|
</Badge>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Badge size="xs" variant="light" color="gray">{it.currentStatus ?? '—'}</Badge>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Badge size="xs" variant="light" color={it.lastMileRequested ? 'blue' : 'gray'}>
|
|
{it.lastMileRequested ? 'Yes' : 'No'}
|
|
</Badge>
|
|
</Table.Td>
|
|
<Table.Td>{it.pickupOption}</Table.Td>
|
|
</Table.Tr>
|
|
);
|
|
})}
|
|
</Table.Tbody>
|
|
</Table>
|
|
);
|
|
}
|
|
|
|
/** Import Arrive Queue: arrived IMPORT trains, with Open (detail) + Auto Unload per train. */
|
|
const getPendingUnloadBookings = (train: ImportTrain) =>
|
|
train.pendingUnloadBookings ?? train.totalBookings;
|
|
|
|
const isFullyUnloaded = (train: ImportTrain) =>
|
|
Boolean(train.fullyUnloaded) || (train.totalBookings > 0 && getPendingUnloadBookings(train) === 0);
|
|
|
|
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. */}
|
|
{/* Stays visible after the first exit — multi-truck bookings
|
|
weigh each truck in and out until all have left. */}
|
|
{r.currentStatus === 'READY_FOR_PICKUP' && r.hasAssignedTruck && (
|
|
<Button
|
|
size="compact-xs"
|
|
variant="light"
|
|
color="yellow"
|
|
leftSection={<Truck size={14} />}
|
|
onClick={() => setReleaseItem(toInventoryItem(r))}
|
|
>
|
|
{r.releaseOrderReference ? 'Truck Arrival / Leaving' : 'Truck Arrival'}
|
|
</Button>
|
|
)}
|
|
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
|
|
<Button
|
|
size="compact-xs"
|
|
variant="light"
|
|
color="orange"
|
|
leftSection={<FileText size={14} />}
|
|
loading={busyId === r.id}
|
|
onClick={() => openReleaseDocument(r)}
|
|
>
|
|
Exit Paper
|
|
</Button>
|
|
)}
|
|
<Menu shadow="md" width={240} position="bottom-end" withinPortal>
|
|
<Menu.Target>
|
|
<ActionIcon variant="subtle" color="gray" aria-label="More actions" loading={busyId === r.id}>
|
|
<MoreHorizontal size={16} />
|
|
</ActionIcon>
|
|
</Menu.Target>
|
|
<Menu.Dropdown>
|
|
{r.currentStatus === 'UNLOADED' && (
|
|
<Menu.Item leftSection={<MapPin size={14} />} onClick={() => setStoreItem(toInventoryItem(r))}>
|
|
Store…
|
|
</Menu.Item>
|
|
)}
|
|
{r.currentStatus !== 'UNLOADED' && (
|
|
<Menu.Item leftSection={<ArrowRightLeft size={14} />} onClick={() => setMoveItem(toInventoryItem(r))}>
|
|
Move…
|
|
</Menu.Item>
|
|
)}
|
|
{['UNLOADED', 'STORED'].includes(r.currentStatus) && r.inspectionStatus === 'PASSED' && (
|
|
<Menu.Item onClick={() => runRowAction(r, 'Ready for pickup', () => readyMutation.mutateAsync(r.id))}>
|
|
Ready for pickup
|
|
</Menu.Item>
|
|
)}
|
|
{r.currentStatus === 'READY_FOR_PICKUP' && (
|
|
<Menu.Item
|
|
leftSection={<Truck size={14} />}
|
|
disabled={!r.hasAssignedTruck}
|
|
onClick={() => setReleaseItem(toInventoryItem(r))}
|
|
>
|
|
{r.hasAssignedTruck
|
|
? r.releaseOrderReference
|
|
? 'Truck arrival / leaving'
|
|
: 'Truck arrival'
|
|
: 'Truck arrival — assign a truck first'}
|
|
</Menu.Item>
|
|
)}
|
|
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
|
|
<Menu.Item leftSection={<FileText size={14} />} onClick={() => openReleaseDocument(r)}>
|
|
Exit paper
|
|
</Menu.Item>
|
|
)}
|
|
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
|
|
<Menu.Item onClick={() => setDeliverItem(toInventoryItem(r))}>Deliver</Menu.Item>
|
|
)}
|
|
{r.inspectionStatus === 'PASSED' && (
|
|
<Menu.Item leftSection={<FileText size={14} />} onClick={() => openHandoverDocument(r)}>
|
|
{r.handoverDocumentReference ? 'View handover' : 'Handover'}
|
|
</Menu.Item>
|
|
)}
|
|
<Menu.Item onClick={() => setInspectId(r.id)}>Inspect / report</Menu.Item>
|
|
{/* Double handling is decided once the goods are off
|
|
the wagon (every row here is unloaded) — Yes is
|
|
what makes the fee rule bill this booking. */}
|
|
<Menu.Divider />
|
|
<Menu.Label>
|
|
Double handling —{' '}
|
|
{r.doubleHandling == null ? 'not set' : r.doubleHandling ? 'Yes' : 'No'}
|
|
</Menu.Label>
|
|
<Menu.Item
|
|
leftSection={
|
|
r.doubleHandling === true ? <Check size={14} /> : <Layers size={14} />
|
|
}
|
|
disabled={!r.bookingId || r.doubleHandling === true}
|
|
onClick={() =>
|
|
runRowAction(r, 'Double handling: Yes — fee rule applies', () =>
|
|
warehouseService.setDoubleHandling(r.bookingId as string, true),
|
|
)
|
|
}
|
|
>
|
|
Yes — apply fee
|
|
</Menu.Item>
|
|
<Menu.Item
|
|
leftSection={
|
|
r.doubleHandling === false ? <Check size={14} /> : <Layers size={14} />
|
|
}
|
|
disabled={!r.bookingId || r.doubleHandling === false}
|
|
onClick={() =>
|
|
runRowAction(r, 'Double handling: No', () =>
|
|
warehouseService.setDoubleHandling(r.bookingId as string, false),
|
|
)
|
|
}
|
|
>
|
|
No
|
|
</Menu.Item>
|
|
<Menu.Divider />
|
|
<Menu.Item leftSection={<PackageCheck size={14} />} onClick={() => setFeeItem(toInventoryItem(r))}>
|
|
Storage / fee preview
|
|
</Menu.Item>
|
|
<Menu.Item leftSection={<History size={14} />} onClick={() => setHistoryItem(toInventoryItem(r))}>
|
|
History
|
|
</Menu.Item>
|
|
</Menu.Dropdown>
|
|
</Menu>
|
|
</Group>
|
|
</Table.Td>
|
|
</Table.Tr>
|
|
{expandedRow === r.id && (
|
|
<BookingItemsExpansion
|
|
bookingId={r.bookingId}
|
|
colSpan={16}
|
|
bulkFallback={r.cargoType ? `Bulk cargo — ${r.cargoType}, ${formatNumber(Number(r.weight))} t` : undefined}
|
|
/>
|
|
)}
|
|
</Fragment>
|
|
))}
|
|
</Table.Tbody>
|
|
</Table>
|
|
</Table.ScrollContainer>
|
|
)}
|
|
|
|
<InspectionReportModal
|
|
opened={Boolean(inspectId)}
|
|
onClose={() => setInspectId(null)}
|
|
inventoryId={inspectId}
|
|
/>
|
|
<InventoryDetailModal opened={Boolean(viewItem)} onClose={() => setViewItem(null)} item={viewItem} />
|
|
<InventoryHistoryModal opened={Boolean(historyItem)} onClose={() => setHistoryItem(null)} item={historyItem} />
|
|
<FeePreviewModal
|
|
opened={Boolean(feeItem)}
|
|
onClose={() => setFeeItem(null)}
|
|
inventoryId={feeItem?.id ?? null}
|
|
/>
|
|
<ReleaseOrderModal opened={Boolean(releaseItem)} onClose={() => setReleaseItem(null)} item={releaseItem} />
|
|
<StoreInventoryModal opened={Boolean(storeItem)} onClose={() => setStoreItem(null)} item={storeItem} />
|
|
<MoveInventoryModal opened={Boolean(moveItem)} onClose={() => setMoveItem(null)} item={moveItem} />
|
|
<DeliverInventoryModal opened={Boolean(deliverItem)} onClose={() => setDeliverItem(null)} item={deliverItem} />
|
|
<ContainerItemsModal
|
|
opened={Boolean(containerItemsItem)}
|
|
onClose={() => setContainerItemsItem(null)}
|
|
bookingId={containerItemsItem?.booking?.id ?? null}
|
|
bookingReference={containerItemsItem?.booking?.reference ?? null}
|
|
/>
|
|
<ConfirmActionModal action={confirmAction} onClose={() => setConfirmAction(null)} />
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Import Dispatch Queue (Batch 10): inspected import items that are PICKUP_READY (READY_FOR_PICKUP),
|
|
* awaiting Customer Pickup (release → deliver), Store, or Dispatch. Reuses InventoryWorkbench so all
|
|
* existing actions + modals stay intact; Last Mile shows only when the booking requested door delivery.
|
|
* Nothing is stored automatically — Store is an explicit operator action.
|
|
*/
|
|
function ImportDispatchQueueTab({ enabled }: { enabled: boolean }) {
|
|
const { toast } = useToast();
|
|
const { data: items = [], isLoading } = useQuery(
|
|
api.warehouses.listInventory.queryOptions({
|
|
input: { filter: enabled ? { status: 'READY_FOR_PICKUP' } : undefined },
|
|
}),
|
|
);
|
|
|
|
return (
|
|
<Stack gap="sm" mt="sm">
|
|
<Text size="sm" c="dimmed">
|
|
<b>{items.length}</b> pickup-ready item{items.length !== 1 ? 's' : ''}
|
|
</Text>
|
|
<InventoryWorkbench
|
|
items={items}
|
|
isLoading={isLoading}
|
|
onLastMile={(it) =>
|
|
toast({
|
|
title: 'Last mile delivery',
|
|
description: `Door delivery for ${it.booking?.reference ?? it.id} — handled in the next batch.`,
|
|
})
|
|
}
|
|
/>
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
type WarehouseFlowDirection = 'IMPORT' | 'EXPORT' | 'BOTH';
|
|
type ImportWarehouseTab = 'arrive-queue' | 'unloaded-queue' | 'dispatch-queue' | 'locate-booking';
|
|
type ExportWarehouseTab = 'receive-queue' | 'received' | 'ready-to-load' | 'loaded' | 'dispatch-queue' | 'locate-booking';
|
|
|
|
interface WarehouseQueueTab<TValue extends string> {
|
|
value: TValue;
|
|
label: string;
|
|
icon: React.ReactNode;
|
|
count?: number;
|
|
}
|
|
|
|
interface WarehouseFlowWorkbenchProps {
|
|
direction?: WarehouseFlowDirection;
|
|
enabled?: boolean;
|
|
onChanged?: () => void;
|
|
focusedBookingId?: string;
|
|
focusedBookingLabel?: string;
|
|
}
|
|
|
|
function 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} />;
|
|
}
|