merge conflict

This commit is contained in:
marshal
2026-06-30 05:17:56 +03:00
126 changed files with 7248 additions and 2122 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

@@ -180,8 +180,10 @@ export default function NewBookingPage() {
const queryClient = useQueryClient();
const [isGovernment, setIsGovernment] = useState(false);
const [governmentInstitution, setGovernmentInstitution] = useState("");
const [companyId, setCompanyId] = useState<string | null>(null);
// Government bookings bill to a real government company + an explicit profile.
const [govCompanyId, setGovCompanyId] = useState<string | null>(null);
const [govProfileId, setGovProfileId] = useState<string | null>(null);
const [freightType, setFreightType] = useState<FreightType>("CONTAINER");
const [originYardId, setOriginYardId] = useState<string | null>(null);
const [destinationYardId, setDestinationYardId] = useState<string | null>(null);
@@ -220,6 +222,42 @@ export default function NewBookingPage() {
label: c.name || c.email || c.tin || c.id,
}));
// Active government companies (kind=government) the booking can bill to.
const { data: govCompaniesPage, isLoading: govCompaniesLoading } = useQuery({
queryKey: ["companies", "government", "active"],
queryFn: () =>
customersService.list({
page: 1,
pageSize: 1000,
kind: "government",
status: "active",
}),
enabled: isGovernment,
});
const govCompanies = govCompaniesPage?.items ?? [];
const govCompanyOptions = govCompanies.map((c) => ({
value: c.id,
label: c.name || c.tin || c.id,
}));
// Profiles (importer/exporter) of the chosen government company — the booking
// must link to one explicitly.
const selectedGovCompany = govCompanies.find((c) => c.id === govCompanyId);
const govProfileOptions = (selectedGovCompany?.companyProfiles ?? [])
.filter((p) => p.status === "active")
.map((p) => ({
value: p.id,
label: `${p.type === "importer" ? "Import" : p.type === "exporter" ? "Export" : p.type}${
p.reference ? `${p.reference}` : ""
}`,
}));
// Reset the chosen profile when the government company changes.
useEffect(() => {
setGovProfileId(null);
}, [govCompanyId]);
// Day-level pool: fetch only the days that have a departure on the route (no
// train, no capacity). The batch engine assigns the train after booking.
const { data: availableDays, isLoading: daysLoading } = useQuery(
@@ -306,7 +344,7 @@ export default function NewBookingPage() {
Boolean(tradeDirection) &&
Boolean(serviceTypeId) &&
departureSatisfied &&
(isGovernment ? governmentInstitution.trim().length >= 2 : Boolean(companyId)) &&
(isGovernment ? Boolean(govCompanyId && govProfileId) : Boolean(companyId)) &&
(freightType === "BULK"
? Boolean(cargoTypeId) && bulkWeight > 0
: allLinesValid);
@@ -320,8 +358,8 @@ export default function NewBookingPage() {
mutationFn: () =>
bookingsService.create({
isGovernment,
governmentInstitution: isGovernment ? governmentInstitution : undefined,
companyId: isGovernment ? undefined : companyId || undefined,
companyId: isGovernment ? govCompanyId || undefined : companyId || undefined,
companyProfileId: isGovernment ? govProfileId || undefined : undefined,
freightType,
contractType: "NEW",
equipmentReturn,
@@ -390,18 +428,37 @@ export default function NewBookingPage() {
<Stack gap="md">
<Switch
label="Government booking"
description="No company required — institution name instead. Expedited to the scheduling queue."
description="Bills to a government entity + profile. Expedited to the scheduling queue."
checked={isGovernment}
onChange={(e) => setIsGovernment(e.currentTarget.checked)}
/>
{isGovernment ? (
<TextInput
label="Government institution"
placeholder="e.g. Ministry of Transport"
value={governmentInstitution}
onChange={(e) => setGovernmentInstitution(e.currentTarget.value)}
required
/>
<Group grow align="flex-start">
<Select
label="Government entity"
placeholder="Select government company"
data={govCompanyOptions}
value={govCompanyId}
onChange={setGovCompanyId}
searchable
required
disabled={govCompaniesLoading}
nothingFoundMessage="No active government companies"
/>
<Select
label="Profile"
placeholder={
govCompanyId ? "Select import/export profile" : "Pick an entity first"
}
data={govProfileOptions}
value={govProfileId}
onChange={setGovProfileId}
searchable
required
disabled={!govCompanyId}
nothingFoundMessage="No active profiles for this entity"
/>
</Group>
) : (
<Select
label="Customer"

View File

@@ -6,6 +6,7 @@ import {
MoreHorizontal,
Printer,
RefreshCw,
Ruler,
Truck,
} from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -42,6 +43,7 @@ import {
} from "@/services/first-mile.service";
import { bookingsService } from "@/services/bookings.service";
import { vehiclesService } from "@/services/vehicles.service";
import { ratesService } from "@/services/rates.service";
import type { BookingDetail } from "@/types/booking";
const formatPrice = (amount: number) =>
@@ -312,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);
@@ -330,6 +333,8 @@ const FirstMilePage = () => {
const [distanceOpen, setDistanceOpen] = useState(false);
const [distanceValue, setDistanceValue] = useState("");
const [invoiceOpen, setInvoiceOpen] = useState(false);
const [invoiceRecord, setInvoiceRecord] = useState<FirstMileRecord | null>(null);
const { data: listData, isLoading } = useQuery({
queryKey: QUERY_KEYS.FIRST_MILE.list(),
@@ -347,6 +352,14 @@ const FirstMilePage = () => {
},
});
const { data: ratesData } = useQuery({
queryKey: ["rates", "FIRST_MILE"],
queryFn: async () => {
const res = await ratesService.getByType("FIRST_MILE");
return res.data;
},
});
const { data: paidBookingsData, isLoading: bookingsLoading } = useQuery({
queryKey: QUERY_KEYS.BOOKINGS.list({ status: "PAID" }),
queryFn: () => bookingsService.list({ status: "PAID", pageSize: 100 }),
@@ -376,7 +389,7 @@ const FirstMilePage = () => {
mutationFn: ({ id, data }: { id: string; data: { status?: FirstMileApiStatus; vehicleId?: string | null } }) =>
firstMileService.update(id, data),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.ROOT });
void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.list() });
},
onError: () => {
toast({ title: "Update failed", variant: "destructive" });
@@ -384,10 +397,10 @@ const FirstMilePage = () => {
});
const updateDistanceMutation = useMutation({
mutationFn: ({ id, exactKm }: { id: string; exactKm: number }) =>
firstMileService.update(id, { exactKm }),
mutationFn: ({ id, exactKm, remainingPayment }: { id: string; exactKm: number; remainingPayment?: number }) =>
firstMileService.update(id, { exactKm, ...(remainingPayment != null && { remainingPayment }) }),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.ROOT });
void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.list() });
if (activeRecord) {
toast({ title: "Distance updated", description: `${bookingRef(activeRecord)}${distanceValue} km` });
}
@@ -485,16 +498,39 @@ const FirstMilePage = () => {
setDistanceValue("");
};
const openInvoice = (record: FirstMileRecord) => {
setInvoiceRecord(record);
setInvoiceOpen(true);
};
const closeInvoice = () => {
setInvoiceOpen(false);
setInvoiceRecord(null);
};
const handleSaveDistance = () => {
const distance = parseFloat(distanceValue);
if (!activeId || isNaN(distance) || distance < 0) {
toast({ title: "Invalid distance", description: "Enter a valid distance value.", variant: "destructive" });
return;
}
updateDistanceMutation.mutate({ id: activeId, exactKm: distance });
let remainingPayment: number | undefined;
if (ratesData?.data) {
const firstMileRate = ratesData.data.find(
(r) => r.rateType === "FIRST_MILE" && (r.status === "LIVE" || r.status === "DRAFT")
);
if (firstMileRate) {
const rateValue = parseFloat(firstMileRate.rateValue);
remainingPayment = distance * rateValue;
}
}
updateDistanceMutation.mutate({ id: activeId, exactKm: distance, remainingPayment });
};
const matchesFilter = (r: FirstMileRecord) => {
if (filterPostPaymentPending && r.isPostPaymentCompleted) return false;
switch (statusFilter) {
case "ALL": return true;
case "ASSIGNED": return isAssigned(r);
@@ -532,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(() => {
@@ -701,6 +737,27 @@ const FirstMilePage = () => {
meta: { headerClassName, cellClassName },
cell: ({ row }) => row.original.exactKm != null ? row.original.exactKm : <Text c="dimmed"></Text>,
},
{
id: "invoice",
header: "Invoice",
meta: { headerClassName, cellClassName },
cell: ({ row }) => {
const hasDistance = row.original.exactKm != null && row.original.exactKm > 0;
if (!hasDistance) {
return <Text c="dimmed"></Text>;
}
return (
<UnstyledButton
onClick={() => openInvoice(row.original)}
c="blue"
fw={500}
style={{ textDecoration: "underline", cursor: "pointer" }}
>
#345
</UnstyledButton>
);
},
},
{
id: "status",
header: "Status",
@@ -767,9 +824,10 @@ const FirstMilePage = () => {
View detail
</Menu.Item>
<Menu.Item
leftSection={<Ruler size={15} />}
onClick={() => openDistance(row.original.id)}
>
Add Actual distance
Add distance
</Menu.Item>
{canPrint && (
<Menu.Item
@@ -831,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>
@@ -1122,6 +1191,87 @@ const FirstMilePage = () => {
</Group>
</Stack>
</Modal>
{/* Invoice modal */}
<Modal
opened={invoiceOpen}
onClose={closeInvoice}
title={<Text fw={600}>Invoice #345</Text>}
size="lg"
radius="lg"
centered
>
<Stack gap="md">
{invoiceRecord && (
<>
<Card withBorder padding="md" radius="md" bg="var(--mantine-color-gray-0)">
<Stack gap="sm">
<Group justify="space-between">
<Text fw={700}>EDR Freight</Text>
<Text fw={600} size="sm">Invoice #345</Text>
</Group>
<Divider />
<SimpleGrid cols={2} spacing="sm">
<InfoRow label="Booking" value={bookingRef(invoiceRecord)} />
<InfoRow label="Customer" value={customerName(invoiceRecord)} />
<InfoRow label="Pickup" value={pickupLocation(invoiceRecord)} />
<InfoRow label="Destination" value={destinationYardName(invoiceRecord)} />
<InfoRow label="Est. Distance" value={invoiceRecord.estimatedKm ? `${invoiceRecord.estimatedKm} km` : "—"} />
<InfoRow label="Actual Distance" value={invoiceRecord.exactKm ? `${invoiceRecord.exactKm} km` : "—"} />
<InfoRow label="Advanced Payment" value={formatPrice(invoiceRecord.advancedPayment)} />
<InfoRow label="Post Payment" value={formatPrice(invoiceRecord.remainingPayment)} />
</SimpleGrid>
<Divider />
<Group justify="flex-end">
<Stack gap={0} align="flex-end" style={{ minWidth: 200 }}>
<Group justify="space-between" w="100%">
<Text size="sm" c="dimmed">Post Payment</Text>
<Text size="sm">{formatPrice(invoiceRecord.remainingPayment)}</Text>
</Group>
<Group justify="space-between" w="100%">
<Text size="sm" c="dimmed">Advanced Payment</Text>
<Text size="sm">{formatPrice(invoiceRecord.advancedPayment)}</Text>
</Group>
<Divider my="xs" />
{(() => {
const postPayment = parseFloat(String(invoiceRecord.remainingPayment));
const advancedPayment = parseFloat(String(invoiceRecord.advancedPayment));
const difference = postPayment - advancedPayment;
if (difference > 0) {
return (
<Group justify="space-between" w="100%">
<Text size="sm" fw={600}>Remaining to Pay</Text>
<Text fw={700} c="orange">{formatPrice(difference)}</Text>
</Group>
);
} else if (difference < 0) {
return (
<Group justify="space-between" w="100%">
<Text size="sm" fw={600}>Refund</Text>
<Text fw={700} c="green">{formatPrice(Math.abs(difference))}</Text>
</Group>
);
} else {
return (
<Group justify="space-between" w="100%">
<Text size="sm" fw={600}>Status</Text>
<Text fw={700} c="blue">Settled</Text>
</Group>
);
}
})()}
</Stack>
</Group>
</Stack>
</Card>
</>
)}
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={closeInvoice}>Close</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
};

View File

@@ -5,6 +5,7 @@ import {
MoreHorizontal,
Printer,
RefreshCw,
Ruler,
Truck,
} from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -21,12 +22,14 @@ import {
Group,
Menu,
Modal,
NumberInput,
ScrollArea,
Select,
SimpleGrid,
Stack,
Text,
TextInput,
UnstyledButton,
} from "@mantine/core";
import type { ArrivalQueueItem } from "@/types/warehouse";
import { warehouseService } from "@/services/warehouse.service";
@@ -41,6 +44,7 @@ import {
lastMileService,
} from "@/services/last-mile.service";
import { vehiclesService } from "@/services/vehicles.service";
import { ratesService } from "@/services/rates.service";
const formatPrice = (amount: number) =>
`ETB ${amount.toLocaleString("en-US", {
@@ -294,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);
@@ -311,6 +316,11 @@ const LastMilePage = () => {
const [acceptVehicleValue, setAcceptVehicleValue] = useState<string | null>(null);
const [arrivalSearch, setArrivalSearch] = useState("");
const [distanceOpen, setDistanceOpen] = useState(false);
const [distanceValue, setDistanceValue] = useState("");
const [invoiceOpen, setInvoiceOpen] = useState(false);
const [invoiceRecord, setInvoiceRecord] = useState<LastMileRecord | null>(null);
const { data: listData, isLoading } = useQuery({
queryKey: QUERY_KEYS.LAST_MILE.list(),
queryFn: async () => {
@@ -327,6 +337,14 @@ const LastMilePage = () => {
},
});
const { data: ratesData } = useQuery({
queryKey: ["rates", "LAST_MILE"],
queryFn: async () => {
const res = await ratesService.getByType("LAST_MILE");
return res.data;
},
});
const records = listData?.data ?? [];
const vehicleOptions = useMemo(
@@ -352,6 +370,21 @@ const LastMilePage = () => {
},
});
const updateDistanceMutation = useMutation({
mutationFn: ({ id, exactKm, remainingPayment }: { id: string; exactKm: number; remainingPayment?: number }) =>
lastMileService.update(id, { exactKm, ...(remainingPayment != null && { remainingPayment }) }),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.list() });
if (activeRecord) {
toast({ title: "Distance updated", description: `${bookingRef(activeRecord)}${distanceValue} km` });
}
closeDistance();
},
onError: () => {
toast({ title: "Update failed", variant: "destructive" });
},
});
const { data: arrivalQueueData, isLoading: arrivalLoading } = useQuery({
queryKey: ["warehouse-inventory", "arrival-queue"],
queryFn: () => warehouseService.arrivalQueue().then((r) => r.data),
@@ -422,6 +455,49 @@ const LastMilePage = () => {
acceptMutation.mutate({ items: selectedArrivalItems, vehicleId: acceptVehicleValue });
};
const openDistance = (id: string) => {
setActiveId(id);
setDistanceValue("");
setDistanceOpen(true);
};
const closeDistance = () => {
setDistanceOpen(false);
setActiveId(null);
setDistanceValue("");
};
const openInvoice = (record: LastMileRecord) => {
setInvoiceRecord(record);
setInvoiceOpen(true);
};
const closeInvoice = () => {
setInvoiceOpen(false);
setInvoiceRecord(null);
};
const handleSaveDistance = () => {
const distance = parseFloat(distanceValue);
if (!activeId || isNaN(distance) || distance < 0) {
toast({ title: "Invalid distance", description: "Enter a valid distance value.", variant: "destructive" });
return;
}
let remainingPayment: number | undefined;
if (ratesData?.data) {
const lastMileRate = ratesData.data.find(
(r) => r.rateType === "LAST_MILE" && (r.status === "LIVE" || r.status === "DRAFT")
);
if (lastMileRate) {
const rateValue = parseFloat(lastMileRate.rateValue);
remainingPayment = distance * rateValue;
}
}
updateDistanceMutation.mutate({ id: activeId, exactKm: distance, remainingPayment });
};
const activeRecord = useMemo(
() => records.find((r) => r.id === activeId) ?? null,
[records, activeId],
@@ -433,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);
@@ -470,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(() => {
@@ -639,6 +716,27 @@ const LastMilePage = () => {
meta: { headerClassName, cellClassName },
cell: ({ row }) => row.original.exactKm != null ? row.original.exactKm : <Text c="dimmed"></Text>,
},
{
id: "invoice",
header: "Invoice",
meta: { headerClassName, cellClassName },
cell: ({ row }) => {
const hasDistance = row.original.exactKm != null && row.original.exactKm > 0;
if (!hasDistance) {
return <Text c="dimmed"></Text>;
}
return (
<UnstyledButton
onClick={() => openInvoice(row.original)}
c="blue"
fw={500}
style={{ textDecoration: "underline", cursor: "pointer" }}
>
#345
</UnstyledButton>
);
},
},
{
id: "status",
header: "Status",
@@ -704,6 +802,12 @@ const LastMilePage = () => {
>
View detail
</Menu.Item>
<Menu.Item
leftSection={<Ruler size={15} />}
onClick={() => openDistance(row.original.id)}
>
Add distance
</Menu.Item>
{canPrint && (
<Menu.Item
leftSection={<Printer size={15} />}
@@ -764,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>
@@ -1000,6 +1115,134 @@ const LastMilePage = () => {
</Group>
</Stack>
</Modal>
{/* Add Actual Distance modal */}
<Modal
opened={distanceOpen}
onClose={closeDistance}
title={<Text fw={600}>Add Actual Distance</Text>}
size="md"
radius="lg"
centered
>
<Stack gap="md">
{activeRecord && (
<Card withBorder padding="md" radius="md" bg="var(--mantine-color-gray-0)">
<Stack gap="sm">
<Text fw={600} size="sm">{bookingRef(activeRecord)}</Text>
<Group justify="space-between">
<Text size="xs" c="dimmed">Customer</Text>
<Text size="sm">{customerName(activeRecord)}</Text>
</Group>
<Group justify="space-between">
<Text size="xs" c="dimmed">Est. Distance (KM)</Text>
<Text size="sm">{activeRecord.estimatedKm ?? "—"}</Text>
</Group>
</Stack>
</Card>
)}
<NumberInput
label="Actual Distance (KM)"
placeholder="Enter distance"
value={distanceValue}
onChange={(v) => setDistanceValue(String(v ?? ""))}
min={0}
step={0.1}
decimalScale={2}
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={closeDistance}>Cancel</Button>
<Button
onClick={handleSaveDistance}
loading={updateDistanceMutation.isPending}
disabled={!distanceValue}
>
Save
</Button>
</Group>
</Stack>
</Modal>
{/* Invoice modal */}
<Modal
opened={invoiceOpen}
onClose={closeInvoice}
title={<Text fw={600}>Invoice #345</Text>}
size="lg"
radius="lg"
centered
>
<Stack gap="md">
{invoiceRecord && (
<>
<Card withBorder padding="md" radius="md" bg="var(--mantine-color-gray-0)">
<Stack gap="sm">
<Group justify="space-between">
<Text fw={700}>EDR Freight</Text>
<Text fw={600} size="sm">Invoice #345</Text>
</Group>
<Divider />
<SimpleGrid cols={2} spacing="sm">
<InfoRow label="Booking" value={bookingRef(invoiceRecord)} />
<InfoRow label="Customer" value={customerName(invoiceRecord)} />
<InfoRow label="Pickup" value={originYardName(invoiceRecord)} />
<InfoRow label="Destination" value={deliveryLocation(invoiceRecord)} />
<InfoRow label="Est. Distance" value={invoiceRecord.estimatedKm ? `${invoiceRecord.estimatedKm} km` : "—"} />
<InfoRow label="Actual Distance" value={invoiceRecord.exactKm ? `${invoiceRecord.exactKm} km` : "—"} />
<InfoRow label="Advanced Payment" value={formatPrice(invoiceRecord.advancedPayment)} />
<InfoRow label="Post Payment" value={formatPrice(invoiceRecord.remainingPayment)} />
</SimpleGrid>
<Divider />
<Group justify="flex-end">
<Stack gap={0} align="flex-end" style={{ minWidth: 200 }}>
<Group justify="space-between" w="100%">
<Text size="sm" c="dimmed">Post Payment</Text>
<Text size="sm">{formatPrice(invoiceRecord.remainingPayment)}</Text>
</Group>
<Group justify="space-between" w="100%">
<Text size="sm" c="dimmed">Advanced Payment</Text>
<Text size="sm">{formatPrice(invoiceRecord.advancedPayment)}</Text>
</Group>
<Divider my="xs" />
{(() => {
const postPayment = parseFloat(String(invoiceRecord.remainingPayment));
const advancedPayment = parseFloat(String(invoiceRecord.advancedPayment));
const difference = postPayment - advancedPayment;
if (difference > 0) {
return (
<Group justify="space-between" w="100%">
<Text size="sm" fw={600}>Remaining to Pay</Text>
<Text fw={700} c="orange">{formatPrice(difference)}</Text>
</Group>
);
} else if (difference < 0) {
return (
<Group justify="space-between" w="100%">
<Text size="sm" fw={600}>Refund</Text>
<Text fw={700} c="green">{formatPrice(Math.abs(difference))}</Text>
</Group>
);
} else {
return (
<Group justify="space-between" w="100%">
<Text size="sm" fw={600}>Status</Text>
<Text fw={700} c="blue">Settled</Text>
</Group>
);
}
})()}
</Stack>
</Group>
</Stack>
</Card>
</>
)}
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={closeInvoice}>Close</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
};

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

@@ -0,0 +1,35 @@
import { api } from '../auth/http';
export interface Rate {
id: string;
rateType: string;
appliesTo: string;
trigger: string;
containerTypeId: string | null;
cargoTypeId: string | null;
tradeDirection: string | null;
currency: string;
rateValue: string;
rateUnit: string;
status: string;
proposedByStaffId: string;
approvedByCeoId: string | null;
approvedAt: string | null;
effectiveFrom: string;
effectiveTo: string | null;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
}
export interface RatesListResponse {
data: Rate[];
meta: { total: number; page: number; pageSize: number; totalPages: number };
}
export const ratesService = {
list: (pageSize = 1000) =>
api.get<RatesListResponse>(`/rates?pageSize=${pageSize}`),
getByType: (rateType: string) =>
api.get<RatesListResponse>(`/rates?rateType=${rateType}&pageSize=1000`),
};

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

@@ -18,6 +18,9 @@ export type CompanyType =
/** Mirrors backend `CompanyStatus`. */
export type CompanyStatus = "active" | "pending" | "suspended" | "blacklisted";
/** Mirrors backend `CompanyKind` — commercial customer vs. government entity. */
export type CompanyKind = "commercial" | "government";
/** Mirrors backend `ProfileType` (the role a company plays). */
export type ProfileType =
| "importer"
@@ -47,6 +50,7 @@ export interface Company {
id: string;
name: string;
type: CompanyType;
kind: CompanyKind;
status: CompanyStatus;
tin: string;
vatNumber?: string | null;
@@ -73,6 +77,7 @@ export interface CompanyListFilter {
pageSize: number;
search?: string;
type?: CompanyType;
kind?: CompanyKind;
status?: CompanyStatus;
}

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;

View File

@@ -3,7 +3,6 @@ import { fileURLToPath } from "node:url";
import { createRequire } from "node:module";
import { defineConfig } from "vitest/config";
import { loadEnv, type Plugin } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
@@ -11,7 +10,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
const require = createRequire(import.meta.url);
const streamBrowserifyPath = require.resolve("stream-browserify");
export default defineConfig(({ mode }) => {
export default defineConfig(() => {
return {
plugins: [react(), tailwindcss()],
resolve: {

View File

@@ -31,7 +31,8 @@ import LoginPage from "./pages/accounts/LoginPage";
import SetPasswordPage from "./pages/accounts/SetPasswordPage";
import SignupPage from "./pages/accounts/SignupPage";
import VerificationOtpPage from "./pages/accounts/VerificationOtpPage";
import BillingPage from "./pages/billing/BillingPage";
import InvoiceDetailPage from "./pages/billing/InvoiceDetailPage";
import InvoicesList from "./pages/billing/InvoicesList";
import BookingContractPage from "./pages/bookings/BookingContractPage";
import BookingDetailPage from "./pages/bookings/BookingDetailPage";
import EditBookingPage from "./pages/bookings/EditBookingPage";
@@ -188,7 +189,7 @@ const sidebarItems: SidebarItem[] = [
icon: <MapPin size={18} />,
},
{
label: "Billing",
label: "Invoices",
href: "/billing",
icon: <Receipt size={18} />,
},
@@ -285,7 +286,8 @@ const App = () => {
/>
<Route path="/contracts/:id" element={<ContractDetailPage />} />
<Route path="/tracking" element={<TrackingPage />} />
<Route path="/billing" element={<BillingPage />} />
<Route path="/billing" element={<InvoicesList />} />
<Route path="/billing/:id" element={<InvoiceDetailPage />} />
{/* Profile was merged into Settings — keep old links working. */}
<Route
path="/profile"

View File

@@ -143,4 +143,10 @@ export const URL_CONSTANTS = {
INTENT: (bookingId: string) => `/api/payments/intents/${bookingId}`,
CHECKOUT: "/api/payments/checkout",
},
BILLING: {
MY_INVOICES: "/api/billing/my-invoices",
MY_INVOICE_BY_ID: (id: string) => `/api/billing/my-invoices/${id}`,
PAY_INVOICE: (id: string) => `/api/billing/my-invoices/${id}/pay`,
},
};

View File

@@ -0,0 +1,23 @@
/** Currency code carried on invoices / dashboard figures (ETB, USD, DJF, …). */
export type Currency = string;
const SYMBOLS: Record<string, string> = {
USD: "$",
ETB: "Br",
DJF: "DJF",
};
/**
* Format a money amount with its currency symbol, e.g. `Br 12,500.00`.
* Unknown currency codes fall back to printing the raw code.
*/
export function formatCurrency(
amount: number,
currency: Currency = "ETB",
): string {
const symbol = SYMBOLS[currency] ?? currency;
return `${symbol} ${Number(amount ?? 0).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`;
}

View File

@@ -1,20 +0,0 @@
import { invoices, type Invoice } from "@/pages/billing/invoices.mock";
import { customers, type Customer } from "@/pages/customers/customers.mock";
/**
* Mock "logged-in customer". When auth integrates, replace this with the value
* pulled from `@edr/iamui-common` / the JWT context.
*/
const CURRENT_CUSTOMER_ID = 1;
export function getCurrentCustomer(): Customer {
return (
customers.find((c) => c.id === CURRENT_CUSTOMER_ID) ??
(customers[0] as Customer)
);
}
export function getMyInvoices(): Invoice[] {
const me = getCurrentCustomer();
return invoices.filter((inv) => inv.customerId === me.id);
}

View File

@@ -1,5 +1,5 @@
import type { Currency } from "@/pages/billing/invoices.mock";
import { formatCurrency } from "@/pages/billing/invoices.mock";
import type { Currency } from "@/lib/currency";
import { formatCurrency } from "@/lib/currency";
import { Group, Grid, Select, Stack } from "@mantine/core";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
@@ -34,7 +34,6 @@ export default function MyPortalPage() {
totalOutstanding,
companyName,
greeting,
recentInvoices,
dashboard,
volumePoints,
maxVolume,
@@ -120,7 +119,7 @@ export default function MyPortalPage() {
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 4 }}>
<InvoicesSection invoices={recentInvoices} />
<InvoicesSection invoices={outstandingInvoices} />
</Grid.Col>
</Grid>

View File

@@ -1,7 +1,7 @@
import { Box, Group, Skeleton, Text } from "@mantine/core";
import { memo } from "react";
import type { Currency } from "@/pages/billing/invoices.mock";
import { formatCurrency } from "@/pages/billing/invoices.mock";
import type { Currency } from "@/lib/currency";
import { formatCurrency } from "@/lib/currency";
import { formatPct } from "../constants";
import { Card } from "./Card";

View File

@@ -1,8 +1,8 @@
import type { Currency, InvoiceStatus } from "@/pages/billing/invoices.mock";
import { formatCurrency } from "@/pages/billing/invoices.mock";
import { formatCurrency } from "@/lib/currency";
import type { PortalInvoice } from "@/services/invoices.service";
import { Box, Group, Stack, Text } from "@mantine/core";
import { format } from "date-fns";
import { CheckCircle2, ChevronRight, Clock3, Zap } from "lucide-react";
import { Freight } from "@edr/types";
import { ChevronRight, Clock3 } from "lucide-react";
import { memo } from "react";
import { Link } from "react-router-dom";
import { cv, INVOICE_BADGE } from "../constants";
@@ -10,26 +10,26 @@ import { Card } from "./Card";
import { EmptyState } from "./EmptyState";
interface InvoicesSectionProps {
invoices: Array<{
id: number;
number: string;
bookingReference: string;
amount: number;
currency: Currency;
status: InvoiceStatus;
dueDate: string;
paidDate: string | null;
}>;
invoices: PortalInvoice[];
}
/** Max rows to list in the compact dashboard card. */
const MAX_ROWS = 5;
const titleCase = (v: string) =>
v ? v.charAt(0).toUpperCase() + v.slice(1).toLowerCase() : "";
export const InvoicesSection = memo(function InvoicesSection({
invoices,
}: InvoicesSectionProps) {
const outstandingInvoices = invoices.filter(
(inv) => inv.status === "Sent" || inv.status === "Overdue",
// Dashboard shows unpaid invoices only (pending + overdue).
const pendingInvoices = invoices.filter(
(inv) =>
inv.status === Freight.InvoiceStatus.Pending ||
inv.status === Freight.InvoiceStatus.Overdue,
);
const totalOutstanding = outstandingInvoices.reduce(
(sum, inv) => sum + inv.amount,
const totalOutstanding = pendingInvoices.reduce(
(sum, inv) => sum + Number(inv.totalAmount),
0,
);
@@ -56,94 +56,74 @@ export const InvoicesSection = memo(function InvoicesSection({
<Text fz={24} fw={800} mt={4} c="edr-text">
{formatCurrency(totalOutstanding || 0, "ETB")}
</Text>
<Group
justify="space-between"
align="center"
mt={8}
wrap="nowrap"
>
<Text fz={12} c="edr-amber-text">
{outstandingInvoices.length || 2} invoices unpaid
</Text>
<Group
gap={5}
align="center"
px={14}
py={8}
bg="edr-accent"
className="cursor-pointer rounded-[9px]"
>
<Zap size={15} color="#fff" />
<Text fz={13} fw={700} c="white">
Pay all
</Text>
</Group>
</Group>
<Text fz={12} mt={8} c="edr-amber-text">
{pendingInvoices.length} invoices unpaid
</Text>
</Box>
{invoices.length === 0 ? (
<EmptyState message="No invoices yet." />
{pendingInvoices.length === 0 ? (
<EmptyState message="No pending invoices." />
) : (
<Stack gap={0}>
{invoices.map((invoice, i) => {
{pendingInvoices.slice(0, MAX_ROWS).map((invoice, i) => {
const badge = INVOICE_BADGE[invoice.status];
const dueText =
invoice.status === "Paid"
? `Paid ${format(new Date(invoice.paidDate ?? invoice.dueDate), "MMM d")}`
: invoice.status === "Overdue"
? "Overdue 3 days"
: `Due ${invoice.dueDate}`;
const DueIcon =
invoice.status === "Paid" ? CheckCircle2 : Clock3;
const dueIconColor =
invoice.status === "Paid"
? cv("edr-green.5")
: cv("edr-muted");
const isOverdue = invoice.status === Freight.InvoiceStatus.Overdue;
const dueText = isOverdue
? "Overdue"
: `Due ${new Date(invoice.dueAt).toLocaleDateString()}`;
const dueIconColor = isOverdue ? cv("edr-red") : cv("edr-muted");
return (
<Box key={invoice.id}>
{i > 0 && <Box h={1} bg="edr-divider" />}
<Stack gap={8} py={10}>
<Group
justify="space-between"
align="flex-start"
wrap="nowrap"
<Link
to={`/billing/${invoice.id}`}
className="no-underline"
style={{ display: "block", color: "inherit" }}
>
<Stack
gap={8}
py={10}
className="cursor-pointer rounded-[9px] transition-colors hover:bg-[var(--mantine-color-edr-soft-0)]"
>
<Box>
<Text fz={13} fw={700} c="edr-text">
{invoice.number}
</Text>
<Text fz={11} c="edr-muted">
{invoice.bookingReference}
</Text>
</Box>
<Text fz={14} fw={700} c="edr-text">
{formatCurrency(invoice.amount, invoice.currency)}
</Text>
</Group>
<Group
justify="space-between"
align="center"
wrap="nowrap"
>
<Group gap={5} align="center">
<DueIcon size={13} color={dueIconColor} />
<Text fz={12} c="edr-muted">
{dueText}
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Box>
<Text fz={13} fw={700} c="edr-text">
{invoice.invoiceNumber}
</Text>
<Text fz={11} c="edr-muted">
{titleCase(invoice.source)} · {titleCase(invoice.type)}
</Text>
</Box>
<Text fz={14} fw={700} c="edr-text">
{formatCurrency(
Number(invoice.totalAmount),
invoice.currency,
)}
</Text>
</Group>
<Box
bg={badge.bg}
px={10}
py={4}
className="rounded-full"
>
<Text fz={11} fw={700} c={badge.text}>
{badge.label}
</Text>
</Box>
</Group>
</Stack>
<Group justify="space-between" align="center" wrap="nowrap">
<Group gap={5} align="center">
<Clock3 size={13} color={dueIconColor} />
<Text fz={12} c="edr-muted">
{dueText}
</Text>
</Group>
{badge && (
<Box
bg={badge.bg}
px={10}
py={4}
className="rounded-full"
>
<Text fz={11} fw={700} c={badge.text}>
{badge.label}
</Text>
</Box>
)}
</Group>
</Stack>
</Link>
</Box>
);
})}

View File

@@ -1,4 +1,4 @@
import { formatCurrency } from "@/pages/billing/invoices.mock";
import { formatCurrency } from "@/lib/currency";
import { SimpleGrid } from "@mantine/core";
import { CheckCircle2, Clock3, Layers, Truck, Wallet } from "lucide-react";
import { memo } from "react";

View File

@@ -12,7 +12,7 @@ import {
Wallet,
type LucideIcon,
} from "lucide-react";
import type { InvoiceStatus } from "@/pages/billing/invoices.mock";
import { Freight } from "@edr/types";
export const cv = (token: string) => {
const [name, shade] = token.split(".");
@@ -514,12 +514,13 @@ export const ACTION_PROPS: Record<
};
export const INVOICE_BADGE: Record<
InvoiceStatus,
Freight.InvoiceStatus,
{ label: string; bg: string; text: string }
> = {
Draft: { label: "Draft", bg: "edr-slate-soft", text: "edr-slate" },
Sent: { label: "Due soon", bg: "edr-amber-soft", text: "edr-amber-text" },
Paid: { label: "Paid", bg: "edr-soft", text: "edr-green.7" },
Overdue: { label: "Overdue", bg: "edr-red-soft", text: "edr-red" },
Cancelled: { label: "Cancelled", bg: "edr-slate-soft", text: "edr-slate" },
[Freight.InvoiceStatus.Draft]: { label: "Draft", bg: "edr-slate-soft", text: "edr-slate" },
[Freight.InvoiceStatus.Pending]: { label: "Due soon", bg: "edr-amber-soft", text: "edr-amber-text" },
[Freight.InvoiceStatus.Paid]: { label: "Paid", bg: "edr-soft", text: "edr-green.7" },
[Freight.InvoiceStatus.Overdue]: { label: "Overdue", bg: "edr-red-soft", text: "edr-red" },
[Freight.InvoiceStatus.Cancelled]: { label: "Cancelled", bg: "edr-slate-soft", text: "edr-slate" },
[Freight.InvoiceStatus.Refunded]: { label: "Refunded", bg: "edr-blue-soft", text: "edr-blue" },
};

View File

@@ -1,13 +1,14 @@
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import { Freight } from "@edr/types";
import useAuth from "@/hooks/useAuth";
import { getMyInvoices } from "@/lib/currentCustomer";
import { api } from "@/services/api";
import { ACTIVE_STATUSES } from "./constants";
export function useMyPortalData(selectedProfileId?: string) {
const { user, customer, company } = useAuth();
const myInvoices = useMemo(() => getMyInvoices(), []);
const invoicesQuery = useQuery(api.invoices.listMy.queryOptions());
const myInvoices = invoicesQuery.data ?? [];
const companyProfiles = company?.company?.companyProfiles ?? [];
@@ -59,11 +60,13 @@ export function useMyPortalData(selectedProfileId?: string) {
).length;
const outstandingInvoices = myInvoices.filter(
(inv) => inv.status === "Sent" || inv.status === "Overdue",
(inv) =>
inv.status === Freight.InvoiceStatus.Pending ||
inv.status === Freight.InvoiceStatus.Overdue,
);
const totalOutstanding = outstandingInvoices.reduce(
(sum, inv) => sum + inv.amount,
(sum, inv) => sum + Number(inv.totalAmount),
0,
);
@@ -91,6 +94,7 @@ export function useMyPortalData(selectedProfileId?: string) {
bookingsQuery,
dashboardQuery,
contractsQuery,
invoicesQuery,
allContracts,
recentContracts,
activeContractsCount,

View File

@@ -1,382 +0,0 @@
import { useMemo, useState } from "react";
import {
AlertCircle,
Clock,
DollarSign,
Download,
Filter,
MoreHorizontal,
Pencil,
Plus,
Receipt,
Search,
Trash2,
} from "lucide-react";
import Breadcrumbs from "@/components/Breadcrumbs";
import NewInvoicePage from "./NewInvoicePage";
import DeleteInvoiceDialog from "./DeleteInvoiceDialog";
import { formatCurrency, invoices, type InvoiceStatus } from "./invoices.mock";
import {
DataTable,
DataTableFooter,
type ColumnDef,
usePagination,
Button,
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
Input,
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from "@edr/ui-common";
type FilterValue = "All" | InvoiceStatus;
const FILTERS: FilterValue[] = [
"All",
"Draft",
"Sent",
"Paid",
"Overdue",
"Cancelled",
];
export default function BillingPage() {
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [filter, setFilter] = useState<FilterValue>("All");
const [query, setQuery] = useState("");
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return invoices.filter((inv) => {
if (filter !== "All" && inv.status !== filter) return false;
if (!q) return true;
return (
inv.number.toLowerCase().includes(q) ||
inv.customer.toLowerCase().includes(q) ||
inv.bookingReference.toLowerCase().includes(q)
);
});
}, [filter, query]);
const total = filtered.length;
const pageCount = Math.ceil(total / pagination.pageSize);
const start = pagination.pageIndex * pagination.pageSize;
const end = Math.min(start + pagination.pageSize, total);
const paginatedData = useMemo(
() => filtered.slice(start, end),
[start, end, filtered],
);
const totalRevenue = invoices
.filter((inv) => inv.status === "Paid" && inv.currency === "USD")
.reduce((sum, inv) => sum + inv.amount, 0);
const outstanding = invoices
.filter(
(inv) =>
(inv.status === "Sent" || inv.status === "Overdue") &&
inv.currency === "USD",
)
.reduce((sum, inv) => sum + inv.amount, 0);
const overdueCount = invoices.filter(
(inv) => inv.status === "Overdue",
).length;
const columns: ColumnDef<(typeof invoices)[number]>[] = [
{
id: "invoice",
header: "Invoice",
cell: ({ row }) => {
const inv = row.original;
return (
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground">
<Receipt />
</div>
<div>
<p className="font-medium text-slate-900">{inv.number}</p>
<p className="text-sm text-slate-500">Issued {inv.issueDate}</p>
</div>
</div>
);
},
},
{
accessorKey: "customer",
header: "Customer",
},
{
accessorKey: "bookingReference",
header: "Booking",
},
{
id: "amount",
header: "Amount",
cell: ({ row }) => {
const inv = row.original;
return (
<span className="text-sm font-medium text-slate-900">
{formatCurrency(inv.amount, inv.currency)}
</span>
);
},
},
{
accessorKey: "dueDate",
header: "Due Date",
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
id: "actions",
size: 40,
cell: ({ row }) => {
const invoice = row.original;
return (
<div
className="flex justify-end"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>
<Download />
Download
</DropdownMenuItem>
<NewInvoicePage
mode="edit"
invoice={{
number: invoice.number,
customerId: invoice.customerId,
bookingReference: invoice.bookingReference,
amount: invoice.amount,
currency: invoice.currency,
status: invoice.status,
issueDate: invoice.issueDate,
dueDate: invoice.dueDate,
notes: invoice.notes,
}}
>
<DropdownMenuItem onSelect={(e: Event) => e.preventDefault()}>
<Pencil />
Edit
</DropdownMenuItem>
</NewInvoicePage>
<DropdownMenuSeparator />
<DeleteInvoiceDialog invoiceNumber={invoice.number}>
<DropdownMenuItem
onSelect={(e: Event) => e.preventDefault()}
variant="destructive"
>
<Trash2 />
Void
</DropdownMenuItem>
</DeleteInvoiceDialog>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
},
];
return (
<div className="min-h-screen p-6">
<div className="space-y-6">
<Breadcrumbs items={[{ label: "Billing" }]} />
<Card className="p-6 flex-row justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
Billing
</h1>
<p className="mt-1 text-sm text-secondary-foreground">
Manage invoices, payments, and financial records.
</p>
</div>
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
<div className="relative w-full sm:w-80">
<Search className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input
type="search"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setPagination({
pageIndex: 0,
pageSize: pagination.pageSize,
});
}}
placeholder="Search invoices..."
className="pl-8!"
/>
</div>
<NewInvoicePage>
<Button>
<Plus />
New Invoice
</Button>
</NewInvoicePage>
</div>
</Card>
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Total Revenue (USD)</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">
{formatCurrency(totalRevenue, "USD")}
</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<DollarSign />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Outstanding (USD)</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">
{formatCurrency(outstanding, "USD")}
</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Clock />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Overdue Invoices</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">
{overdueCount}
</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-red-100 text-red-600">
<AlertCircle />
</div>
</CardContent>
</Card>
</div>
<Card className="p-2">
<div className="flex flex-wrap gap-1">
{FILTERS.map((f) => {
const isActive = f === filter;
const count =
f === "All"
? invoices.length
: invoices.filter((inv) => inv.status === f).length;
return (
<button
key={f}
type="button"
onClick={() => {
setFilter(f);
setPagination({
pageIndex: 0,
pageSize: pagination.pageSize,
});
}}
className={
isActive
? "inline-flex items-center gap-2 rounded-2xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground"
: "inline-flex items-center gap-2 rounded-2xl px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-primary/10 hover:text-primary"
}
>
{f}
<span
className={
isActive
? "rounded-full bg-white/20 px-2 py-0.5 text-xs"
: "rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600"
}
>
{count}
</span>
</button>
);
})}
</div>
</Card>
<Card className="gap-0">
<CardHeader className="flex flex-row items-center justify-between border-b">
<div>
<CardTitle>Invoices</CardTitle>
<CardDescription>
Issued invoices and their payment status.
</CardDescription>
</div>
<Button variant="secondary" size="sm">
<Filter />
Filter
</Button>
</CardHeader>
<CardContent className="px-0">
<DataTable
columns={columns}
data={paginatedData}
status="success"
onRowClick={() => { }}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount: pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
}}
containerClassName="border-b shadow-none"
footer={DataTableFooter}
/>
</CardContent>
</Card>
</div>
</div>
);
}
function StatusBadge({ status }: { status: InvoiceStatus }) {
const styles: Record<InvoiceStatus, string> = {
Draft: "bg-slate-100 text-slate-600",
Sent: "bg-sky-100 text-sky-700",
Paid: "bg-emerald-100 text-emerald-700",
Overdue: "bg-red-100 text-red-700",
Cancelled: "bg-amber-100 text-amber-700",
};
return (
<span
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${styles[status]}`}
>
{status}
</span>
);
}

View File

@@ -1,63 +0,0 @@
import type { ReactNode } from "react";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
export interface DeleteInvoiceDialogProps {
invoiceNumber: string;
onConfirm?: () => void;
children: ReactNode;
}
export default function DeleteInvoiceDialog({
invoiceNumber,
onConfirm,
children,
}: DeleteInvoiceDialogProps) {
return (
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-md rounded-3xl">
<DialogHeader>
<DialogTitle className="text-xl font-bold">
Void invoice?
</DialogTitle>
<DialogDescription>
This will void invoice{" "}
<span className="font-semibold text-slate-900">
{invoiceNumber}
</span>
. This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-2">
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<DialogClose asChild>
<Button
onClick={onConfirm}
className="bg-red-600 text-white hover:bg-red-700"
>
Void Invoice
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,247 @@
import { useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
Alert,
Box,
Button,
Center,
Divider,
Group,
Loader,
Paper,
SimpleGrid,
Stack,
Table,
Text,
Title,
} from "@mantine/core";
import { ArrowLeft, CreditCard, Info } from "lucide-react";
import { api } from "@/services/api";
import { formatCurrency } from "@/lib/currency";
import { BORDER, INK, MUTED } from "../contracts/contract-ui";
import {
billedTo,
fmtDate,
InvoiceStatusBadge,
isPayable,
titleCase,
} from "./invoice-ui";
function MetaItem({ label, value }: { label: string; value: string }) {
return (
<Box>
<Text fz={11} fw={700} c={MUTED} style={{ textTransform: "uppercase", letterSpacing: "0.05em" }}>
{label}
</Text>
<Text fz={14} mt={4} style={{ color: INK }}>
{value}
</Text>
</Box>
);
}
export default function InvoiceDetailPage() {
const { id = "" } = useParams();
const navigate = useNavigate();
const { data: invoice, isLoading, isError } = useQuery(
api.invoices.get.queryOptions({ input: { id } }),
);
const payMutation = useMutation(
api.invoices.pay.mutationOptions({
onSuccess: (res) => {
const url = res.clientAction?.url;
if (url) window.location.href = url;
},
}),
);
if (isLoading) {
return (
<Center py={80}>
<Loader color="edr-green" />
</Center>
);
}
if (isError || !invoice) {
return (
<Box style={{ padding: "28px 32px" }}>
<Button
variant="subtle"
color="gray"
leftSection={<ArrowLeft size={16} />}
onClick={() => navigate("/billing")}
mb="md"
>
Back to invoices
</Button>
<Alert color="red" title="Invoice not found">
We couldn't load this invoice. It may not exist or you may not have
access to it.
</Alert>
</Box>
);
}
const payable = isPayable(invoice.status);
const lines = invoice.lines ?? [];
const handlePay = () => {
const returnUrl = `${window.location.origin}/payment/success`;
const failureUrl = `${window.location.origin}/payment/failure`;
payMutation.mutate({ id, payload: { returnUrl, failureUrl } });
};
return (
<Box style={{ padding: "28px 32px 32px" }}>
<Stack gap="lg">
<Button
variant="subtle"
color="gray"
leftSection={<ArrowLeft size={16} />}
onClick={() => navigate("/billing")}
style={{ alignSelf: "flex-start" }}
styles={{ root: { fontWeight: 600 } }}
>
Back to invoices
</Button>
{/* Header */}
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Group gap={12} align="center" wrap="wrap">
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
{invoice.invoiceNumber}
</Title>
<InvoiceStatusBadge status={invoice.status} />
</Group>
{payable && (
<Button
color="edr-green"
radius="md"
size="md"
leftSection={<CreditCard size={16} />}
loading={payMutation.isPending}
onClick={handlePay}
styles={{ root: { fontWeight: 600, height: 42, paddingInline: 18 } }}
>
Pay {formatCurrency(Number(invoice.totalAmount), invoice.currency)}
</Button>
)}
</Group>
{payMutation.isError && (
<Alert color="red" icon={<Info size={16} />} title="Payment could not be started">
Please try again, or contact support if the problem persists.
</Alert>
)}
{/* Summary */}
<Paper withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="lg">
<MetaItem label="Billed To" value={billedTo(invoice)} />
<MetaItem label="Source" value={`${titleCase(invoice.source)} · ${invoice.type}`} />
<MetaItem label="Issued" value={fmtDate(invoice.issuedAt)} />
<MetaItem label="Due" value={fmtDate(invoice.dueAt)} />
</SimpleGrid>
<Divider my="lg" color={BORDER} />
<Group justify="space-between" align="center">
<Text fz={14} fw={700} c={MUTED} style={{ textTransform: "uppercase", letterSpacing: "0.05em" }}>
Total
</Text>
<Text fz={24} fw={800} style={{ color: INK }}>
{formatCurrency(Number(invoice.totalAmount), invoice.currency)}
</Text>
</Group>
</Paper>
{/* Line items */}
<Paper
withBorder
radius="lg"
style={{ borderColor: BORDER, overflow: "hidden" }}
>
<Box px="lg" py="md" style={{ borderBottom: `1px solid ${BORDER}` }}>
<Text fz={15} fw={700} style={{ color: INK }}>
Line items
</Text>
</Box>
<Box style={{ overflowX: "auto" }}>
<Table
verticalSpacing={12}
horizontalSpacing={20}
styles={{
th: {
fontSize: 11,
fontWeight: 700,
letterSpacing: "0.05em",
textTransform: "uppercase",
color: MUTED,
background: "#F8FAFC",
borderBottom: `1px solid ${BORDER}`,
whiteSpace: "nowrap",
},
td: { borderBottom: `1px solid ${BORDER}` },
}}
>
<Table.Thead>
<Table.Tr>
<Table.Th>Charge</Table.Th>
<Table.Th ta="right">Qty</Table.Th>
<Table.Th ta="right">Unit Rate</Table.Th>
<Table.Th ta="right">Amount</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{lines.length === 0 && (
<Table.Tr>
<Table.Td colSpan={4}>
<Center py={28}>
<Text fz={13} c="dimmed">
No line items on this invoice.
</Text>
</Center>
</Table.Td>
</Table.Tr>
)}
{lines.map((line) => (
<Table.Tr key={line.id}>
<Table.Td>
<Text fz={14} fw={600} style={{ color: INK }}>
{titleCase(line.chargeType)}
</Text>
{line.description && (
<Text fz={12} c="dimmed">
{line.description}
</Text>
)}
</Table.Td>
<Table.Td ta="right">
<Text fz={13} style={{ color: INK }}>
{Number(line.quantity)}
</Text>
</Table.Td>
<Table.Td ta="right">
<Text fz={13} style={{ color: INK }}>
{formatCurrency(Number(line.unitRate), line.currency)}
</Text>
</Table.Td>
<Table.Td ta="right">
<Text fz={13} fw={700} style={{ color: INK }}>
{formatCurrency(Number(line.amount), line.currency)}
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Box>
</Paper>
</Stack>
</Box>
);
}

View File

@@ -0,0 +1,537 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
Box,
Button,
Center,
Group,
Loader,
Paper,
Select,
Stack,
Table,
Text,
TextInput,
Title,
} from "@mantine/core";
import {
AlertTriangle,
ChevronLeft,
ChevronRight,
CreditCard,
Eye,
FileStack,
Inbox,
Receipt,
Search,
Wallet,
X,
} from "lucide-react";
import { Freight } from "@edr/types";
import { api } from "@/services/api";
import { formatCurrency } from "@/lib/currency";
import {
BORDER,
GREEN,
INK,
MUTED,
StatCard,
} from "../contracts/contract-ui";
import {
billedTo,
fmtDate,
InvoiceStatusBadge,
isPayable,
PAYABLE_STATUSES,
titleCase,
} from "./invoice-ui";
const PAGE_SIZES = ["10", "25", "50"];
export default function InvoicesList() {
const navigate = useNavigate();
const [query, setQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<string | null>(null);
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(10);
const { data, isLoading, isError } = useQuery(
api.invoices.listMy.queryOptions(),
);
const all = useMemo(() => data ?? [], [data]);
const stats = useMemo(() => {
const outstanding = all.filter((i) =>
PAYABLE_STATUSES.includes(i.status),
).length;
const overdue = all.filter(
(i) => i.status === Freight.InvoiceStatus.Overdue,
).length;
return { outstanding, overdue, total: all.length };
}, [all]);
const rows = useMemo(() => {
const q = query.trim().toLowerCase();
return all.filter((inv) => {
if (statusFilter && inv.status !== statusFilter) return false;
if (!q) return true;
return (
inv.invoiceNumber.toLowerCase().includes(q) ||
inv.source.toLowerCase().includes(q) ||
inv.sourceId.toLowerCase().includes(q) ||
billedTo(inv).toLowerCase().includes(q)
);
});
}, [all, query, statusFilter]);
const total = rows.length;
const pageCount = Math.max(1, Math.ceil(total / pageSize));
const clampedIndex = Math.min(pageIndex, pageCount - 1);
const start = total === 0 ? 0 : clampedIndex * pageSize + 1;
const end = Math.min((clampedIndex + 1) * pageSize, total);
const pageRows = rows.slice(clampedIndex * pageSize, clampedIndex * pageSize + pageSize);
const resetPage = () => setPageIndex(0);
const goToPage = (i: number) =>
setPageIndex(Math.max(0, Math.min(i, pageCount - 1)));
const hasFilters = !!query || !!statusFilter;
return (
<Box style={{ padding: "28px 32px 32px" }}>
<Stack gap="lg">
{/* Header */}
<Group justify="space-between" align="center" wrap="wrap" gap="md">
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
Invoices
</Title>
</Group>
{/* Summary strip */}
<Group gap="md" wrap="wrap" align="stretch">
<StatCard
label="Outstanding"
hint="awaiting payment"
value={stats.outstanding}
icon={Wallet}
color="edr-accent"
/>
<StatCard
label="Overdue"
hint="past due date"
value={stats.overdue}
icon={AlertTriangle}
color="red"
/>
<StatCard
label="Total invoices"
value={stats.total}
icon={FileStack}
color="violet"
/>
</Group>
{/* Search + filters */}
<Paper withBorder radius="lg" p="sm" style={{ borderColor: BORDER }}>
<Group gap={10} wrap="wrap" align="center">
<TextInput
placeholder="Search by number, source or reference…"
leftSection={<Search size={16} />}
value={query}
onChange={(e) => {
setQuery(e.currentTarget.value);
resetPage();
}}
radius="md"
styles={{ input: { height: 42 } }}
style={{ flex: 1, minWidth: 220, maxWidth: 380 }}
/>
<Select
placeholder="Any status"
data={[
{ value: Freight.InvoiceStatus.Pending, label: "Due" },
{ value: Freight.InvoiceStatus.Overdue, label: "Overdue" },
{ value: Freight.InvoiceStatus.Paid, label: "Paid" },
{ value: Freight.InvoiceStatus.Draft, label: "Draft" },
{ value: Freight.InvoiceStatus.Cancelled, label: "Cancelled" },
{ value: Freight.InvoiceStatus.Refunded, label: "Refunded" },
]}
value={statusFilter}
onChange={(v) => {
setStatusFilter(v);
resetPage();
}}
clearable
radius="md"
comboboxProps={{ withinPortal: true }}
style={{ width: 160 }}
styles={{ input: { height: 42 } }}
aria-label="Filter by status"
/>
{hasFilters && (
<Button
variant="subtle"
color="gray"
radius="md"
leftSection={<X size={14} />}
onClick={() => {
setQuery("");
setStatusFilter(null);
resetPage();
}}
styles={{ root: { fontWeight: 600 } }}
>
Clear
</Button>
)}
</Group>
</Paper>
{/* Table */}
<Paper
withBorder
radius="lg"
style={{ borderColor: BORDER, overflow: "hidden" }}
>
<Box style={{ overflowX: "auto" }}>
<Table
verticalSpacing={14}
horizontalSpacing={20}
highlightOnHover
highlightOnHoverColor="#F4FBF8"
styles={{
th: {
fontSize: 11,
fontWeight: 700,
letterSpacing: "0.05em",
textTransform: "uppercase",
color: MUTED,
background: "#F8FAFC",
borderBottom: `1px solid ${BORDER}`,
whiteSpace: "nowrap",
position: "sticky",
top: 0,
zIndex: 1,
},
tr: { transition: "background-color 120ms ease" },
td: {
borderBottom: `1px solid ${BORDER}`,
verticalAlign: "middle",
},
}}
>
<Table.Thead>
<Table.Tr>
<Table.Th>Invoice</Table.Th>
<Table.Th>Billed To</Table.Th>
<Table.Th>Source</Table.Th>
<Table.Th ta="right">Amount</Table.Th>
<Table.Th>Issued</Table.Th>
<Table.Th>Due</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th ta="right">Action</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{isLoading && (
<Table.Tr>
<Table.Td colSpan={8}>
<Center py={48}>
<Loader color="edr-green" size="sm" />
</Center>
</Table.Td>
</Table.Tr>
)}
{!isLoading && isError && (
<Table.Tr>
<Table.Td colSpan={8}>
<Center py={48}>
<Text fz={13} c="red">
Failed to load invoices. Please try again.
</Text>
</Center>
</Table.Td>
</Table.Tr>
)}
{!isLoading && !isError && pageRows.length === 0 && (
<Table.Tr>
<Table.Td colSpan={8}>
<Stack align="center" gap={8} py={48}>
<Inbox size={26} color={MUTED} style={{ opacity: 0.5 }} />
<Text fz={13} c="dimmed">
{hasFilters
? "No invoices match your filters."
: "No invoices yet."}
</Text>
</Stack>
</Table.Td>
</Table.Tr>
)}
{!isLoading &&
!isError &&
pageRows.map((inv) => {
const payable = isPayable(inv.status);
return (
<Table.Tr
key={inv.id}
style={{ cursor: "pointer" }}
onClick={() => navigate(`/billing/${inv.id}`)}
>
<Table.Td>
<Group gap={10} wrap="nowrap" align="center">
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 34,
height: 34,
borderRadius: 9,
background: "#E6F7EF",
color: GREEN,
flexShrink: 0,
}}
>
<Receipt size={16} />
</Box>
<Box>
<Text fz={14} fw={700} style={{ color: INK }}>
{inv.invoiceNumber}
</Text>
<Text fz={12} c="dimmed">
{titleCase(inv.type)}
</Text>
</Box>
</Group>
</Table.Td>
<Table.Td>
<Text fz={13} style={{ color: INK }}>
{billedTo(inv)}
</Text>
</Table.Td>
<Table.Td>
<Text fz={13} style={{ color: INK }}>
{titleCase(inv.source)}
</Text>
</Table.Td>
<Table.Td ta="right">
<Text fz={13} fw={700} style={{ color: INK }}>
{formatCurrency(Number(inv.totalAmount), inv.currency)}
</Text>
</Table.Td>
<Table.Td>
<Text
fz={13}
c={inv.issuedAt ? undefined : "dimmed"}
style={{ color: inv.issuedAt ? INK : undefined }}
>
{fmtDate(inv.issuedAt)}
</Text>
</Table.Td>
<Table.Td>
<Text fz={13} style={{ color: INK }}>
{fmtDate(inv.dueAt)}
</Text>
</Table.Td>
<Table.Td>
<InvoiceStatusBadge status={inv.status} />
</Table.Td>
<Table.Td>
<Group justify="flex-end" gap={8} wrap="nowrap">
<Button
size="sm"
radius="md"
h={34}
variant={payable ? "filled" : "light"}
color="edr-green"
leftSection={
payable ? (
<CreditCard size={15} />
) : (
<Eye size={15} />
)
}
onClick={(e) => {
e.stopPropagation();
navigate(`/billing/${inv.id}`);
}}
styles={{
root: {
fontWeight: 600,
fontSize: 13,
paddingInline: 14,
whiteSpace: "nowrap",
boxShadow: payable
? "0 1px 2px rgba(14,163,113,0.25)"
: "none",
},
}}
>
{payable ? "Pay" : "View"}
</Button>
</Group>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</Box>
{/* Pagination footer */}
{!isLoading && !isError && total > 0 && (
<Group
justify="space-between"
align="center"
wrap="wrap"
gap="md"
px={20}
py={14}
style={{ borderTop: `1px solid ${BORDER}`, background: "#FCFDFE" }}
>
<Group gap={10} align="center">
<Text fz={13} c="dimmed">
Rows
</Text>
<Select
data={PAGE_SIZES}
value={String(pageSize)}
onChange={(v) => {
if (!v) return;
setPageSize(Number(v));
setPageIndex(0);
}}
radius="md"
size="xs"
comboboxProps={{ withinPortal: true }}
style={{ width: 76 }}
allowDeselect={false}
/>
<Text fz={13} c="dimmed">
{start}{end} of {total}
</Text>
</Group>
<Group gap={6} align="center">
<PagerButton
icon={<ChevronLeft size={16} />}
disabled={clampedIndex === 0}
onClick={() => goToPage(clampedIndex - 1)}
ariaLabel="Previous page"
/>
{pageNumbers(clampedIndex, pageCount).map((p, i) =>
p === "…" ? (
<Text key={`gap-${i}`} fz={13} c="dimmed" px={4}>
</Text>
) : (
<PageChip
key={p}
page={p}
active={p === clampedIndex}
onClick={() => goToPage(p)}
/>
),
)}
<PagerButton
icon={<ChevronRight size={16} />}
disabled={clampedIndex >= pageCount - 1}
onClick={() => goToPage(clampedIndex + 1)}
ariaLabel="Next page"
/>
</Group>
</Group>
)}
</Paper>
</Stack>
</Box>
);
}
/** Compact page-number window with ellipses: 1 … 4 5 6 … 12. */
function pageNumbers(active: number, count: number): (number | "…")[] {
if (count <= 7) return Array.from({ length: count }, (_, i) => i);
const out: (number | "…")[] = [0];
const lo = Math.max(1, active - 1);
const hi = Math.min(count - 2, active + 1);
if (lo > 1) out.push("…");
for (let i = lo; i <= hi; i++) out.push(i);
if (hi < count - 2) out.push("…");
out.push(count - 1);
return out;
}
function PageChip({
page,
active,
onClick,
}: {
page: number;
active: boolean;
onClick: () => void;
}) {
return (
<Box
component="button"
onClick={onClick}
style={{
minWidth: 32,
height: 32,
padding: "0 8px",
borderRadius: 9,
border: `1px solid ${active ? GREEN : BORDER}`,
background: active ? GREEN : "#FFFFFF",
color: active ? "#FFFFFF" : INK,
fontSize: 13,
fontWeight: active ? 700 : 600,
cursor: "pointer",
transition: "all 120ms ease",
}}
>
{page + 1}
</Box>
);
}
function PagerButton({
icon,
disabled,
onClick,
ariaLabel,
}: {
icon: React.ReactNode;
disabled: boolean;
onClick: () => void;
ariaLabel: string;
}) {
return (
<Box
component="button"
aria-label={ariaLabel}
onClick={onClick}
disabled={disabled}
style={{
width: 32,
height: 32,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 9,
border: `1px solid ${BORDER}`,
background: "#FFFFFF",
color: disabled ? "#C2CCD6" : INK,
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.6 : 1,
transition: "all 120ms ease",
}}
>
{icon}
</Box>
);
}

View File

@@ -1,202 +0,0 @@
import type { ReactNode } from "react";
import { Calendar, DollarSign, Hash } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { customers } from "../customers/customers.mock";
import { bookings } from "../bookings/bookings.mock";
import type { Currency, InvoiceStatus } from "./invoices.mock";
export interface InvoiceFormData {
number?: string;
customerId?: number;
bookingReference?: string;
amount?: number;
currency?: Currency;
status?: InvoiceStatus;
issueDate?: string;
dueDate?: string;
notes?: string;
}
export interface NewInvoicePageProps {
mode?: "create" | "edit";
invoice?: InvoiceFormData;
children?: ReactNode;
}
const selectClass =
"flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-xs outline-none transition hover:border-slate-300 focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20";
export default function NewInvoicePage({
mode = "create",
invoice,
children,
}: NewInvoicePageProps = {}) {
const isEdit = mode === "edit";
const title = isEdit ? "Edit Invoice" : "New Invoice";
const description = isEdit
? "Update invoice details."
: "Create a new invoice for a customer booking.";
const submitLabel = isEdit ? "Save Changes" : "Create Invoice";
return (
<Dialog>
<DialogTrigger asChild>
{children ?? <Button>{isEdit ? "Edit" : "New Invoice"}</Button>}
</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl rounded-3xl">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="grid gap-5 py-4 md:grid-cols-2">
{/* Invoice Number */}
<div className="space-y-2">
<Label>Invoice Number *</Label>
<div className="relative">
<Hash className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
defaultValue={invoice?.number ?? ""}
placeholder="e.g. INV-2026-0001"
className="pl-10"
/>
</div>
</div>
{/* Status */}
<div className="space-y-2">
<Label>Status</Label>
<select
defaultValue={invoice?.status ?? "Draft"}
className={selectClass}
>
<option>Draft</option>
<option>Sent</option>
<option>Paid</option>
<option>Overdue</option>
<option>Cancelled</option>
</select>
</div>
{/* Customer */}
<div className="space-y-2">
<Label>Customer *</Label>
<select
defaultValue={invoice?.customerId ?? ""}
className={selectClass}
>
<option value="" disabled>
Select customer
</option>
{customers.map((c) => (
<option key={c.id} value={c.id}>
{c.company}
</option>
))}
</select>
</div>
{/* Booking */}
<div className="space-y-2">
<Label>Booking Reference</Label>
<select
defaultValue={invoice?.bookingReference ?? ""}
className={selectClass}
>
<option value="">No linked booking</option>
{bookings.map((b) => (
<option key={b.id} value={b.reference}>
{b.reference} {b.customer}
</option>
))}
</select>
</div>
{/* Amount */}
<div className="space-y-2">
<Label>Amount *</Label>
<div className="relative">
<DollarSign className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
type="number"
min={0}
step="0.01"
defaultValue={invoice?.amount ?? 0}
className="pl-10"
/>
</div>
</div>
{/* Currency */}
<div className="space-y-2">
<Label>Currency</Label>
<select
defaultValue={invoice?.currency ?? "USD"}
className={selectClass}
>
<option>USD</option>
<option>ETB</option>
<option>DJF</option>
</select>
</div>
{/* Issue Date */}
<div className="space-y-2">
<Label>Issue Date *</Label>
<div className="relative">
<Calendar className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
type="date"
defaultValue={invoice?.issueDate ?? ""}
className="pl-10"
/>
</div>
</div>
{/* Due Date */}
<div className="space-y-2">
<Label>Due Date *</Label>
<div className="relative">
<Calendar className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
type="date"
defaultValue={invoice?.dueDate ?? ""}
className="pl-10"
/>
</div>
</div>
{/* Notes */}
<div className="space-y-2 md:col-span-2">
<Label>Notes</Label>
<Textarea
defaultValue={invoice?.notes ?? ""}
placeholder="Payment terms, references, etc."
/>
</div>
</div>
<div className="flex justify-end gap-3">
<Button variant="outline">Cancel</Button>
<Button className="bg-[#10B981] text-white hover:bg-[#10B981]/90">
{submitLabel}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,60 @@
import { Box } from "@mantine/core";
import { Freight } from "@edr/types";
import type { PortalInvoice } from "@/services/invoices.service";
/** Statuses a customer can still pay. */
export const PAYABLE_STATUSES: Freight.InvoiceStatus[] = [
Freight.InvoiceStatus.Pending,
Freight.InvoiceStatus.Overdue,
];
export const isPayable = (status: Freight.InvoiceStatus) =>
PAYABLE_STATUSES.includes(status);
const STATUS_STYLE: Record<
Freight.InvoiceStatus,
{ label: string; bg: string; fg: string }
> = {
[Freight.InvoiceStatus.Draft]: { label: "Draft", bg: "#EEF2F6", fg: "#64748B" },
[Freight.InvoiceStatus.Pending]: { label: "Due", bg: "#FEF3E2", fg: "#B45309" },
[Freight.InvoiceStatus.Paid]: { label: "Paid", bg: "#E6F7EF", fg: "#0A6F4D" },
[Freight.InvoiceStatus.Overdue]: { label: "Overdue", bg: "#FDECEC", fg: "#C0392B" },
[Freight.InvoiceStatus.Cancelled]: { label: "Cancelled", bg: "#EEF2F6", fg: "#64748B" },
[Freight.InvoiceStatus.Refunded]: { label: "Refunded", bg: "#EAF1FB", fg: "#2563EB" },
};
export function InvoiceStatusBadge({ status }: { status: Freight.InvoiceStatus }) {
const s = STATUS_STYLE[status] ?? { label: status, bg: "#EEF2F6", fg: "#64748B" };
return (
<Box
style={{
display: "inline-flex",
alignItems: "center",
padding: "4px 10px",
borderRadius: 999,
background: s.bg,
color: s.fg,
fontSize: 12,
fontWeight: 700,
whiteSpace: "nowrap",
}}
>
{s.label}
</Box>
);
}
export const titleCase = (v: string) =>
v ? v.charAt(0).toUpperCase() + v.slice(1).toLowerCase() : "—";
/** Best label for who an invoice is billed to (profile ref → profile type → company). */
export function billedTo(inv: PortalInvoice): string {
const profile = inv.companyProfile;
if (profile?.reference) return profile.reference;
if (profile?.type) return titleCase(profile.type);
return inv.company?.name ?? "—";
}
export const fmtDate = (v: string | null | undefined) =>
v ? new Date(v).toLocaleDateString() : "—";

View File

@@ -1,88 +0,0 @@
import { customers } from "../customers/customers.mock";
import { bookings } from "../bookings/bookings.mock";
export type InvoiceStatus =
| "Draft"
| "Sent"
| "Paid"
| "Overdue"
| "Cancelled";
export type Currency = "USD" | "ETB" | "DJF";
export interface Invoice {
id: number;
number: string;
customerId: number;
customer: string;
bookingReference: string;
amount: number;
currency: Currency;
status: InvoiceStatus;
issueDate: string;
dueDate: string;
paidDate: string | null;
notes: string;
}
const statuses: InvoiceStatus[] = [
"Draft",
"Sent",
"Paid",
"Overdue",
"Cancelled",
];
const currencies: Currency[] = ["USD", "ETB", "DJF"];
export const invoices: Invoice[] = Array.from({ length: 24 }, (_, i) => {
const customer = customers[i % customers.length] as (typeof customers)[number];
const booking = bookings[i % bookings.length] as (typeof bookings)[number];
const id = i + 1;
const issue = new Date(2026, 3, 1 + (i % 28));
const due = new Date(issue);
due.setDate(due.getDate() + 30);
const status = statuses[i % statuses.length] as InvoiceStatus;
const currency = currencies[i % currencies.length] as Currency;
const baseAmount = 5000 + (i * 1234) % 25000;
return {
id,
number: `INV-2026-${String(id).padStart(4, "0")}`,
customerId: customer.id,
customer: customer.company,
bookingReference: booking.reference,
amount: Math.round(baseAmount * 100) / 100,
currency,
status,
issueDate: issue.toISOString().slice(0, 10),
dueDate: due.toISOString().slice(0, 10),
paidDate:
status === "Paid"
? new Date(due.getTime() - 86400000 * (i % 7))
.toISOString()
.slice(0, 10)
: null,
notes:
i % 3 === 0
? "Net 30 payment terms."
: i % 3 === 1
? "Bank transfer preferred."
: "Payment due upon receipt.",
};
});
export function getInvoiceById(id: number | string): Invoice | undefined {
const numericId = typeof id === "string" ? Number(id) : id;
return invoices.find((inv) => inv.id === numericId);
}
export function formatCurrency(amount: number, currency: Currency): string {
const symbols: Record<Currency, string> = {
USD: "$",
ETB: "Br",
DJF: "DJF",
};
return `${symbols[currency]} ${amount.toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`;
}

View File

@@ -248,4 +248,4 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
{viewer}
</PageShell>
);
}
}

View File

@@ -30,6 +30,12 @@ import {
IntentStatus,
} from "./payments.service";
import { consignmentsService } from "./consignments.service";
import {
invoicesService,
PortalInvoice,
PortalInvoiceDetail,
PayInvoicePayload,
} from "./invoices.service";
import { trackingService } from "./tracking.service";
import { fileUploadSettingsService } from "./fileUploadSettings.service";
import { dropdownSettingsService } from "./dropdownSettings.service";
@@ -597,4 +603,23 @@ export const api = {
({ optionId }) => dropdownSettingsService.removeOption(optionId),
),
},
invoices: {
listMy: endpoint<void, PortalInvoice[]>(
"invoices",
"listMy",
invoicesService.listMy,
),
get: endpoint<{ id: string }, PortalInvoiceDetail>(
"invoices",
"get",
({ id }) => invoicesService.get(id),
),
pay: endpoint<
{ id: string; payload?: PayInvoicePayload },
InitiateResponse
>("invoices", "pay", ({ id, payload }) => invoicesService.pay(id, payload)),
},
};

View File

@@ -72,6 +72,13 @@ export interface SignContractPayload {
consentText?: string;
}
export interface ApproveDeliveryResponse {
bookingId: string;
inventoryId: string;
approvedAt: string;
signerDisplayName: string;
}
export interface BookingListFilter {
status?: string;
/** Comma-separated statuses (overrides `status` when set). */
@@ -242,6 +249,13 @@ export const bookingsService = {
return data.data ?? data;
},
approveDelivery: async (id: string): Promise<ApproveDeliveryResponse> => {
const { data } = await client.post(
`/api/warehouse-inventory/bookings/${id}/approve-delivery`,
);
return data.data ?? data;
},
getBookableSchedules: async (
query: Freight.BookableSchedulesQuery = {},
): Promise<Freight.BookableScheduleItem[]> => {

View File

@@ -0,0 +1,49 @@
import type { Freight } from "@edr/types";
import { URL_CONSTANTS } from "@/constants/URLS";
import { client } from "../utils/api";
import type { InitiateResponse } from "./payments.service";
const B = URL_CONSTANTS.BILLING;
/** A customer-facing invoice row, as returned by `GET /billing/my-invoices`. */
export type PortalInvoice = Freight.IInvoice;
/** An invoice plus its line items, as returned by `GET /billing/my-invoices/:id`. */
export type PortalInvoiceDetail = Freight.IInvoice & {
lines: Freight.IInvoiceLine[];
};
export interface PayInvoicePayload {
method?: string;
platform?: "web" | "mobile";
payerAccount?: string;
returnUrl?: string;
failureUrl?: string;
}
export const invoicesService = {
/** Every invoice billed to the signed-in customer's company, newest first. */
listMy: async (): Promise<PortalInvoice[]> => {
const { data } = await client.get(B.MY_INVOICES);
return data.data ?? data;
},
/** One of the customer's invoices, with its line items. */
get: async (id: string): Promise<PortalInvoiceDetail> => {
const { data } = await client.get(B.MY_INVOICE_BY_ID(id));
return data.data ?? data;
},
/** Initiate gateway payment for an open invoice; returns the client action. */
pay: async (
id: string,
payload: PayInvoicePayload = {},
): Promise<InitiateResponse> => {
const { data } = await client.post(B.PAY_INVOICE(id), {
platform: "web",
...payload,
});
return data.data ?? data;
},
};