mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 23:28:11 +00:00
Marshaling document Receive Export and import handover to customer
This commit is contained in:
@@ -120,6 +120,26 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
||||
}
|
||||
};
|
||||
|
||||
const openHandoverDocument = async (item: WarehouseInventoryItem) => {
|
||||
setBusyId(item.id);
|
||||
const pdfWindow = window.open('', '_blank');
|
||||
try {
|
||||
const response = await warehouseService.downloadHandoverDocument(item.id);
|
||||
const filename = `handover-${item.booking?.reference ?? item.bookingId ?? item.id}.pdf`;
|
||||
const opened = openPdfBlob(response.data, filename, pdfWindow);
|
||||
toast({ title: opened ? 'Handover document opened' : 'Handover document downloaded' });
|
||||
} catch (error) {
|
||||
pdfWindow?.close();
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Handover document failed',
|
||||
description: extractErrorMessage(error),
|
||||
});
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const acceptLastMile = async (item: WarehouseInventoryItem) => {
|
||||
const reference = item.booking?.reference;
|
||||
if (!reference) {
|
||||
@@ -227,6 +247,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
||||
onInspect={setInspectItem}
|
||||
onFeePreview={setFeeItem}
|
||||
onReleaseDocument={downloadReleaseDocument}
|
||||
onHandoverDocument={openHandoverDocument}
|
||||
onLastMile={onLastMile ? acceptLastMile : undefined}
|
||||
selectedIds={selected}
|
||||
onToggleSelect={toggleSelect}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Fragment, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
Loader,
|
||||
Modal,
|
||||
NumberInput,
|
||||
ScrollArea,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
@@ -15,28 +17,58 @@ import {
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { ChevronDown, ChevronRight, ClipboardCheck, Info, PackageSearch, Train, Truck } from 'lucide-react';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ClipboardCheck,
|
||||
Eye,
|
||||
FileText,
|
||||
History,
|
||||
Info,
|
||||
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 { useInventoryInquiry } from '@/hooks/useWarehouses';
|
||||
import { firstMileService } from '@/services/first-mile.service';
|
||||
import { warehouseService } from '@/services/warehouse.service';
|
||||
import type {
|
||||
EligibleBooking,
|
||||
InventoryInquiryFilter,
|
||||
InventoryInquiryResult,
|
||||
ImportTrain,
|
||||
ImportTrainItem,
|
||||
ImportUnloadedItem,
|
||||
ReadyToLoadRow,
|
||||
ReceiveInventoryPayload,
|
||||
TruckEntrancePayload,
|
||||
WarehouseInventoryItem,
|
||||
} from '@/types/warehouse';
|
||||
import { BookingSelect } from './BookingSelect';
|
||||
import { DeliverInventoryModal } from './DeliverInventoryModal';
|
||||
import { FeePreviewModal } from './FeePreviewModal';
|
||||
import { InspectionReportModal } from './InspectionReportModal';
|
||||
import { InventoryDetailModal } from './InventoryDetailModal';
|
||||
import { InventoryHistoryModal } from './InventoryHistoryModal';
|
||||
import { InventoryWorkbench } from './InventoryWorkbench';
|
||||
import { extractErrorMessage, formatDate, formatNumber } from './options';
|
||||
import { InventoryInquiryDetailModal } from './InventoryInquiryDetailModal';
|
||||
import { ReleaseOrderModal } from './ReleaseOrderModal';
|
||||
import { WarehouseInquiryTable } from './WarehouseInquiryTable';
|
||||
import { extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions } from './options';
|
||||
import { openPdfBlob } from './pdf';
|
||||
import '@/components/overview/overview.css';
|
||||
|
||||
interface ReceiveInventoryModalProps {
|
||||
opened: boolean;
|
||||
@@ -608,6 +640,7 @@ function EligibleTab({
|
||||
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');
|
||||
@@ -717,6 +750,7 @@ function EligibleTab({
|
||||
setSelected(new Set());
|
||||
setTruckOpen(false);
|
||||
setPendingReceiveIds([]);
|
||||
setReceivedAt(null);
|
||||
setLockedTruckFields({});
|
||||
setPackagingFreightType('MIXED');
|
||||
onChanged?.();
|
||||
@@ -749,6 +783,7 @@ function EligibleTab({
|
||||
}
|
||||
const { form, lockedFields, packagingFreightType: nextPackagingFreightType } = truckEntranceFromBookings(selectedRows);
|
||||
setPendingReceiveIds(filteredIds);
|
||||
setReceivedAt(new Date().toISOString());
|
||||
setTruckForm(form);
|
||||
setLockedTruckFields(lockedFields);
|
||||
setPackagingFreightType(nextPackagingFreightType);
|
||||
@@ -807,15 +842,18 @@ function EligibleTab({
|
||||
)}
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="default"
|
||||
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(selectableRows.map((r) => r.id))}
|
||||
onClick={() => openTruckReceive(selected.size > 0 ? [...selected] : selectableRows.map((r) => r.id))}
|
||||
>
|
||||
Receive All Eligible
|
||||
{direction === 'EXPORT' ? 'Receive to Warehouse' : 'Receive All to Warehouse'}
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="default"
|
||||
disabled={!locationReady || selected.size === 0}
|
||||
loading={bulkReceive.isPending}
|
||||
onClick={() => openTruckReceive([...selected])}
|
||||
@@ -859,7 +897,7 @@ function EligibleTab({
|
||||
<Table.Th>Origin</Table.Th>
|
||||
<Table.Th>Destination</Table.Th>
|
||||
<Table.Th>Route</Table.Th>
|
||||
<Table.Th>Container #</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>
|
||||
@@ -899,7 +937,17 @@ function EligibleTab({
|
||||
<Table.Td>
|
||||
{r.origin || r.destination ? `${r.origin ?? '?'} → ${r.destination ?? '?'}` : '—'}
|
||||
</Table.Td>
|
||||
<Table.Td>—</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>
|
||||
@@ -952,7 +1000,7 @@ function EligibleTab({
|
||||
loading={bulkReceive.isPending}
|
||||
onClick={() => openTruckReceive([r.id])}
|
||||
>
|
||||
{canReceive ? 'Receive' : 'Await First Mile'}
|
||||
{canReceive ? 'Receive to Warehouse' : 'Await First Mile'}
|
||||
</Button>
|
||||
)}
|
||||
</Table.Td>
|
||||
@@ -967,7 +1015,7 @@ function EligibleTab({
|
||||
<Modal
|
||||
opened={truckOpen}
|
||||
onClose={() => setTruckOpen(false)}
|
||||
title="Export Truck Arrival / First Mile Receive Form"
|
||||
title="Receive to Warehouse"
|
||||
centered
|
||||
size="lg"
|
||||
>
|
||||
@@ -979,6 +1027,52 @@ function EligibleTab({
|
||||
: '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}
|
||||
@@ -1084,7 +1178,7 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
|
||||
<Table.Th>Booking ID</Table.Th>
|
||||
<Table.Th>Customer ID</Table.Th>
|
||||
<Table.Th>Customer Name</Table.Th>
|
||||
<Table.Th>Container #</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>
|
||||
@@ -1466,11 +1560,11 @@ function LoadedExportTab({
|
||||
}
|
||||
|
||||
/** Assigned bookings/items for an arrived import train (read-only detail view). */
|
||||
function ImportTrainDetailTable({ scheduleId }: { scheduleId: string }) {
|
||||
function ImportTrainDetailTable({ train }: { train: ImportTrain }) {
|
||||
const { data: items = [], isLoading } = useQuery(
|
||||
api.warehouses.importTrainItems.queryOptions({
|
||||
input: { scheduleId },
|
||||
enabled: Boolean(scheduleId),
|
||||
input: { scheduleId: train.scheduleId },
|
||||
enabled: Boolean(train.scheduleId),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1493,6 +1587,7 @@ function ImportTrainDetailTable({ scheduleId }: { scheduleId: string }) {
|
||||
<Table withTableBorder verticalSpacing="xs" fz="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Wagon</Table.Th>
|
||||
<Table.Th>Booking ID</Table.Th>
|
||||
<Table.Th>Booking Ref</Table.Th>
|
||||
<Table.Th>Customer ID</Table.Th>
|
||||
@@ -1509,7 +1604,12 @@ function ImportTrainDetailTable({ scheduleId }: { scheduleId: string }) {
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{items.map((it: ImportTrainItem) => (
|
||||
<Table.Tr key={it.bookingId}>
|
||||
<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" c="dimmed">{it.bookingId.slice(0, 8)}…</Text>
|
||||
</Table.Td>
|
||||
@@ -1656,8 +1756,8 @@ function ImportArriveQueueTab({
|
||||
<Table.Td ta="center">{t.totalCargoes}</Table.Td>
|
||||
<Table.Td>
|
||||
<Stack gap={2}>
|
||||
<Badge color={fullyUnloaded ? 'green' : 'indigo'} variant="light" size="sm">
|
||||
{fullyUnloaded ? 'UNLOADED' : t.status}
|
||||
<Badge color="indigo" variant="light" size="sm">
|
||||
{t.status}
|
||||
</Badge>
|
||||
<Text size="xs" c="dimmed">
|
||||
{Math.max(unloadedBookings, 0)}/{t.totalBookings} unloaded
|
||||
@@ -1690,7 +1790,7 @@ function ImportArriveQueueTab({
|
||||
{isOpen && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={11} bg="var(--mantine-color-gray-0)">
|
||||
<ImportTrainDetailTable scheduleId={t.scheduleId} />
|
||||
<ImportTrainDetailTable train={t} />
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
@@ -1712,14 +1812,24 @@ function ImportArriveQueueTab({
|
||||
*/
|
||||
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 storeMutation = useMutation(api.warehouses.store.mutationOptions());
|
||||
const readyMutation = useMutation(api.warehouses.markReadyForPickup.mutationOptions());
|
||||
const dispatchMutation = useMutation(api.warehouses.dispatch.mutationOptions());
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
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 allSelected = rows.length > 0 && selected.size === rows.length;
|
||||
const someSelected = selected.size > 0 && !allSelected;
|
||||
@@ -1744,11 +1854,62 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
description: r.skippedCount ? `${r.skippedCount} skipped` : undefined,
|
||||
});
|
||||
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,
|
||||
status: row.currentStatus,
|
||||
arrivedAt: row.arrivalTime,
|
||||
unloadedAt: row.arrivalTime,
|
||||
inspectionStatus: row.inspectionStatus,
|
||||
releaseDate: row.releaseDate,
|
||||
releaseOrderReference: row.releaseOrderReference,
|
||||
deliveredAt: row.deliveredAt,
|
||||
booking: row.bookingId
|
||||
? {
|
||||
id: row.bookingId,
|
||||
reference: row.bookingReference ?? row.bookingId,
|
||||
tradeDirection: 'IMPORT',
|
||||
lastMileDeliveryAddress: row.lastMileRequested ? row.pickupOption : null,
|
||||
}
|
||||
: 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);
|
||||
} catch (error) {
|
||||
pdfWindow?.close();
|
||||
toast({ variant: 'destructive', title: 'Handover document failed', description: extractErrorMessage(error) });
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="sm" mt="sm">
|
||||
<Group justify="space-between">
|
||||
@@ -1852,9 +2013,92 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
<Badge color="indigo" variant="light" size="sm">{r.currentStatus}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Button size="compact-xs" variant="light" color="orange" onClick={() => setInspectId(r.id)}>
|
||||
Inspect / Report
|
||||
</Button>
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
<Tooltip label="View details" withArrow>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={() => setViewItem(toInventoryItem(r))}>
|
||||
<Eye size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
{r.currentStatus === 'UNLOADED' && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="blue"
|
||||
loading={busyId === r.id}
|
||||
onClick={() => runRowAction(r, 'Inventory stored', () => storeMutation.mutateAsync(r.id))}
|
||||
>
|
||||
Store
|
||||
</Button>
|
||||
)}
|
||||
{['UNLOADED', 'STORED'].includes(r.currentStatus) && r.inspectionStatus === 'PASSED' && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
loading={busyId === r.id}
|
||||
onClick={() => runRowAction(r, 'Ready for pickup', () => readyMutation.mutateAsync(r.id))}
|
||||
>
|
||||
Ready Pickup
|
||||
</Button>
|
||||
)}
|
||||
{r.currentStatus === 'READY_FOR_PICKUP' && !r.releaseDate && (
|
||||
<>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="yellow"
|
||||
onClick={() => setReleaseItem(toInventoryItem(r))}
|
||||
>
|
||||
Truck Arrival
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{r.currentStatus === 'READY_FOR_PICKUP' && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="green"
|
||||
loading={busyId === r.id}
|
||||
onClick={() => runRowAction(r, 'Inventory dispatched', () => dispatchMutation.mutateAsync(r.id))}
|
||||
>
|
||||
Dispatch
|
||||
</Button>
|
||||
)}
|
||||
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="green"
|
||||
onClick={() => setDeliverItem(toInventoryItem(r))}
|
||||
>
|
||||
Deliver
|
||||
</Button>
|
||||
)}
|
||||
{r.inspectionStatus === 'PASSED' && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="teal"
|
||||
leftSection={<FileText size={14} />}
|
||||
onClick={() => openHandoverDocument(r)}
|
||||
>
|
||||
Handover
|
||||
</Button>
|
||||
)}
|
||||
<Button size="compact-xs" variant="light" color="orange" onClick={() => setInspectId(r.id)}>
|
||||
Inspect / Report
|
||||
</Button>
|
||||
<Tooltip label="Storage / fee preview" withArrow>
|
||||
<ActionIcon variant="subtle" color="teal" onClick={() => setFeeItem(toInventoryItem(r))}>
|
||||
<PackageCheck size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="History" withArrow>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={() => setHistoryItem(toInventoryItem(r))}>
|
||||
<History size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
@@ -1868,6 +2112,15 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
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} />
|
||||
<DeliverInventoryModal opened={Boolean(deliverItem)} onClose={() => setDeliverItem(null)} item={deliverItem} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1905,20 +2158,340 @@ function ImportDispatchQueueTab({ enabled }: { enabled: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** New bulk Receive: Import / Export tabs with eligible PAID bookings. */
|
||||
function BulkReceiveModal({ opened, onClose, onReceived }: ReceiveInventoryModalProps) {
|
||||
const [location, setLocation] = useState<Location>({ warehouseId: '', yardId: '', zoneId: '' });
|
||||
const [tab, setTab] = useState<'IMPORT' | 'EXPORT'>('IMPORT');
|
||||
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';
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) setLocation({ warehouseId: '', yardId: '', zoneId: '' });
|
||||
}, [opened]);
|
||||
interface WarehouseQueueTab<TValue extends string> {
|
||||
value: TValue;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
interface WarehouseFlowWorkbenchProps {
|
||||
direction?: WarehouseFlowDirection;
|
||||
enabled?: boolean;
|
||||
onChanged?: () => void;
|
||||
}
|
||||
|
||||
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 (
|
||||
<Modal opened={opened} onClose={onClose} title="Receive at warehouse" centered size="80rem">
|
||||
<Stack gap="md">
|
||||
<LocationSelects value={location} onChange={setLocation} />
|
||||
<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,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
location: Location;
|
||||
onChanged?: () => void;
|
||||
}) {
|
||||
const [activeTab, setActiveTab] = useState<ExportWarehouseTab>('receive-queue');
|
||||
const { data: eligibleRows = [] } = useQuery(api.warehouses.eligibleBookings.queryOptions({ 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} />
|
||||
)}
|
||||
{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,
|
||||
}: 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;
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled) setLocation({ warehouseId: '', yardId: '', zoneId: '' });
|
||||
}, [enabled, direction]);
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{activeDirection === 'EXPORT' && (
|
||||
<LocationSelects value={location} onChange={setLocation} />
|
||||
)}
|
||||
|
||||
{direction === 'BOTH' ? (
|
||||
<Tabs value={tab} onChange={(v) => setTab((v as 'IMPORT' | 'EXPORT') ?? 'IMPORT')}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="IMPORT" leftSection={<PackageSearch size={16} />}>
|
||||
@@ -1930,54 +2503,27 @@ function BulkReceiveModal({ opened, onClose, onReceived }: ReceiveInventoryModal
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="IMPORT">
|
||||
<Tabs defaultValue="arrive-queue" mt="xs">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="arrive-queue" leftSection={<Train size={14} />}>
|
||||
Arrive Queue
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="unloaded-queue">Unloaded Queue</Tabs.Tab>
|
||||
<Tabs.Tab value="dispatch-queue">Dispatch Queue</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="arrive-queue">
|
||||
<ImportArriveQueueTab enabled={opened} onChanged={onReceived} />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="unloaded-queue">
|
||||
<ImportUnloadedQueueTab enabled={opened} />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="dispatch-queue">
|
||||
<ImportDispatchQueueTab enabled={opened} />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
<ImportWarehouseTabs enabled={enabled} onChanged={onChanged} />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="EXPORT">
|
||||
<Tabs defaultValue="receive-queue" mt="xs">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="receive-queue">Receive Queue</Tabs.Tab>
|
||||
<Tabs.Tab value="received">Received</Tabs.Tab>
|
||||
<Tabs.Tab value="ready-to-load">Ready To Load</Tabs.Tab>
|
||||
<Tabs.Tab value="loaded">Loaded</Tabs.Tab>
|
||||
<Tabs.Tab value="dispatch-queue">Dispatch Queue</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="receive-queue">
|
||||
<EligibleTab direction="EXPORT" location={location} enabled={opened} onChanged={onReceived} />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="received">
|
||||
<ExportReceivedTab enabled={opened} onChanged={onReceived} />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="ready-to-load">
|
||||
<ReadyToLoadTab enabled={opened} onChanged={onReceived} />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="loaded">
|
||||
<LoadedExportTab enabled={opened} dispatchable={false} onChanged={onReceived} />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="dispatch-queue">
|
||||
<LoadedExportTab enabled={opened} dispatchable onChanged={onReceived} />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
<ExportWarehouseTabs enabled={enabled} location={location} onChanged={onChanged} />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
) : activeDirection === 'IMPORT' ? (
|
||||
<ImportWarehouseTabs enabled={enabled} onChanged={onChanged} />
|
||||
) : (
|
||||
<ExportWarehouseTabs enabled={enabled} location={location} onChanged={onChanged} />
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/** New bulk Receive: Import / Export tabs with eligible PAID bookings. */
|
||||
function BulkReceiveModal({ opened, onClose, onReceived }: ReceiveInventoryModalProps) {
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Receive at warehouse" centered size="80rem">
|
||||
<Stack gap="md">
|
||||
<WarehouseFlowWorkbench enabled={opened} direction="BOTH" onChanged={onReceived} />
|
||||
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
|
||||
@@ -148,12 +148,13 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Exit inspection and release paper" centered size="lg">
|
||||
<Modal opened={opened} onClose={onClose} title="Customer truck arrival and exit weighing" centered size="lg">
|
||||
<Stack gap="md">
|
||||
<Alert icon={<Info size={16} />} color="orange" variant="light">
|
||||
<Text size="sm">
|
||||
Save the exit inspection before generating the exit paper. Gate clearance is blocked when
|
||||
recorded net weight does not equal gross weight minus tare weight.
|
||||
Register the customer truck and driver at arrival, record tare weight, then record gross
|
||||
weight at exit after loading. Gate clearance is blocked when recorded net weight does not
|
||||
equal gross weight minus tare weight.
|
||||
</Text>
|
||||
</Alert>
|
||||
<TextInput
|
||||
@@ -224,7 +225,7 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="orange" onClick={handleSubmit} loading={releaseMutation.isPending || downloading}>
|
||||
Exit Inspection & View Exit Paper
|
||||
Save Truck Arrival & View Exit Paper
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -19,6 +19,7 @@ interface WarehouseInventoryTableProps {
|
||||
onInspect?: (item: WarehouseInventoryItem) => void;
|
||||
onFeePreview?: (item: WarehouseInventoryItem) => void;
|
||||
onReleaseDocument?: (item: WarehouseInventoryItem) => void;
|
||||
onHandoverDocument?: (item: WarehouseInventoryItem) => void;
|
||||
onLastMile?: (item: WarehouseInventoryItem) => void;
|
||||
selectedIds?: Set<string>;
|
||||
onToggleSelect?: (id: string) => void;
|
||||
@@ -55,6 +56,7 @@ export function WarehouseInventoryTable({
|
||||
onInspect,
|
||||
onFeePreview,
|
||||
onReleaseDocument,
|
||||
onHandoverDocument,
|
||||
onLastMile,
|
||||
selectedIds,
|
||||
onToggleSelect,
|
||||
@@ -105,6 +107,10 @@ export function WarehouseInventoryTable({
|
||||
const kind = itemKind(item);
|
||||
const busy = busyId === item.id;
|
||||
const nextAction = getNextInventoryAction(item);
|
||||
const canGenerateHandover =
|
||||
item.inspectionStatus === 'PASSED' &&
|
||||
Boolean(item.bookingId) &&
|
||||
(!item.booking?.tradeDirection || item.booking.tradeDirection === 'IMPORT');
|
||||
|
||||
return (
|
||||
<Table.Tr key={item.id}>
|
||||
@@ -164,7 +170,7 @@ export function WarehouseInventoryTable({
|
||||
loading={busy}
|
||||
onClick={() => onAdvance(item, nextAction)}
|
||||
>
|
||||
{nextAction === 'release' ? 'Exit Inspection' : humanizeEnum(nextAction.replace(/-/g, '_'))}
|
||||
{nextAction === 'release' ? 'Truck Arrival' : humanizeEnum(nextAction.replace(/-/g, '_'))}
|
||||
</Button>
|
||||
)}
|
||||
{item.status === 'READY_FOR_PICKUP' && (
|
||||
@@ -217,6 +223,13 @@ export function WarehouseInventoryTable({
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onHandoverDocument && canGenerateHandover && (
|
||||
<Tooltip label="Generate customer handover document" withArrow>
|
||||
<ActionIcon variant="subtle" color="teal" onClick={() => onHandoverDocument(item)}>
|
||||
<FileText size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onLastMile && item.booking?.lastMileDeliveryAddress && (
|
||||
<Tooltip label="Last mile delivery" withArrow>
|
||||
<ActionIcon variant="subtle" color="blue" onClick={() => onLastMile(item)}>
|
||||
|
||||
@@ -9,7 +9,7 @@ export { WarehouseInquiryTable } from './WarehouseInquiryTable';
|
||||
export { CreateWarehouseModal } from './CreateWarehouseModal';
|
||||
export { CreateYardModal } from './CreateYardModal';
|
||||
export { CreateZoneModal } from './CreateZoneModal';
|
||||
export { ReceiveInventoryModal } from './ReceiveInventoryModal';
|
||||
export { ReceiveInventoryModal, WarehouseFlowWorkbench } from './ReceiveInventoryModal';
|
||||
export { WarehouseInfoCard } from './WarehouseInfoCard';
|
||||
export { MoveInventoryModal } from './MoveInventoryModal';
|
||||
export { ReserveInventoryModal } from './ReserveInventoryModal';
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { WarehouseFeeInvoice, WarehouseInventoryItem } from '@/types/warehouse';
|
||||
import type {
|
||||
ImportUnloadedItem,
|
||||
WarehouseFeeInvoice,
|
||||
WarehouseInventoryItem,
|
||||
} from '@/types/warehouse';
|
||||
import type { BookingDetail } from '@/types/booking';
|
||||
|
||||
type PdfLine = {
|
||||
@@ -19,6 +23,11 @@ export interface WarehouseExitPaperContext {
|
||||
releasedAt?: Date;
|
||||
}
|
||||
|
||||
export interface WarehouseHandoverPdfContext {
|
||||
item: ImportUnloadedItem | WarehouseInventoryItem;
|
||||
handedOverAt?: Date;
|
||||
}
|
||||
|
||||
const escapePdfText = (value: string) =>
|
||||
value.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
|
||||
|
||||
@@ -214,6 +223,67 @@ const containerSummary = (booking?: BookingDetail | null, inventory?: WarehouseI
|
||||
.join(', ');
|
||||
};
|
||||
|
||||
const shortId = (value: unknown) => {
|
||||
const text = firstText(value);
|
||||
return text === '-' ? '-' : text.slice(0, 8);
|
||||
};
|
||||
|
||||
const compactWeight = (value: unknown) => {
|
||||
const num = Number(value ?? 0);
|
||||
if (!Number.isFinite(num) || num <= 0) return '-';
|
||||
return num.toLocaleString(undefined, { maximumFractionDigits: 3 });
|
||||
};
|
||||
|
||||
const handoverValue = (item: ImportUnloadedItem | WarehouseInventoryItem, key: string) =>
|
||||
(item as unknown as Record<string, unknown>)[key];
|
||||
|
||||
const handoverBookingValue = (item: ImportUnloadedItem | WarehouseInventoryItem, key: string) =>
|
||||
((item as WarehouseInventoryItem).booking as unknown as Record<string, unknown> | null | undefined)?.[key];
|
||||
|
||||
export function buildWarehouseHandoverPdf({ item, handedOverAt = new Date() }: WarehouseHandoverPdfContext) {
|
||||
const bookingReference = firstText(
|
||||
handoverValue(item, 'bookingReference'),
|
||||
handoverBookingValue(item, 'reference'),
|
||||
item.bookingId,
|
||||
);
|
||||
const customerName = firstText(
|
||||
handoverValue(item, 'customerName'),
|
||||
handoverBookingValue(item, 'customerName'),
|
||||
handoverBookingValue(item, 'companyName'),
|
||||
);
|
||||
const containerNumber = firstText(handoverValue(item, 'containerNumber'));
|
||||
const cargoType = firstText(handoverValue(item, 'cargoType'), handoverValue(item, 'cargoDescription'));
|
||||
const currentStatus = firstText(handoverValue(item, 'currentStatus'), (item as WarehouseInventoryItem).status);
|
||||
const handoverReference = `HND-${bookingReference.replace(/[^a-zA-Z0-9]+/g, '-')}`;
|
||||
const goodsSummary = firstText(containerNumber, cargoType, (item as WarehouseInventoryItem).goodsId);
|
||||
|
||||
return buildSimplePdf([
|
||||
{ text: 'Ethio-Djibouti Railway S.C.', size: 12, bold: true, yGap: 0, align: 'center' },
|
||||
{ text: 'Import Goods Handover Document', size: 23, bold: true, yGap: 28, align: 'center' },
|
||||
{ text: '[ EDR TO CUSTOMER ]', size: 14, bold: true, color: 'green', yGap: 24, align: 'center' },
|
||||
{ text: `Document No: ${handoverReference}`, bold: true, yGap: 30, align: 'center' },
|
||||
{ text: `Handover Date & Time: ${fmtDate(handedOverAt)}`, align: 'center' },
|
||||
{ text: `From: Ethio-Djibouti Railway S.C.`, align: 'center' },
|
||||
{ text: `To Customer: ${customerName}`, align: 'center' },
|
||||
{ text: `Customer ID: ${shortId(handoverValue(item, 'customerId'))}`, align: 'center' },
|
||||
{ text: `Booking Reference: ${bookingReference}`, align: 'center' },
|
||||
{ text: `Booking ID: ${shortId(item.bookingId)}`, align: 'center' },
|
||||
{ text: `Train Schedule: ${firstText(handoverValue(item, 'trainSchedule'))}`, align: 'center' },
|
||||
{ text: `Arrival Time: ${fmtDate(handoverValue(item, 'arrivalTime') ?? (item as WarehouseInventoryItem).arrivedAt)}`, align: 'center' },
|
||||
{ text: `Release Order: ${firstText((item as WarehouseInventoryItem).releaseOrderReference)}`, align: 'center' },
|
||||
{ text: `Release Date: ${fmtDate((item as WarehouseInventoryItem).releaseDate)}`, align: 'center' },
|
||||
{ text: 'GOODS LIST', size: 13, bold: true, yGap: 30, align: 'center' },
|
||||
{ text: `1. ${goodsSummary}`, bold: true, align: 'center' },
|
||||
{ text: `Container: ${containerNumber} Cargo: ${cargoType}`, align: 'center' },
|
||||
{ text: `Weight: ${compactWeight(item.weight)} Status: ${currentStatus}`, align: 'center' },
|
||||
{ text: `Inspection: ${firstText(item.inspectionStatus)} Pickup Option: ${firstText(handoverValue(item, 'pickupOption'), 'TERMINAL_PICKUP')}`, align: 'center' },
|
||||
{ text: `Warehouse: ${firstText((item as WarehouseInventoryItem).warehouse?.name, (item as WarehouseInventoryItem).warehouse?.code)} Yard: ${firstText((item as WarehouseInventoryItem).yard?.name, (item as WarehouseInventoryItem).yard?.code)}`, align: 'center' },
|
||||
{ text: 'This document confirms EDR handed over the listed import goods to the customer after warehouse inspection.', yGap: 30, align: 'center' },
|
||||
], [
|
||||
...buildWarehouseOfficerSealBand(),
|
||||
]);
|
||||
}
|
||||
|
||||
export function buildWarehouseExitPaperPdf(input: WarehouseFeeInvoice | WarehouseExitPaperContext, releasedItemArg?: WarehouseInventoryItem) {
|
||||
const context: WarehouseExitPaperContext =
|
||||
'invoice' in input ? input : { invoice: input, releasedItem: releasedItemArg };
|
||||
|
||||
Reference in New Issue
Block a user