style: finish revamp of the warehouse page

This commit is contained in:
Nathnael
2026-06-20 10:45:22 +00:00
parent b7b03db945
commit 3dfb8d99ba
8 changed files with 631 additions and 552 deletions

View File

@@ -1,4 +1,5 @@
import { Stack, Table, Text } from '@mantine/core';
import { Stack, Text } from '@mantine/core';
import { DataTable, type ColumnDef } from '@edr/ui-common';
import type { InventoryInquiryResult } from '@/types/warehouse';
import { InventoryStatusBadge } from './badges';
@@ -16,70 +17,61 @@ const itemDescriptor = (result: InventoryInquiryResult) => {
return '—';
};
export function WarehouseInquiryTable({ results }: WarehouseInquiryTableProps) {
if (results.length === 0) {
return (
<Text c="dimmed" ta="center" py="xl">
No matching items. Adjust your search to locate cargo, containers or goods.
const columns: ColumnDef<InventoryInquiryResult>[] = [
{
id: 'booking',
header: 'Booking',
cell: ({ row }) => (
<Text size="sm" fw={600}>
{row.original.bookingNumber ?? row.original.bookingId.slice(0, 8)}
</Text>
);
}
),
},
{ id: 'customer', header: 'Customer', cell: ({ row }) => row.original.customerName ?? '—' },
{ id: 'item', header: 'Item', cell: ({ row }) => itemDescriptor(row.original) },
{
id: 'warehouse',
header: 'Warehouse',
cell: ({ row }) => (
<Stack gap={0}>
<Text size="sm">{row.original.warehouse?.name ?? '—'}</Text>
{row.original.warehouse?.code && (
<Text size="xs" c="dimmed">
{row.original.warehouse.code}
</Text>
)}
</Stack>
),
},
{ id: 'yard', header: 'Yard', cell: ({ row }) => row.original.yard?.name ?? '—' },
{ id: 'zone', header: 'Zone', cell: ({ row }) => row.original.zone?.name ?? '—' },
{
id: 'status',
header: 'Status',
cell: ({ row }) => <InventoryStatusBadge status={row.original.status} />,
},
{ id: 'qty', header: 'Qty', cell: ({ row }) => formatNumber(row.original.quantity) },
{ id: 'weight', header: 'Weight', cell: ({ row }) => formatNumber(row.original.weight) },
{
id: 'arrived',
header: 'Arrived',
cell: ({ row }) => <Text size="xs">{formatDate(row.original.arrivedAt)}</Text>,
},
{
id: 'ready',
header: 'Ready',
cell: ({ row }) => <Text size="xs">{formatDate(row.original.readyForLoadingAt)}</Text>,
},
];
export function WarehouseInquiryTable({ results }: WarehouseInquiryTableProps) {
return (
<Table.ScrollContainer minWidth={1100}>
<Table highlightOnHover verticalSpacing="sm" striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Booking</Table.Th>
<Table.Th>Customer</Table.Th>
<Table.Th>Item</Table.Th>
<Table.Th>Warehouse</Table.Th>
<Table.Th>Yard</Table.Th>
<Table.Th>Zone</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Qty</Table.Th>
<Table.Th>Weight</Table.Th>
<Table.Th>Arrived</Table.Th>
<Table.Th>Ready</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{results.map((result) => (
<Table.Tr key={result.id}>
<Table.Td>
<Text size="sm" fw={600}>
{result.bookingNumber ?? result.bookingId.slice(0, 8)}
</Text>
</Table.Td>
<Table.Td>{result.customerName ?? '—'}</Table.Td>
<Table.Td>{itemDescriptor(result)}</Table.Td>
<Table.Td>
<Stack gap={0}>
<Text size="sm">{result.warehouse?.name ?? '—'}</Text>
{result.warehouse?.code && (
<Text size="xs" c="dimmed">
{result.warehouse.code}
</Text>
)}
</Stack>
</Table.Td>
<Table.Td>{result.yard?.name ?? '—'}</Table.Td>
<Table.Td>{result.zone?.name ?? '—'}</Table.Td>
<Table.Td>
<InventoryStatusBadge status={result.status} />
</Table.Td>
<Table.Td>{formatNumber(result.quantity)}</Table.Td>
<Table.Td>{formatNumber(result.weight)}</Table.Td>
<Table.Td>
<Text size="xs">{formatDate(result.arrivedAt)}</Text>
</Table.Td>
<Table.Td>
<Text size="xs">{formatDate(result.readyForLoadingAt)}</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
<DataTable
columns={columns}
data={results}
status="success"
emptyMessage="No matching items. Adjust your search to locate cargo, containers or goods."
containerClassName="border-0 shadow-none"
/>
);
}

View File

@@ -1,5 +1,7 @@
import { ActionIcon, Badge, Button, Group, Table, Text, Tooltip } from '@mantine/core';
import { useMemo } from 'react';
import { ActionIcon, Badge, Button, Group, Text, Tooltip } from '@mantine/core';
import { ArrowRightLeft, ClipboardList, Coins, History } from 'lucide-react';
import { DataTable, type ColumnDef } from '@edr/ui-common';
import type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse';
import { INVENTORY_NEXT_ACTION } from '@/types/warehouse';
@@ -40,115 +42,117 @@ export function WarehouseInventoryTable({
onInspect,
onFeePreview,
}: WarehouseInventoryTableProps) {
if (items.length === 0) {
return (
<Text c="dimmed" ta="center" py="xl">
No inventory items found.
</Text>
);
}
const columns = useMemo<ColumnDef<WarehouseInventoryItem>[]>(
() => [
{
id: 'booking',
header: 'Booking',
cell: ({ row }) =>
row.original.bookingId ? (
<Tooltip label={row.original.bookingId} withArrow>
<Text size="sm" fw={600}>
{row.original.bookingId.slice(0, 8)}
</Text>
</Tooltip>
) : (
<Text size="sm" c="dimmed">
</Text>
),
},
{
id: 'facility',
header: 'Facility',
cell: ({ row }) => row.original.warehouse?.facility?.name ?? '—',
},
{ id: 'warehouse', header: 'Warehouse', cell: ({ row }) => row.original.warehouse?.code ?? '—' },
{ id: 'yard', header: 'Yard', cell: ({ row }) => row.original.yard?.code ?? '—' },
{ id: 'zone', header: 'Zone', cell: ({ row }) => row.original.zone?.code ?? '—' },
{
id: 'item',
header: 'Item',
cell: ({ row }) => {
const kind = itemKind(row.original);
return (
<Badge color={kind.color} variant="light" size="sm" radius="md">
{kind.label}
</Badge>
);
},
},
{ id: 'qty', header: 'Qty', cell: ({ row }) => formatNumber(row.original.quantity) },
{ id: 'weight', header: 'Weight', cell: ({ row }) => formatNumber(row.original.weight) },
{
id: 'status',
header: 'Status',
cell: ({ row }) => <InventoryStatusBadge status={row.original.status} />,
},
{
id: 'arrived',
header: 'Arrived',
cell: ({ row }) => <Text size="xs">{formatDate(row.original.arrivedAt)}</Text>,
},
{
id: 'actions',
header: '',
cell: ({ row }) => {
const item = row.original;
const busy = busyId === item.id;
const nextAction = INVENTORY_NEXT_ACTION[item.status];
return (
<Group gap="xs" justify="flex-end" wrap="nowrap" onClick={(e) => e.stopPropagation()}>
{nextAction && (
<Button
size="compact-xs"
variant="light"
color={actionColor[nextAction]}
loading={busy}
onClick={() => onAdvance(item, nextAction)}
>
{humanizeEnum(nextAction.replace(/-/g, '_'))}
</Button>
)}
{item.status !== 'DISPATCHED' && (
<Tooltip label="Move" withArrow>
<ActionIcon variant="subtle" color="gray" onClick={() => onMove(item)}>
<ArrowRightLeft size={16} />
</ActionIcon>
</Tooltip>
)}
{onInspect && (
<Tooltip label="Inspection / Report" withArrow>
<ActionIcon variant="subtle" color="orange" onClick={() => onInspect(item)}>
<ClipboardList size={16} />
</ActionIcon>
</Tooltip>
)}
{onFeePreview && (
<Tooltip label="Storage / Demurrage preview" withArrow>
<ActionIcon variant="subtle" color="teal" onClick={() => onFeePreview(item)}>
<Coins size={16} />
</ActionIcon>
</Tooltip>
)}
<Tooltip label="History" withArrow>
<ActionIcon variant="subtle" color="gray" onClick={() => onHistory(item)}>
<History size={16} />
</ActionIcon>
</Tooltip>
</Group>
);
},
},
],
[busyId, onAdvance, onMove, onHistory, onInspect, onFeePreview],
);
return (
<Table.ScrollContainer minWidth={1150}>
<Table highlightOnHover verticalSpacing="sm" striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Booking</Table.Th>
<Table.Th>Facility</Table.Th>
<Table.Th>Warehouse</Table.Th>
<Table.Th>Yard</Table.Th>
<Table.Th>Zone</Table.Th>
<Table.Th>Item</Table.Th>
<Table.Th>Qty</Table.Th>
<Table.Th>Weight</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Arrived</Table.Th>
<Table.Th ta="right">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{items.map((item) => {
const kind = itemKind(item);
const busy = busyId === item.id;
const nextAction = INVENTORY_NEXT_ACTION[item.status];
return (
<Table.Tr key={item.id}>
<Table.Td>
{item.bookingId ? (
<Tooltip label={item.bookingId} withArrow>
<Text size="sm" fw={600}>
{item.bookingId.slice(0, 8)}
</Text>
</Tooltip>
) : (
<Text size="sm" c="dimmed">
</Text>
)}
</Table.Td>
<Table.Td>{item.warehouse?.facility?.name ?? '—'}</Table.Td>
<Table.Td>{item.warehouse?.code ?? '—'}</Table.Td>
<Table.Td>{item.yard?.code ?? '—'}</Table.Td>
<Table.Td>{item.zone?.code ?? '—'}</Table.Td>
<Table.Td>
<Badge color={kind.color} variant="light" size="sm" radius="md">
{kind.label}
</Badge>
</Table.Td>
<Table.Td>{formatNumber(item.quantity)}</Table.Td>
<Table.Td>{formatNumber(item.weight)}</Table.Td>
<Table.Td>
<InventoryStatusBadge status={item.status} />
</Table.Td>
<Table.Td>
<Text size="xs">{formatDate(item.arrivedAt)}</Text>
</Table.Td>
<Table.Td>
<Group gap="xs" justify="flex-end" wrap="nowrap">
{nextAction && (
<Button
size="compact-xs"
variant="light"
color={actionColor[nextAction]}
loading={busy}
onClick={() => onAdvance(item, nextAction)}
>
{humanizeEnum(nextAction.replace(/-/g, '_'))}
</Button>
)}
{item.status !== 'DISPATCHED' && (
<Tooltip label="Move" withArrow>
<ActionIcon variant="subtle" color="gray" onClick={() => onMove(item)}>
<ArrowRightLeft size={16} />
</ActionIcon>
</Tooltip>
)}
{onInspect && (
<Tooltip label="Inspection / Report" withArrow>
<ActionIcon variant="subtle" color="orange" onClick={() => onInspect(item)}>
<ClipboardList size={16} />
</ActionIcon>
</Tooltip>
)}
{onFeePreview && (
<Tooltip label="Storage / Demurrage preview" withArrow>
<ActionIcon variant="subtle" color="teal" onClick={() => onFeePreview(item)}>
<Coins size={16} />
</ActionIcon>
</Tooltip>
)}
<Tooltip label="History" withArrow>
<ActionIcon variant="subtle" color="gray" onClick={() => onHistory(item)}>
<History size={16} />
</ActionIcon>
</Tooltip>
</Group>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
<DataTable
columns={columns}
data={items}
status="success"
emptyMessage="No inventory items found."
containerClassName="border-0 shadow-none"
/>
);
}

View File

@@ -1,6 +1,7 @@
import { useMemo } from 'react';
import { ActionIcon, Anchor, Group, Table, Text } from '@mantine/core';
import { ActionIcon, Group, Text } from '@mantine/core';
import { Eye, Pencil } from 'lucide-react';
import { DataTable, type ColumnDef } from '@edr/ui-common';
import { useStations } from '@/hooks/useStations';
import type { Warehouse } from '@/types/warehouse';
@@ -20,73 +21,91 @@ export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTablePro
[stations],
);
if (warehouses.length === 0) {
return (
<Text c="dimmed" ta="center" py="xl">
No warehouses found.
</Text>
);
}
const columns: ColumnDef<Warehouse>[] = [
{
id: 'code',
header: 'Code',
cell: ({ row }) => (
<Text
fw={600}
size="sm"
c="edr-green.7"
style={{ cursor: 'pointer' }}
onClick={() => onView(row.original)}
>
{row.original.code}
</Text>
),
},
{ id: 'name', header: 'Name', cell: ({ row }) => row.original.name },
{
id: 'facility',
header: 'Facility',
cell: ({ row }) => {
const name = row.original.stationId
? stationNameById.get(row.original.stationId)
: undefined;
return name ? (
<Text size="sm" fw={500}>
{name}
</Text>
) : (
<Text size="sm" c="dimmed">
</Text>
);
},
},
{
id: 'type',
header: 'Type',
cell: ({ row }) => <WarehouseTypeBadge type={row.original.type} />,
},
{
id: 'location',
header: 'Location',
cell: ({ row }) => row.original.locationName ?? '—',
},
{
id: 'weight',
header: 'Weight (cur / cap)',
cell: ({ row }) => formatCapacity(row.original.currentWeight, row.original.capacityWeight),
},
{
id: 'containers',
header: 'Containers (cur / cap)',
cell: ({ row }) =>
formatCapacity(row.original.currentContainers, row.original.capacityContainers),
},
{
id: 'status',
header: 'Status',
cell: ({ row }) => <WarehouseStatusBadge status={row.original.status} />,
},
{
id: 'actions',
header: '',
cell: ({ row }) => (
<Group gap="xs" justify="flex-end" wrap="nowrap" onClick={(e) => e.stopPropagation()}>
<ActionIcon variant="subtle" color="gray" onClick={() => onView(row.original)} title="View">
<Eye size={16} />
</ActionIcon>
<ActionIcon variant="subtle" color="gray" onClick={() => onEdit(row.original)} title="Edit">
<Pencil size={16} />
</ActionIcon>
</Group>
),
},
];
return (
<Table.ScrollContainer minWidth={900}>
<Table highlightOnHover verticalSpacing="sm" striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Code</Table.Th>
<Table.Th>Name</Table.Th>
<Table.Th>Facility</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Location</Table.Th>
<Table.Th>Weight (cur / cap)</Table.Th>
<Table.Th>Containers (cur / cap)</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th ta="right">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{warehouses.map((warehouse) => (
<Table.Tr key={warehouse.id}>
<Table.Td>
<Anchor fw={600} size="sm" onClick={() => onView(warehouse)}>
{warehouse.code}
</Anchor>
</Table.Td>
<Table.Td>{warehouse.name}</Table.Td>
<Table.Td>
{warehouse.stationId && stationNameById.get(warehouse.stationId) ? (
<Text size="sm" fw={500}>
{stationNameById.get(warehouse.stationId)}
</Text>
) : (
<Text size="sm" c="dimmed">
</Text>
)}
</Table.Td>
<Table.Td>
<WarehouseTypeBadge type={warehouse.type} />
</Table.Td>
<Table.Td>{warehouse.locationName ?? '—'}</Table.Td>
<Table.Td>{formatCapacity(warehouse.currentWeight, warehouse.capacityWeight)}</Table.Td>
<Table.Td>{formatCapacity(warehouse.currentContainers, warehouse.capacityContainers)}</Table.Td>
<Table.Td>
<WarehouseStatusBadge status={warehouse.status} />
</Table.Td>
<Table.Td>
<Group gap="xs" justify="flex-end" wrap="nowrap">
<ActionIcon variant="subtle" color="gray" onClick={() => onView(warehouse)} title="View">
<Eye size={16} />
</ActionIcon>
<ActionIcon variant="subtle" color="gray" onClick={() => onEdit(warehouse)} title="Edit">
<Pencil size={16} />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
<DataTable
columns={columns}
data={warehouses}
status="success"
onRowClick={(warehouse) => onView(warehouse)}
emptyMessage="No warehouses found."
containerClassName="border-0 shadow-none"
/>
);
}

View File

@@ -5,11 +5,10 @@ import {
Button,
Card,
Group,
Loader,
Table,
Text,
} from '@mantine/core';
import { ClipboardList, Eye, PackageOpen, Truck } from 'lucide-react';
import { DataTable, type ColumnDef } from '@edr/ui-common';
import { PageContainer, PageHeader } from '@/components/page';
import {
@@ -60,6 +59,96 @@ export default function ArrivalQueuePage() {
}
};
const columns: ColumnDef<ArrivalQueueItem>[] = [
{
id: 'booking',
header: 'Booking',
cell: ({ row }) => (
<Text fw={600} size="sm">
{row.original.bookingReference}
</Text>
),
},
{ id: 'customer', header: 'Customer', cell: ({ row }) => row.original.customer ?? '—' },
{
id: 'cargo',
header: 'Cargo / Container',
cell: ({ row }) => row.original.container ?? row.original.cargo ?? '—',
},
{
id: 'arrival',
header: 'Arrival',
cell: ({ row }) => <Text size="xs">{formatDate(row.original.arrivalDate)}</Text>,
},
{ id: 'facility', header: 'Facility', cell: ({ row }) => row.original.facility ?? '—' },
{ id: 'warehouse', header: 'Warehouse', cell: ({ row }) => row.original.warehouse ?? '—' },
{ id: 'yard', header: 'Yard', cell: ({ row }) => row.original.yard ?? '—' },
{ id: 'zone', header: 'Zone', cell: ({ row }) => row.original.zone ?? '—' },
{
id: 'status',
header: 'Status',
cell: ({ row }) =>
row.original.unloaded ? (
<Badge variant="light" color="green" size="sm">
{row.original.currentStatus ?? 'RECEIVED'}
</Badge>
) : (
<Badge variant="light" color="orange" size="sm">
Not unloaded
</Badge>
),
},
{
id: 'inspection',
header: 'Inspection',
cell: ({ row }) => inspectionBadge(row.original.inspectionStatus),
},
{
id: 'actions',
header: '',
cell: ({ row }) => {
const item = row.original;
return (
<Group gap="xs" justify="flex-end" wrap="nowrap" onClick={(e) => e.stopPropagation()}>
{!item.unloaded && (
<Button
size="compact-xs"
variant="light"
leftSection={<Truck size={14} />}
loading={unloadOne.isPending}
onClick={() => handleUnloadOne(item)}
>
Unload
</Button>
)}
{item.inventoryId && (
<Button
size="compact-xs"
variant="light"
color="green"
leftSection={<ClipboardList size={14} />}
onClick={() => setInspectInventoryId(item.inventoryId)}
>
Inspect
</Button>
)}
{item.inventoryId && (
<Button
size="compact-xs"
variant="subtle"
color="gray"
leftSection={<Eye size={14} />}
onClick={() => navigate('/dashboard/warehouse-inventory')}
>
Inventory
</Button>
)}
</Group>
);
},
},
];
return (
<PageContainer>
<PageHeader
@@ -81,105 +170,20 @@ export default function ArrivalQueuePage() {
{items.length} arrived booking(s)
</Text>
{isLoading ? (
<Group justify="center" py="xl">
<Loader />
</Group>
) : items.length === 0 ? (
<VisualEmptyState
variant="container"
title="No arrived bookings"
description="Bookings in transit that arrive appear here for unloading and inspection."
/>
) : (
<Table.ScrollContainer minWidth={1100}>
<Table verticalSpacing="sm" highlightOnHover striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Booking</Table.Th>
<Table.Th>Customer</Table.Th>
<Table.Th>Cargo / Container</Table.Th>
<Table.Th>Arrival</Table.Th>
<Table.Th>Facility</Table.Th>
<Table.Th>Warehouse</Table.Th>
<Table.Th>Yard</Table.Th>
<Table.Th>Zone</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Inspection</Table.Th>
<Table.Th ta="right">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{items.map((item) => (
<Table.Tr key={item.bookingId}>
<Table.Td>
<Text fw={600} size="sm">{item.bookingReference}</Text>
</Table.Td>
<Table.Td>{item.customer ?? '—'}</Table.Td>
<Table.Td>{item.container ?? item.cargo ?? '—'}</Table.Td>
<Table.Td>
<Text size="xs">{formatDate(item.arrivalDate)}</Text>
</Table.Td>
<Table.Td>{item.facility ?? '—'}</Table.Td>
<Table.Td>{item.warehouse ?? '—'}</Table.Td>
<Table.Td>{item.yard ?? '—'}</Table.Td>
<Table.Td>{item.zone ?? '—'}</Table.Td>
<Table.Td>
{item.unloaded ? (
<Badge variant="light" color="green" size="sm">
{item.currentStatus ?? 'RECEIVED'}
</Badge>
) : (
<Badge variant="light" color="orange" size="sm">
Not unloaded
</Badge>
)}
</Table.Td>
<Table.Td>{inspectionBadge(item.inspectionStatus)}</Table.Td>
<Table.Td>
<Group gap="xs" justify="flex-end" wrap="nowrap">
{!item.unloaded && (
<Button
size="compact-xs"
variant="light"
color="orange"
leftSection={<Truck size={14} />}
loading={unloadOne.isPending}
onClick={() => handleUnloadOne(item)}
>
Unload
</Button>
)}
{item.inventoryId && (
<Button
size="compact-xs"
variant="light"
color="green"
leftSection={<ClipboardList size={14} />}
onClick={() => setInspectInventoryId(item.inventoryId)}
>
Inspect
</Button>
)}
{item.inventoryId && (
<Button
size="compact-xs"
variant="subtle"
color="gray"
leftSection={<Eye size={14} />}
onClick={() => navigate('/dashboard/warehouse-inventory')}
>
Inventory
</Button>
)}
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
{!isLoading && items.length === 0 ? (
<VisualEmptyState
variant="container"
title="No arrived bookings"
description="Bookings in transit that arrive appear here for unloading and inspection."
/>
) : (
<DataTable
columns={columns}
data={items}
status={isLoading ? 'loading' : 'success'}
containerClassName="border-0 shadow-none"
/>
)}
</Card>
<InspectionReportModal

View File

@@ -1,9 +1,63 @@
import { Badge, Card, Group, Loader, Table, Text } from '@mantine/core';
import { Badge, Card, Group, Text } from '@mantine/core';
import { DataTable, type ColumnDef } from '@edr/ui-common';
import { PageContainer, PageHeader } from '@/components/page';
import { FreightVisual, VisualEmptyState, formatDate, formatNumber } from '@/components/warehouses';
import { useWarehouseLoadings } from '@/hooks/useWarehouses';
type Loading = NonNullable<ReturnType<typeof useWarehouseLoadings>['data']>[number];
const columns: ColumnDef<Loading>[] = [
{
id: 'wagon',
header: 'Wagon',
cell: ({ row }) => (
<Group gap={6} wrap="nowrap">
<FreightVisual variant="wagon" size={22} />
<Text fw={600} size="sm">
{row.original.wagonNumber ?? row.original.wagonId.slice(0, 8)}
</Text>
</Group>
),
},
{
id: 'warehouse',
header: 'Warehouse',
cell: ({ row }) =>
row.original.inventory?.warehouse
? `${row.original.inventory.warehouse.name} (${row.original.inventory.warehouse.code})`
: '—',
},
{
id: 'zone',
header: 'Zone',
cell: ({ row }) => (row.original.inventory?.zone ? row.original.inventory.zone.name : '—'),
},
{
id: 'weight',
header: 'Loaded Weight (kg)',
cell: ({ row }) => formatNumber(row.original.loadedWeight),
},
{
id: 'loadedAt',
header: 'Loaded At',
cell: ({ row }) => formatDate(row.original.loadedAt),
},
{
id: 'status',
header: 'Status',
cell: ({ row }) => (
<Badge
variant="light"
color={row.original.inventory?.status === 'DISPATCHED' ? 'green' : 'teal'}
size="sm"
>
{row.original.inventory?.status ?? 'LOADED'}
</Badge>
),
},
];
/** Record of every inventory item loaded onto a wagon. */
export default function LoadedInventoryPage() {
const { data, isLoading } = useWarehouseLoadings();
@@ -17,62 +71,19 @@ export default function LoadedInventoryPage() {
/>
<Card>
{isLoading ? (
<Group justify="center" py="xl">
<Loader />
</Group>
) : loadings.length === 0 ? (
{!isLoading && loadings.length === 0 ? (
<VisualEmptyState
variant="container"
title="No loaded inventory yet"
description="Once items are loaded onto a wagon, their records show here."
/>
) : (
<Table.ScrollContainer minWidth={760}>
<Table verticalSpacing="sm" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Wagon</Table.Th>
<Table.Th>Warehouse</Table.Th>
<Table.Th>Zone</Table.Th>
<Table.Th>Loaded Weight (kg)</Table.Th>
<Table.Th>Loaded At</Table.Th>
<Table.Th>Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{loadings.map((l) => (
<Table.Tr key={l.id}>
<Table.Td>
<Group gap={6} wrap="nowrap">
<FreightVisual variant="wagon" size={22} />
<Text fw={600} size="sm">
{l.wagonNumber ?? l.wagonId.slice(0, 8)}
</Text>
</Group>
</Table.Td>
<Table.Td>
{l.inventory?.warehouse
? `${l.inventory.warehouse.name} (${l.inventory.warehouse.code})`
: '—'}
</Table.Td>
<Table.Td>{l.inventory?.zone ? l.inventory.zone.name : '—'}</Table.Td>
<Table.Td>{formatNumber(l.loadedWeight)}</Table.Td>
<Table.Td>{formatDate(l.loadedAt)}</Table.Td>
<Table.Td>
<Badge
variant="light"
color={l.inventory?.status === 'DISPATCHED' ? 'green' : 'teal'}
size="sm"
>
{l.inventory?.status ?? 'LOADED'}
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
<DataTable
columns={columns}
data={loadings}
status={isLoading ? 'loading' : 'success'}
containerClassName="border-0 shadow-none"
/>
)}
</Card>
</PageContainer>

View File

@@ -1,7 +1,8 @@
import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { Badge, Button, Card, Group, Table, Tabs, Text } from '@mantine/core';
import { Badge, Button, Card, Group, Tabs, Text } from '@mantine/core';
import { CreditCard, Eye, Truck } from 'lucide-react';
import { DataTable, type ColumnDef } from '@edr/ui-common';
import { PageContainer, PageHeader } from '@/components/page';
import {
@@ -176,63 +177,67 @@ interface PendingPaymentTableProps {
/** Read-only view of unpaid ready-for-loading items. No Mark-as-Loaded action. */
function PendingPaymentTable({ items, onNavigate }: PendingPaymentTableProps) {
const columns: ColumnDef<WarehouseInventoryItem>[] = [
{
id: 'booking',
header: 'Booking',
cell: ({ row }) => (
<Text fw={600} size="sm">
{row.original.booking?.reference ?? row.original.bookingId?.slice(0, 8) ?? '—'}
</Text>
),
},
{ id: 'warehouse', header: 'Warehouse', cell: ({ row }) => row.original.warehouse?.code ?? '—' },
{ id: 'zone', header: 'Zone', cell: ({ row }) => row.original.zone?.code ?? '—' },
{ id: 'weight', header: 'Weight (kg)', cell: ({ row }) => formatNumber(row.original.weight) },
{
id: 'payment',
header: 'Payment',
cell: ({ row }) => (
<Badge color="orange" variant="light" size="sm">
{row.original.booking?.status ?? row.original.booking?.paymentStatus ?? 'UNPAID'}
</Badge>
),
},
{
id: 'actions',
header: '',
cell: ({ row }) => {
const item = row.original;
return (
<Group gap="xs" justify="flex-end" wrap="nowrap" onClick={(e) => e.stopPropagation()}>
<Button
size="compact-xs"
variant="light"
color="gray"
leftSection={<Eye size={14} />}
disabled={!item.bookingId}
onClick={() => item.bookingId && onNavigate(`/dashboard/booking-requests/${item.bookingId}`)}
>
Booking
</Button>
<Button
size="compact-xs"
variant="light"
leftSection={<CreditCard size={14} />}
disabled={!item.bookingId}
onClick={() => item.bookingId && onNavigate(`/dashboard/booking-requests/${item.bookingId}`)}
>
Payment
</Button>
</Group>
);
},
},
];
return (
<Table.ScrollContainer minWidth={820}>
<Table verticalSpacing="sm" highlightOnHover striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Booking</Table.Th>
<Table.Th>Warehouse</Table.Th>
<Table.Th>Zone</Table.Th>
<Table.Th>Weight (kg)</Table.Th>
<Table.Th>Payment</Table.Th>
<Table.Th ta="right">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{items.map((item) => (
<Table.Tr key={item.id}>
<Table.Td>
<Text fw={600} size="sm">
{item.booking?.reference ?? item.bookingId?.slice(0, 8) ?? '—'}
</Text>
</Table.Td>
<Table.Td>{item.warehouse?.code ?? '—'}</Table.Td>
<Table.Td>{item.zone?.code ?? '—'}</Table.Td>
<Table.Td>{formatNumber(item.weight)}</Table.Td>
<Table.Td>
<Badge color="orange" variant="light" size="sm">
{item.booking?.status ?? item.booking?.paymentStatus ?? 'UNPAID'}
</Badge>
</Table.Td>
<Table.Td>
<Group gap="xs" justify="flex-end" wrap="nowrap">
<Button
size="compact-xs"
variant="light"
color="gray"
leftSection={<Eye size={14} />}
disabled={!item.bookingId}
onClick={() => item.bookingId && onNavigate(`/dashboard/booking-requests/${item.bookingId}`)}
>
Booking
</Button>
<Button
size="compact-xs"
variant="light"
color="orange"
leftSection={<CreditCard size={14} />}
disabled={!item.bookingId}
onClick={() => item.bookingId && onNavigate(`/dashboard/booking-requests/${item.bookingId}`)}
>
Payment
</Button>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
<DataTable
columns={columns}
data={items}
status="success"
emptyMessage="No unpaid items."
containerClassName="border-0 shadow-none"
/>
);
}

View File

@@ -16,6 +16,7 @@ import {
TextInput,
} from '@mantine/core';
import { Ban, CreditCard, Eye, Search } from 'lucide-react';
import { DataTable, type ColumnDef } from '@edr/ui-common';
import { PageContainer, PageHeader } from '@/components/page';
import { useToast } from '@/hooks/use-toast';
@@ -56,6 +57,56 @@ export default function WarehouseInvoicesPage() {
return invoices.filter((i) => [i.invoiceNumber, i.bookingId, i.customerId].join(' ').toLowerCase().includes(q));
}, [invoices, search]);
const invoiceColumns: ColumnDef<WarehouseFeeInvoice>[] = [
{
id: 'invoiceNumber',
header: 'Invoice No',
cell: ({ row }) => (
<Text fw={600} size="sm">
{row.original.invoiceNumber}
</Text>
),
},
{ id: 'type', header: 'Type', cell: ({ row }) => row.original.invoiceType.replace(/_/g, ' ') },
{ id: 'total', header: 'Total', cell: ({ row }) => fmt(row.original.totalAmount, row.original.currency) },
{ id: 'paid', header: 'Paid', cell: ({ row }) => fmt(row.original.paidAmount, row.original.currency) },
{
id: 'balance',
header: 'Balance',
cell: ({ row }) => fmt(row.original.balanceAmount, row.original.currency),
},
{
id: 'status',
header: 'Status',
cell: ({ row }) => (
<Badge variant="light" color={STATUS_COLOR[row.original.status]}>
{row.original.status.replace(/_/g, ' ')}
</Badge>
),
},
{
id: 'issued',
header: 'Issued',
cell: ({ row }) => <Text size="xs">{fmtDate(row.original.issuedAt)}</Text>,
},
{
id: 'actions',
header: '',
cell: ({ row }) => (
<Group justify="flex-end" onClick={(e) => e.stopPropagation()}>
<ActionIcon
variant="subtle"
color="gray"
onClick={() => setDetailId(row.original.id)}
title="View"
>
<Eye size={16} />
</ActionIcon>
</Group>
),
},
];
return (
<PageContainer>
<PageHeader
@@ -82,41 +133,13 @@ export default function WarehouseInvoicesPage() {
/>
</Group>
{isLoading ? (
<Group justify="center" py="xl"><Loader /></Group>
) : filtered.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">No invoices found.</Text>
) : (
<Table.ScrollContainer minWidth={1000}>
<Table striped highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Invoice No</Table.Th><Table.Th>Type</Table.Th><Table.Th>Total</Table.Th>
<Table.Th>Paid</Table.Th><Table.Th>Balance</Table.Th><Table.Th>Status</Table.Th>
<Table.Th>Issued</Table.Th><Table.Th ta="right">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{filtered.map((inv) => (
<Table.Tr key={inv.id}>
<Table.Td><Text fw={600} size="sm">{inv.invoiceNumber}</Text></Table.Td>
<Table.Td>{inv.invoiceType.replace(/_/g, ' ')}</Table.Td>
<Table.Td>{fmt(inv.totalAmount, inv.currency)}</Table.Td>
<Table.Td>{fmt(inv.paidAmount, inv.currency)}</Table.Td>
<Table.Td>{fmt(inv.balanceAmount, inv.currency)}</Table.Td>
<Table.Td><Badge variant="light" color={STATUS_COLOR[inv.status]}>{inv.status.replace(/_/g, ' ')}</Badge></Table.Td>
<Table.Td><Text size="xs">{fmtDate(inv.issuedAt)}</Text></Table.Td>
<Table.Td ta="right">
<ActionIcon variant="subtle" color="gray" onClick={() => setDetailId(inv.id)} title="View">
<Eye size={16} />
</ActionIcon>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
<DataTable
columns={invoiceColumns}
data={filtered}
status={isLoading ? 'loading' : 'success'}
emptyMessage="No invoices found."
containerClassName="border-0 shadow-none"
/>
</Card>
<InvoiceDetailModal id={detailId} onClose={() => setDetailId(null)} />

View File

@@ -5,17 +5,16 @@ import {
Button,
Card,
Group,
Loader,
Modal,
NumberInput,
Select,
Stack,
Table,
Tabs,
Text,
TextInput,
} from '@mantine/core';
import { Plus, Trash2 } from 'lucide-react';
import { DataTable, type ColumnDef } from '@edr/ui-common';
import { PageContainer, PageHeader } from '@/components/page';
import { useToast } from '@/hooks/use-toast';
@@ -105,45 +104,52 @@ function AllocationRules() {
setForm({ name: '', priority: 100, freightType: '', tradeDirection: '', cargoTypeCode: '', containerStatus: '', targetYardCode: '', storageType: '' });
};
const columns: ColumnDef<(typeof rules)[number]>[] = [
{ id: 'priority', header: 'Priority', cell: ({ row }) => row.original.priority },
{ id: 'name', header: 'Name', cell: ({ row }) => row.original.name },
{ id: 'freight', header: 'Freight', cell: ({ row }) => row.original.freightType ?? '—' },
{ id: 'trade', header: 'Trade', cell: ({ row }) => row.original.tradeDirection ?? '—' },
{ id: 'cargo', header: 'Cargo code', cell: ({ row }) => row.original.cargoTypeCode ?? '—' },
{
id: 'targetYard',
header: 'Target yard',
cell: ({ row }) => <Badge variant="light">{row.original.targetYardCode}</Badge>,
},
{
id: 'active',
header: 'Active',
cell: ({ row }) => (
<Badge color={row.original.isActive ? 'green' : 'gray'} variant="light">
{row.original.isActive ? 'Yes' : 'No'}
</Badge>
),
},
{
id: 'actions',
header: '',
cell: ({ row }) => (
<Group justify="flex-end" onClick={(e) => e.stopPropagation()}>
<ActionIcon variant="subtle" color="red" onClick={() => remove.mutate(row.original.id)} title="Delete">
<Trash2 size={16} />
</ActionIcon>
</Group>
),
},
];
return (
<>
<Group justify="space-between" mb="sm">
<Text c="dimmed" size="sm">{rules.length} rule(s) matched by ascending priority</Text>
<Button leftSection={<Plus size={16} />} onClick={() => setOpen(true)}>New allocation rule</Button>
</Group>
{isLoading ? (
<Group justify="center" py="xl"><Loader /></Group>
) : (
<Table.ScrollContainer minWidth={900}>
<Table striped highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Priority</Table.Th><Table.Th>Name</Table.Th><Table.Th>Freight</Table.Th>
<Table.Th>Trade</Table.Th><Table.Th>Cargo code</Table.Th><Table.Th>Target yard</Table.Th>
<Table.Th>Active</Table.Th><Table.Th ta="right">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rules.map((r) => (
<Table.Tr key={r.id}>
<Table.Td>{r.priority}</Table.Td>
<Table.Td>{r.name}</Table.Td>
<Table.Td>{r.freightType ?? '—'}</Table.Td>
<Table.Td>{r.tradeDirection ?? '—'}</Table.Td>
<Table.Td>{r.cargoTypeCode ?? '—'}</Table.Td>
<Table.Td><Badge variant="light">{r.targetYardCode}</Badge></Table.Td>
<Table.Td><Badge color={r.isActive ? 'green' : 'gray'} variant="light">{r.isActive ? 'Yes' : 'No'}</Badge></Table.Td>
<Table.Td ta="right">
<ActionIcon variant="subtle" color="red" onClick={() => remove.mutate(r.id)} title="Delete">
<Trash2 size={16} />
</ActionIcon>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
<DataTable
columns={columns}
data={rules}
status={isLoading ? 'loading' : 'success'}
emptyMessage="No allocation rules yet."
containerClassName="border-0 shadow-none"
/>
<Modal opened={open} onClose={() => setOpen(false)} title="New allocation rule" centered size="lg">
<Stack gap="sm">
@@ -211,45 +217,60 @@ function FeeRules() {
setOpen(false);
};
const columns: ColumnDef<(typeof rules)[number]>[] = [
{
id: 'type',
header: 'Type',
cell: ({ row }) => (
<Badge color={row.original.ruleType === 'DEMURRAGE_FEE' ? 'orange' : 'teal'} variant="light">
{row.original.ruleType === 'DEMURRAGE_FEE' ? 'Demurrage' : 'Storage'}
</Badge>
),
},
{ id: 'name', header: 'Name', cell: ({ row }) => row.original.name },
{ id: 'freight', header: 'Freight', cell: ({ row }) => row.original.freightType ?? '—' },
{ id: 'trade', header: 'Trade', cell: ({ row }) => row.original.tradeDirection ?? '—' },
{ id: 'freeDays', header: 'Free days', cell: ({ row }) => row.original.freeDays },
{
id: 'rate',
header: 'Rate / day',
cell: ({ row }) => `${Number(row.original.ratePerDay).toLocaleString()} ${row.original.currency}`,
},
{
id: 'active',
header: 'Active',
cell: ({ row }) => (
<Badge color={row.original.isActive ? 'green' : 'gray'} variant="light">
{row.original.isActive ? 'Yes' : 'No'}
</Badge>
),
},
{
id: 'actions',
header: '',
cell: ({ row }) => (
<Group justify="flex-end" onClick={(e) => e.stopPropagation()}>
<ActionIcon variant="subtle" color="red" onClick={() => remove.mutate(row.original.id)} title="Delete">
<Trash2 size={16} />
</ActionIcon>
</Group>
),
},
];
return (
<>
<Group justify="space-between" mb="sm">
<Text c="dimmed" size="sm">{rules.length} rule(s) most specific match applies</Text>
<Button leftSection={<Plus size={16} />} onClick={() => setOpen(true)}>New fee rule</Button>
</Group>
{isLoading ? (
<Group justify="center" py="xl"><Loader /></Group>
) : (
<Table.ScrollContainer minWidth={900}>
<Table striped highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Type</Table.Th><Table.Th>Name</Table.Th><Table.Th>Freight</Table.Th>
<Table.Th>Trade</Table.Th><Table.Th>Free days</Table.Th><Table.Th>Rate / day</Table.Th>
<Table.Th>Active</Table.Th><Table.Th ta="right">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rules.map((r) => (
<Table.Tr key={r.id}>
<Table.Td><Badge color={r.ruleType === 'DEMURRAGE_FEE' ? 'orange' : 'teal'} variant="light">{r.ruleType === 'DEMURRAGE_FEE' ? 'Demurrage' : 'Storage'}</Badge></Table.Td>
<Table.Td>{r.name}</Table.Td>
<Table.Td>{r.freightType ?? '—'}</Table.Td>
<Table.Td>{r.tradeDirection ?? '—'}</Table.Td>
<Table.Td>{r.freeDays}</Table.Td>
<Table.Td>{Number(r.ratePerDay).toLocaleString()} {r.currency}</Table.Td>
<Table.Td><Badge color={r.isActive ? 'green' : 'gray'} variant="light">{r.isActive ? 'Yes' : 'No'}</Badge></Table.Td>
<Table.Td ta="right">
<ActionIcon variant="subtle" color="red" onClick={() => remove.mutate(r.id)} title="Delete">
<Trash2 size={16} />
</ActionIcon>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
<DataTable
columns={columns}
data={rules}
status={isLoading ? 'loading' : 'success'}
emptyMessage="No fee rules yet."
containerClassName="border-0 shadow-none"
/>
<Modal opened={open} onClose={() => setOpen(false)} title="New fee rule" centered size="lg">
<Stack gap="sm">