Merge branch 'dev' into freight/feat/invoice

This commit is contained in:
Nathnael Wondisha
2026-06-29 17:24:29 +03:00
committed by GitHub
42 changed files with 2337 additions and 207 deletions

View File

@@ -73,6 +73,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";
@@ -210,6 +212,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: [
@@ -223,46 +304,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",
@@ -487,6 +528,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 />} />

View File

@@ -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}

View File

@@ -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}>

View File

@@ -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>

View File

@@ -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)}>

View File

@@ -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';

View File

@@ -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 };

View File

@@ -389,6 +389,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) =>

View File

@@ -314,6 +314,7 @@ const FirstMilePage = () => {
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("ALL");
const [filterPostPaymentPending, setFilterPostPaymentPending] = useState(false);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const [assignOpen, setAssignOpen] = useState(false);
@@ -529,6 +530,7 @@ const FirstMilePage = () => {
};
const matchesFilter = (r: FirstMileRecord) => {
if (filterPostPaymentPending && r.isPostPaymentCompleted) return false;
switch (statusFilter) {
case "ALL": return true;
case "ASSIGNED": return isAssigned(r);
@@ -566,7 +568,7 @@ const FirstMilePage = () => {
.includes(term);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [records, search, statusFilter]);
}, [records, search, statusFilter, filterPostPaymentPending]);
const pageCount = Math.max(1, Math.ceil(filteredRecords.length / pagination.pageSize));
const pagedRecords = useMemo(() => {
@@ -887,6 +889,17 @@ const FirstMilePage = () => {
</Button>
);
})}
<Button
size="xs"
variant={filterPostPaymentPending ? "filled" : "default"}
styles={{ label: { fontWeight: 500 } }}
onClick={() => {
setFilterPostPaymentPending(!filterPostPaymentPending);
setPagination((p) => ({ ...p, pageIndex: 0 }));
}}
>
Post Payment Pending
</Button>
</Group>
</Stack>
</Box>

View File

@@ -298,6 +298,7 @@ const LastMilePage = () => {
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("ALL");
const [filterPostPaymentPending, setFilterPostPaymentPending] = useState(false);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const [assignOpen, setAssignOpen] = useState(false);
@@ -508,6 +509,7 @@ const LastMilePage = () => {
);
const matchesFilter = (r: LastMileRecord) => {
if (filterPostPaymentPending && r.isPostPaymentCompleted) return false;
switch (statusFilter) {
case "ALL": return true;
case "ASSIGNED": return isAssigned(r);
@@ -545,7 +547,7 @@ const LastMilePage = () => {
.includes(term);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [records, search, statusFilter]);
}, [records, search, statusFilter, filterPostPaymentPending]);
const pageCount = Math.max(1, Math.ceil(filteredRecords.length / pagination.pageSize));
const pagedRecords = useMemo(() => {
@@ -866,6 +868,17 @@ const LastMilePage = () => {
</Button>
);
})}
<Button
size="xs"
variant={filterPostPaymentPending ? "filled" : "default"}
styles={{ label: { fontWeight: 500 } }}
onClick={() => {
setFilterPostPaymentPending(!filterPostPaymentPending);
setPagination((p) => ({ ...p, pageIndex: 0 }));
}}
>
Post Payment Pending
</Button>
</Group>
</Stack>
</Box>

View File

@@ -296,9 +296,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({
@@ -309,15 +315,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",
});
@@ -708,7 +716,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",

View File

@@ -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

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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),

View File

@@ -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;