mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 15:18:11 +00:00
Marshaling document Receive Export and import handover to customer
This commit is contained in:
@@ -67,6 +67,8 @@ import TrainDetailPage from "./pages/trains/TrainDetailPage";
|
||||
import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage";
|
||||
import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage";
|
||||
import ExportDjiboutiUnloadingQueuePage from "./pages/warehouses/ExportDjiboutiUnloadingQueuePage";
|
||||
import ExportWarehouseFlowPage from "./pages/warehouses/ExportWarehouseFlowPage";
|
||||
import ImportWarehouseFlowPage from "./pages/warehouses/ImportWarehouseFlowPage";
|
||||
import InterchangeDocumentsPage from "./pages/warehouses/InterchangeDocumentsPage";
|
||||
import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage";
|
||||
import LoadedInventoryPage from "./pages/warehouses/LoadedInventoryPage";
|
||||
@@ -198,6 +200,85 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
// },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Port & Terminal",
|
||||
items: [
|
||||
{
|
||||
label: "Import Operations",
|
||||
href: "/dashboard/import-warehouse",
|
||||
icon: <PackageOpen />,
|
||||
children: [
|
||||
{
|
||||
label: "Import Overview",
|
||||
href: "/dashboard/import-warehouse",
|
||||
icon: <PackageOpen />,
|
||||
},
|
||||
{
|
||||
label: "Arrival Queue",
|
||||
href: "/dashboard/arrival-queue",
|
||||
icon: <PackageOpen />,
|
||||
},
|
||||
{
|
||||
label: "Dispatch Queue",
|
||||
href: "/dashboard/dispatch-queue",
|
||||
icon: <Send />,
|
||||
},
|
||||
{
|
||||
label: "Terminal Inventory",
|
||||
href: "/dashboard/warehouse-inventory",
|
||||
icon: <Package />,
|
||||
},
|
||||
{
|
||||
label: "Inventory Inquiry",
|
||||
href: "/dashboard/inventory-inquiry",
|
||||
icon: <Boxes />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Export Operations",
|
||||
href: "/dashboard/export-warehouse",
|
||||
icon: <Truck />,
|
||||
children: [
|
||||
{
|
||||
label: "Export Overview",
|
||||
href: "/dashboard/export-warehouse",
|
||||
icon: <Truck />,
|
||||
},
|
||||
{
|
||||
label: "Loading Queue",
|
||||
href: "/dashboard/loading-queue",
|
||||
icon: <Truck />,
|
||||
},
|
||||
{
|
||||
label: "Loaded Inventory",
|
||||
href: "/dashboard/loaded-inventory",
|
||||
icon: <PackageCheck />,
|
||||
},
|
||||
{
|
||||
label: "Dispatch Queue",
|
||||
href: "/dashboard/dispatch-queue",
|
||||
icon: <Send />,
|
||||
},
|
||||
{
|
||||
label: "Djibouti Unloading",
|
||||
href: "/dashboard/export-djibouti-unloading",
|
||||
icon: <PackageOpen />,
|
||||
},
|
||||
{
|
||||
label: "Interchange Documents",
|
||||
href: "/dashboard/interchange-documents",
|
||||
icon: <FileText />,
|
||||
},
|
||||
{
|
||||
label: "Terminal Inventory",
|
||||
href: "/dashboard/warehouse-inventory",
|
||||
icon: <Package />,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Warehouse Management",
|
||||
items: [
|
||||
@@ -211,46 +292,6 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
href: "/dashboard/warehouses",
|
||||
icon: <Container />,
|
||||
},
|
||||
{
|
||||
label: "Inventory",
|
||||
href: "/dashboard/warehouse-inventory",
|
||||
icon: <Package />,
|
||||
},
|
||||
{
|
||||
label: "Arrival Queue",
|
||||
href: "/dashboard/arrival-queue",
|
||||
icon: <PackageOpen />,
|
||||
},
|
||||
{
|
||||
label: "Loading Queue",
|
||||
href: "/dashboard/loading-queue",
|
||||
icon: <Truck />,
|
||||
},
|
||||
{
|
||||
label: "Loaded Inventory",
|
||||
href: "/dashboard/loaded-inventory",
|
||||
icon: <PackageCheck />,
|
||||
},
|
||||
{
|
||||
label: "Dispatch Queue",
|
||||
href: "/dashboard/dispatch-queue",
|
||||
icon: <Send />,
|
||||
},
|
||||
{
|
||||
label: "Djibouti Unloading",
|
||||
href: "/dashboard/export-djibouti-unloading",
|
||||
icon: <PackageOpen />,
|
||||
},
|
||||
{
|
||||
label: "Interchange Documents",
|
||||
href: "/dashboard/interchange-documents",
|
||||
icon: <FileText />,
|
||||
},
|
||||
{
|
||||
label: "Inventory Inquiry",
|
||||
href: "/dashboard/inventory-inquiry",
|
||||
icon: <Boxes />,
|
||||
},
|
||||
{
|
||||
label: "Allocation & Fees",
|
||||
href: "/dashboard/warehouse-rules",
|
||||
@@ -418,6 +459,8 @@ const App = () => {
|
||||
<Route path="warehouses" element={<WarehouseListPage />} />
|
||||
<Route path="warehouses/:id" element={<WarehouseDetailPage />} />
|
||||
<Route path="warehouse-inventory" element={<WarehouseInventoryPage />} />
|
||||
<Route path="import-warehouse" element={<ImportWarehouseFlowPage />} />
|
||||
<Route path="export-warehouse" element={<ExportWarehouseFlowPage />} />
|
||||
<Route path="arrival-queue" element={<ArrivalQueuePage />} />
|
||||
<Route path="loading-queue" element={<LoadingQueuePage />} />
|
||||
<Route path="loaded-inventory" element={<LoadedInventoryPage />} />
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -331,6 +331,7 @@ export const URL_CONSTANTS = {
|
||||
MARK_READY_PICKUP: (id: string) => `/warehouse-inventory/${id}/ready-for-pickup`,
|
||||
RELEASE: (id: string) => `/warehouse-inventory/${id}/release`,
|
||||
RELEASE_DOCUMENT: (id: string) => `/warehouse-inventory/${id}/release-document`,
|
||||
HANDOVER_DOCUMENT: (id: string) => `/warehouse-inventory/${id}/handover-document`,
|
||||
DELIVER: (id: string) => `/warehouse-inventory/${id}/deliver`,
|
||||
// Receive (Import/Export bulk)
|
||||
ELIGIBLE_BOOKINGS: (direction?: string) =>
|
||||
|
||||
@@ -313,9 +313,15 @@ export default function TrainScheduleV2DetailPage() {
|
||||
const canDispatch = schedule.status === "SCHEDULED";
|
||||
const finalizeStep = hasContainerStep ? 3 : 2;
|
||||
const canModifyBookings = canEditBookings && !["DISPATCHED", "ARRIVED"].includes(schedule.status);
|
||||
const canPrintMarshalling = schedule.direction === "IMPORT" || schedule.direction === "EXPORT";
|
||||
const canPrintMarshalling =
|
||||
(schedule.direction === "IMPORT" || schedule.direction === "EXPORT") &&
|
||||
["DISPATCHED", "ARRIVED"].includes(schedule.status);
|
||||
|
||||
const openMarshallingDocument = async () => {
|
||||
const openMarshallingDocument = async (options?: {
|
||||
title?: string;
|
||||
successDescription?: string;
|
||||
errorTitle?: string;
|
||||
}) => {
|
||||
const pdfWindow = window.open("", "_blank");
|
||||
try {
|
||||
const blob = await downloadMarshalling.mutateAsync({
|
||||
@@ -326,15 +332,17 @@ export default function TrainScheduleV2DetailPage() {
|
||||
const filename = `${prefix}-${schedule.trainNumber ?? scheduleId}.pdf`;
|
||||
const opened = openPdfBlob(blob, filename, pdfWindow);
|
||||
toast({
|
||||
title: "Marshalling document ready",
|
||||
description: opened
|
||||
? "The PDF opened in a browser tab for printing or saving."
|
||||
: "The browser blocked the preview tab, so the PDF was downloaded.",
|
||||
title: options?.title ?? "Marshalling document ready",
|
||||
description:
|
||||
options?.successDescription ??
|
||||
(opened
|
||||
? "The PDF opened in a browser tab for printing or saving."
|
||||
: "The browser blocked the preview tab, so the PDF was downloaded."),
|
||||
});
|
||||
} catch (error) {
|
||||
pdfWindow?.close();
|
||||
toast({
|
||||
title: "Could not open marshalling document",
|
||||
title: options?.errorTitle ?? "Could not open marshalling document",
|
||||
description: parseError(error, "Make sure the train has wagon allocations, then try again."),
|
||||
variant: "destructive",
|
||||
});
|
||||
@@ -725,7 +733,11 @@ export default function TrainScheduleV2DetailPage() {
|
||||
onClick={async () => {
|
||||
try {
|
||||
await dispatch.mutateAsync(scheduleId);
|
||||
toast({ title: "Train dispatched" });
|
||||
await openMarshallingDocument({
|
||||
title: "Train dispatched",
|
||||
successDescription: "Marshalling document generated for the dispatched train.",
|
||||
errorTitle: "Train dispatched, but document could not open",
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Dispatch failed",
|
||||
|
||||
@@ -231,8 +231,8 @@ export default function ArrivalQueuePage() {
|
||||
<Table.Td ta="center">{train.totalCargoes}</Table.Td>
|
||||
<Table.Td>
|
||||
<Stack gap={2}>
|
||||
<Badge variant="light" color={fullyUnloaded ? 'green' : 'teal'} size="sm">
|
||||
{fullyUnloaded ? 'UNLOADED' : train.status}
|
||||
<Badge variant="light" color="teal" size="sm">
|
||||
{train.status}
|
||||
</Badge>
|
||||
<Text size="xs" c="dimmed">
|
||||
{Math.max(unloadedBookings, 0)}/{train.totalBookings} unloaded
|
||||
|
||||
@@ -66,14 +66,14 @@ const statusLabel = (status?: string | null) =>
|
||||
: (status ?? 'PENDING').replace(/_/g, ' ');
|
||||
|
||||
function ExportTrainDetailRows({
|
||||
scheduleId,
|
||||
train,
|
||||
onOpenHistory,
|
||||
}: {
|
||||
scheduleId: string;
|
||||
train: ExportTrain;
|
||||
onOpenHistory: (inventoryId: string) => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const { data: items = [], isLoading } = useExportDjiboutiTrainItems(scheduleId);
|
||||
const { data: items = [], isLoading } = useExportDjiboutiTrainItems(train.scheduleId);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -92,10 +92,11 @@ function ExportTrainDetailRows({
|
||||
}
|
||||
|
||||
return (
|
||||
<Table.ScrollContainer minWidth={1320}>
|
||||
<Table.ScrollContainer minWidth={1420}>
|
||||
<Table highlightOnHover verticalSpacing="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Wagon</Table.Th>
|
||||
<Table.Th>Booking ID</Table.Th>
|
||||
<Table.Th>Booking Reference</Table.Th>
|
||||
<Table.Th>Customer ID</Table.Th>
|
||||
@@ -115,6 +116,11 @@ function ExportTrainDetailRows({
|
||||
<Table.Tbody>
|
||||
{items.map((item: ExportTrainItem) => (
|
||||
<Table.Tr key={`${item.bookingId}-${item.itemType}-${item.itemId ?? item.inventoryId ?? 'item'}`}>
|
||||
<Table.Td>
|
||||
<Text size="xs" fw={600}>
|
||||
{item.sequenceNo ? `#${item.sequenceNo}` : '-'} {item.wagonNumber ?? ''}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{item.bookingId.slice(0, 8)}</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={600}>
|
||||
@@ -384,7 +390,7 @@ export default function ExportDjiboutiUnloadingQueuePage() {
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={12} bg="var(--mantine-color-gray-0)">
|
||||
<ExportTrainDetailRows
|
||||
scheduleId={train.scheduleId}
|
||||
train={train}
|
||||
onOpenHistory={setHistoryInventoryId}
|
||||
/>
|
||||
</Table.Td>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Button, Card } from '@mantine/core';
|
||||
import { PackageSearch } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
import { WarehouseFlowWorkbench } from '@/components/warehouses';
|
||||
|
||||
export default function ExportWarehouseFlowPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Export Operations"
|
||||
subtitle="Manage export receive, terminal inventory, loading readiness, loaded items, and dispatch flow."
|
||||
action={
|
||||
<Button variant="light" leftSection={<PackageSearch size={16} />} onClick={() => navigate('/dashboard/import-warehouse')}>
|
||||
Import Operations
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<WarehouseFlowWorkbench direction="EXPORT" />
|
||||
</Card>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Button, Card } from '@mantine/core';
|
||||
import { Truck } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
import { WarehouseFlowWorkbench } from '@/components/warehouses';
|
||||
|
||||
export default function ImportWarehouseFlowPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Import Operations"
|
||||
subtitle="Manage import gate flow, marshalling handoff, arrived trains, unloaded bookings, and dispatch-ready inventory."
|
||||
action={
|
||||
<Button variant="light" leftSection={<Truck size={16} />} onClick={() => navigate('/dashboard/export-warehouse')}>
|
||||
Export Operations
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<WarehouseFlowWorkbench direction="IMPORT" />
|
||||
</Card>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { Button, Card, Group, Select, Stack, TextInput } from '@mantine/core';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import { PackagePlus, Search } from 'lucide-react';
|
||||
import { PackageOpen, Search, Truck } from 'lucide-react';
|
||||
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
import {
|
||||
InventoryWorkbench,
|
||||
ReceiveInventoryModal,
|
||||
inventoryStatusOptions,
|
||||
} from '@/components/warehouses';
|
||||
import {
|
||||
@@ -19,13 +18,13 @@ import {
|
||||
import type { InventoryFilter, InventoryStatus } from '@/types/warehouse';
|
||||
|
||||
export default function WarehouseInventoryPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const initialStatus = (searchParams.get('status') as InventoryStatus | null) ?? undefined;
|
||||
const [filter, setFilter] = useState<InventoryFilter>(
|
||||
initialStatus ? { status: initialStatus } : {},
|
||||
);
|
||||
const [search, setSearch] = useState('');
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
const [debouncedSearch] = useDebouncedValue(search, 300);
|
||||
const queryFilter = useMemo<InventoryFilter>(
|
||||
@@ -57,9 +56,18 @@ export default function WarehouseInventoryPage() {
|
||||
title="Warehouse Inventory"
|
||||
subtitle="Track received items through the storage, reservation, loading and dispatch lifecycle."
|
||||
action={
|
||||
<Button leftSection={<PackagePlus size={16} />} onClick={() => setModalOpen(true)}>
|
||||
Receive Inventory
|
||||
</Button>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<PackageOpen size={16} />}
|
||||
onClick={() => navigate('/dashboard/import-warehouse')}
|
||||
>
|
||||
Import
|
||||
</Button>
|
||||
<Button leftSection={<Truck size={16} />} onClick={() => navigate('/dashboard/export-warehouse')}>
|
||||
Export
|
||||
</Button>
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -126,8 +134,6 @@ export default function WarehouseInventoryPage() {
|
||||
<InventoryWorkbench items={inventoryQuery.data ?? []} isLoading={inventoryQuery.isLoading} />
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<ReceiveInventoryModal opened={modalOpen} onClose={() => setModalOpen(false)} />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -137,6 +137,10 @@ export const warehouseService = {
|
||||
apiClient.get<Blob>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RELEASE_DOCUMENT(id), {
|
||||
responseType: 'blob',
|
||||
}),
|
||||
downloadHandoverDocument: (id: string) =>
|
||||
apiClient.get<Blob>(URL_CONSTANTS.WAREHOUSE_INVENTORY.HANDOVER_DOCUMENT(id), {
|
||||
responseType: 'blob',
|
||||
}),
|
||||
deliver: (id: string, payload: DeliverInventoryPayload) =>
|
||||
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.DELIVER(id), payload),
|
||||
|
||||
|
||||
@@ -517,6 +517,9 @@ export interface ExportTrainItem {
|
||||
bookingReference: string | null;
|
||||
customerId: string | null;
|
||||
customerName: string | null;
|
||||
wagonNumber: string | null;
|
||||
sequenceNo: number | null;
|
||||
allocatedWeightTons: number | null;
|
||||
itemType: 'CONTAINER' | 'CARGO';
|
||||
itemId: string | null;
|
||||
inventoryId: string | null;
|
||||
@@ -566,6 +569,9 @@ export interface ImportUnloadedItem {
|
||||
pickupOption: string;
|
||||
lastMileRequested: boolean;
|
||||
currentStatus: string;
|
||||
releaseDate: string | null;
|
||||
releaseOrderReference: string | null;
|
||||
deliveredAt: string | null;
|
||||
}
|
||||
|
||||
export interface ImportTrainItem {
|
||||
@@ -573,6 +579,9 @@ export interface ImportTrainItem {
|
||||
bookingReference: string | null;
|
||||
customerId: string | null;
|
||||
customerName: string | null;
|
||||
wagonNumber: string | null;
|
||||
sequenceNo: number | null;
|
||||
allocatedWeightTons: number | null;
|
||||
containerNumber: string | null;
|
||||
cargoType: string | null;
|
||||
weight: number | null;
|
||||
|
||||
Reference in New Issue
Block a user