mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 06:28:12 +00:00
style: finish revamp of the warehouse page
This commit is contained in:
@@ -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 type { InventoryInquiryResult } from '@/types/warehouse';
|
||||||
import { InventoryStatusBadge } from './badges';
|
import { InventoryStatusBadge } from './badges';
|
||||||
@@ -16,70 +17,61 @@ const itemDescriptor = (result: InventoryInquiryResult) => {
|
|||||||
return '—';
|
return '—';
|
||||||
};
|
};
|
||||||
|
|
||||||
export function WarehouseInquiryTable({ results }: WarehouseInquiryTableProps) {
|
const columns: ColumnDef<InventoryInquiryResult>[] = [
|
||||||
if (results.length === 0) {
|
{
|
||||||
return (
|
id: 'booking',
|
||||||
<Text c="dimmed" ta="center" py="xl">
|
header: 'Booking',
|
||||||
No matching items. Adjust your search to locate cargo, containers or goods.
|
cell: ({ row }) => (
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{row.original.bookingNumber ?? row.original.bookingId.slice(0, 8)}
|
||||||
</Text>
|
</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 (
|
return (
|
||||||
<Table.ScrollContainer minWidth={1100}>
|
<DataTable
|
||||||
<Table highlightOnHover verticalSpacing="sm" striped>
|
columns={columns}
|
||||||
<Table.Thead>
|
data={results}
|
||||||
<Table.Tr>
|
status="success"
|
||||||
<Table.Th>Booking</Table.Th>
|
emptyMessage="No matching items. Adjust your search to locate cargo, containers or goods."
|
||||||
<Table.Th>Customer</Table.Th>
|
containerClassName="border-0 shadow-none"
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { ArrowRightLeft, ClipboardList, Coins, History } from 'lucide-react';
|
||||||
|
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||||
|
|
||||||
import type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse';
|
import type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse';
|
||||||
import { INVENTORY_NEXT_ACTION } from '@/types/warehouse';
|
import { INVENTORY_NEXT_ACTION } from '@/types/warehouse';
|
||||||
@@ -40,115 +42,117 @@ export function WarehouseInventoryTable({
|
|||||||
onInspect,
|
onInspect,
|
||||||
onFeePreview,
|
onFeePreview,
|
||||||
}: WarehouseInventoryTableProps) {
|
}: WarehouseInventoryTableProps) {
|
||||||
if (items.length === 0) {
|
const columns = useMemo<ColumnDef<WarehouseInventoryItem>[]>(
|
||||||
return (
|
() => [
|
||||||
<Text c="dimmed" ta="center" py="xl">
|
{
|
||||||
No inventory items found.
|
id: 'booking',
|
||||||
</Text>
|
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 (
|
return (
|
||||||
<Table.ScrollContainer minWidth={1150}>
|
<DataTable
|
||||||
<Table highlightOnHover verticalSpacing="sm" striped>
|
columns={columns}
|
||||||
<Table.Thead>
|
data={items}
|
||||||
<Table.Tr>
|
status="success"
|
||||||
<Table.Th>Booking</Table.Th>
|
emptyMessage="No inventory items found."
|
||||||
<Table.Th>Facility</Table.Th>
|
containerClassName="border-0 shadow-none"
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useMemo } from 'react';
|
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 { Eye, Pencil } from 'lucide-react';
|
||||||
|
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||||
|
|
||||||
import { useStations } from '@/hooks/useStations';
|
import { useStations } from '@/hooks/useStations';
|
||||||
import type { Warehouse } from '@/types/warehouse';
|
import type { Warehouse } from '@/types/warehouse';
|
||||||
@@ -20,73 +21,91 @@ export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTablePro
|
|||||||
[stations],
|
[stations],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (warehouses.length === 0) {
|
const columns: ColumnDef<Warehouse>[] = [
|
||||||
return (
|
{
|
||||||
<Text c="dimmed" ta="center" py="xl">
|
id: 'code',
|
||||||
No warehouses found.
|
header: 'Code',
|
||||||
</Text>
|
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 (
|
return (
|
||||||
<Table.ScrollContainer minWidth={900}>
|
<DataTable
|
||||||
<Table highlightOnHover verticalSpacing="sm" striped>
|
columns={columns}
|
||||||
<Table.Thead>
|
data={warehouses}
|
||||||
<Table.Tr>
|
status="success"
|
||||||
<Table.Th>Code</Table.Th>
|
onRowClick={(warehouse) => onView(warehouse)}
|
||||||
<Table.Th>Name</Table.Th>
|
emptyMessage="No warehouses found."
|
||||||
<Table.Th>Facility</Table.Th>
|
containerClassName="border-0 shadow-none"
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,10 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
|
||||||
Table,
|
|
||||||
Text,
|
Text,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { ClipboardList, Eye, PackageOpen, Truck } from 'lucide-react';
|
import { ClipboardList, Eye, PackageOpen, Truck } from 'lucide-react';
|
||||||
|
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||||
|
|
||||||
import { PageContainer, PageHeader } from '@/components/page';
|
import { PageContainer, PageHeader } from '@/components/page';
|
||||||
import {
|
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 (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -81,105 +170,20 @@ export default function ArrivalQueuePage() {
|
|||||||
{items.length} arrived booking(s)
|
{items.length} arrived booking(s)
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{isLoading ? (
|
{!isLoading && items.length === 0 ? (
|
||||||
<Group justify="center" py="xl">
|
<VisualEmptyState
|
||||||
<Loader />
|
variant="container"
|
||||||
</Group>
|
title="No arrived bookings"
|
||||||
) : items.length === 0 ? (
|
description="Bookings in transit that arrive appear here for unloading and inspection."
|
||||||
<VisualEmptyState
|
/>
|
||||||
variant="container"
|
) : (
|
||||||
title="No arrived bookings"
|
<DataTable
|
||||||
description="Bookings in transit that arrive appear here for unloading and inspection."
|
columns={columns}
|
||||||
/>
|
data={items}
|
||||||
) : (
|
status={isLoading ? 'loading' : 'success'}
|
||||||
<Table.ScrollContainer minWidth={1100}>
|
containerClassName="border-0 shadow-none"
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<InspectionReportModal
|
<InspectionReportModal
|
||||||
|
|||||||
@@ -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 { PageContainer, PageHeader } from '@/components/page';
|
||||||
import { FreightVisual, VisualEmptyState, formatDate, formatNumber } from '@/components/warehouses';
|
import { FreightVisual, VisualEmptyState, formatDate, formatNumber } from '@/components/warehouses';
|
||||||
import { useWarehouseLoadings } from '@/hooks/useWarehouses';
|
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. */
|
/** Record of every inventory item loaded onto a wagon. */
|
||||||
export default function LoadedInventoryPage() {
|
export default function LoadedInventoryPage() {
|
||||||
const { data, isLoading } = useWarehouseLoadings();
|
const { data, isLoading } = useWarehouseLoadings();
|
||||||
@@ -17,62 +71,19 @@ export default function LoadedInventoryPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
{isLoading ? (
|
{!isLoading && loadings.length === 0 ? (
|
||||||
<Group justify="center" py="xl">
|
|
||||||
<Loader />
|
|
||||||
</Group>
|
|
||||||
) : loadings.length === 0 ? (
|
|
||||||
<VisualEmptyState
|
<VisualEmptyState
|
||||||
variant="container"
|
variant="container"
|
||||||
title="No loaded inventory yet"
|
title="No loaded inventory yet"
|
||||||
description="Once items are loaded onto a wagon, their records show here."
|
description="Once items are loaded onto a wagon, their records show here."
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Table.ScrollContainer minWidth={760}>
|
<DataTable
|
||||||
<Table verticalSpacing="sm" highlightOnHover>
|
columns={columns}
|
||||||
<Table.Thead>
|
data={loadings}
|
||||||
<Table.Tr>
|
status={isLoading ? 'loading' : 'success'}
|
||||||
<Table.Th>Wagon</Table.Th>
|
containerClassName="border-0 shadow-none"
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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 { CreditCard, Eye, Truck } from 'lucide-react';
|
||||||
|
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||||
|
|
||||||
import { PageContainer, PageHeader } from '@/components/page';
|
import { PageContainer, PageHeader } from '@/components/page';
|
||||||
import {
|
import {
|
||||||
@@ -176,63 +177,67 @@ interface PendingPaymentTableProps {
|
|||||||
|
|
||||||
/** Read-only view of unpaid ready-for-loading items. No Mark-as-Loaded action. */
|
/** Read-only view of unpaid ready-for-loading items. No Mark-as-Loaded action. */
|
||||||
function PendingPaymentTable({ items, onNavigate }: PendingPaymentTableProps) {
|
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 (
|
return (
|
||||||
<Table.ScrollContainer minWidth={820}>
|
<DataTable
|
||||||
<Table verticalSpacing="sm" highlightOnHover striped>
|
columns={columns}
|
||||||
<Table.Thead>
|
data={items}
|
||||||
<Table.Tr>
|
status="success"
|
||||||
<Table.Th>Booking</Table.Th>
|
emptyMessage="No unpaid items."
|
||||||
<Table.Th>Warehouse</Table.Th>
|
containerClassName="border-0 shadow-none"
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
TextInput,
|
TextInput,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { Ban, CreditCard, Eye, Search } from 'lucide-react';
|
import { Ban, CreditCard, Eye, Search } from 'lucide-react';
|
||||||
|
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||||
|
|
||||||
import { PageContainer, PageHeader } from '@/components/page';
|
import { PageContainer, PageHeader } from '@/components/page';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
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));
|
return invoices.filter((i) => [i.invoiceNumber, i.bookingId, i.customerId].join(' ').toLowerCase().includes(q));
|
||||||
}, [invoices, search]);
|
}, [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 (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -82,41 +133,13 @@ export default function WarehouseInvoicesPage() {
|
|||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{isLoading ? (
|
<DataTable
|
||||||
<Group justify="center" py="xl"><Loader /></Group>
|
columns={invoiceColumns}
|
||||||
) : filtered.length === 0 ? (
|
data={filtered}
|
||||||
<Text c="dimmed" ta="center" py="xl">No invoices found.</Text>
|
status={isLoading ? 'loading' : 'success'}
|
||||||
) : (
|
emptyMessage="No invoices found."
|
||||||
<Table.ScrollContainer minWidth={1000}>
|
containerClassName="border-0 shadow-none"
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<InvoiceDetailModal id={detailId} onClose={() => setDetailId(null)} />
|
<InvoiceDetailModal id={detailId} onClose={() => setDetailId(null)} />
|
||||||
|
|||||||
@@ -5,17 +5,16 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
|
||||||
Modal,
|
Modal,
|
||||||
NumberInput,
|
NumberInput,
|
||||||
Select,
|
Select,
|
||||||
Stack,
|
Stack,
|
||||||
Table,
|
|
||||||
Tabs,
|
Tabs,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { Plus, Trash2 } from 'lucide-react';
|
import { Plus, Trash2 } from 'lucide-react';
|
||||||
|
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||||
|
|
||||||
import { PageContainer, PageHeader } from '@/components/page';
|
import { PageContainer, PageHeader } from '@/components/page';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
@@ -105,45 +104,52 @@ function AllocationRules() {
|
|||||||
setForm({ name: '', priority: 100, freightType: '', tradeDirection: '', cargoTypeCode: '', containerStatus: '', targetYardCode: '', storageType: '' });
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Group justify="space-between" mb="sm">
|
<Group justify="space-between" mb="sm">
|
||||||
<Text c="dimmed" size="sm">{rules.length} rule(s) — matched by ascending priority</Text>
|
<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>
|
<Button leftSection={<Plus size={16} />} onClick={() => setOpen(true)}>New allocation rule</Button>
|
||||||
</Group>
|
</Group>
|
||||||
{isLoading ? (
|
<DataTable
|
||||||
<Group justify="center" py="xl"><Loader /></Group>
|
columns={columns}
|
||||||
) : (
|
data={rules}
|
||||||
<Table.ScrollContainer minWidth={900}>
|
status={isLoading ? 'loading' : 'success'}
|
||||||
<Table striped highlightOnHover verticalSpacing="sm">
|
emptyMessage="No allocation rules yet."
|
||||||
<Table.Thead>
|
containerClassName="border-0 shadow-none"
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Modal opened={open} onClose={() => setOpen(false)} title="New allocation rule" centered size="lg">
|
<Modal opened={open} onClose={() => setOpen(false)} title="New allocation rule" centered size="lg">
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
@@ -211,45 +217,60 @@ function FeeRules() {
|
|||||||
setOpen(false);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Group justify="space-between" mb="sm">
|
<Group justify="space-between" mb="sm">
|
||||||
<Text c="dimmed" size="sm">{rules.length} rule(s) — most specific match applies</Text>
|
<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>
|
<Button leftSection={<Plus size={16} />} onClick={() => setOpen(true)}>New fee rule</Button>
|
||||||
</Group>
|
</Group>
|
||||||
{isLoading ? (
|
<DataTable
|
||||||
<Group justify="center" py="xl"><Loader /></Group>
|
columns={columns}
|
||||||
) : (
|
data={rules}
|
||||||
<Table.ScrollContainer minWidth={900}>
|
status={isLoading ? 'loading' : 'success'}
|
||||||
<Table striped highlightOnHover verticalSpacing="sm">
|
emptyMessage="No fee rules yet."
|
||||||
<Table.Thead>
|
containerClassName="border-0 shadow-none"
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Modal opened={open} onClose={() => setOpen(false)} title="New fee rule" centered size="lg">
|
<Modal opened={open} onClose={() => setOpen(false)} title="New fee rule" centered size="lg">
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
|
|||||||
Reference in New Issue
Block a user