mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 21:08:12 +00:00
fix conflict
This commit is contained in:
@@ -103,9 +103,8 @@ export default function BookingRequestsPage() {
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
statuses: "PAID",
|
||||
schedulingStatuses: "NOT_SCHEDULED,HOLDING,ELIGIBLE",
|
||||
assignedToSchedule: "false",
|
||||
sortBy: "isGovernment",
|
||||
sortBy: "createdAt",
|
||||
sortOrder: "DESC",
|
||||
tab: activeTab,
|
||||
};
|
||||
|
||||
@@ -1,197 +1,271 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Fragment, useState } from 'react';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
} from '@mantine/core';
|
||||
import { ClipboardList, Eye, PackageOpen, Truck } from 'lucide-react';
|
||||
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||
import { ChevronDown, ChevronRight, PackageOpen, Truck } from 'lucide-react';
|
||||
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
import { PageHeader } from '@/components/page';
|
||||
import Breadcrumbs from '@/components/ui/Breadcrumbs';
|
||||
import {
|
||||
InspectionReportModal,
|
||||
VisualEmptyState,
|
||||
WarehouseHero,
|
||||
formatDate,
|
||||
formatNumber,
|
||||
} from '@/components/warehouses';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { api } from '@/services/api';
|
||||
import {
|
||||
useAutoUnloadArrivedBookings,
|
||||
useImportArriveQueue,
|
||||
useImportTrainItems,
|
||||
} from '@/hooks/useWarehouses';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import type { ArrivalQueueItem } from '@/types/warehouse';
|
||||
import type { AutoUnloadArrivedResult, ImportTrain, ImportTrainItem } from '@/types/warehouse';
|
||||
|
||||
function inspectionBadge(status: string | null) {
|
||||
if (!status) return <Badge variant="light" color="gray" size="sm">Not inspected</Badge>;
|
||||
const color = status === 'PASSED' ? 'edr-green' : status === 'FAILED' ? 'red' : 'orange';
|
||||
return <Badge variant="light" color={color} size="sm">{status.replace(/_/g, ' ')}</Badge>;
|
||||
}
|
||||
const getErrorMessage = (error: unknown) => {
|
||||
if (error && typeof error === 'object' && 'response' in error) {
|
||||
const response = (error as { response?: { data?: { message?: unknown } } }).response;
|
||||
const message = response?.data?.message;
|
||||
if (Array.isArray(message)) return message.join(', ');
|
||||
if (typeof message === 'string') return message;
|
||||
}
|
||||
return error instanceof Error ? error.message : undefined;
|
||||
};
|
||||
|
||||
/** Batch 4.5 — arrived bookings awaiting unload / inspection. */
|
||||
export default function ArrivalQueuePage() {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const { data, isLoading } = useQuery(api.warehouses.arrivalQueue.queryOptions());
|
||||
const autoUnload = useMutation(api.warehouses.autoUnloadArrived.mutationOptions());
|
||||
const unloadOne = useMutation(api.warehouses.unloadBooking.mutationOptions());
|
||||
const [inspectInventoryId, setInspectInventoryId] = useState<string | null>(null);
|
||||
function ImportTrainDetailRows({ scheduleId }: { scheduleId: string }) {
|
||||
const { data: items = [], isLoading } = useImportTrainItems(scheduleId);
|
||||
|
||||
const items = data ?? [];
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Group justify="center" py="md">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
const handleAutoUnload = async () => {
|
||||
try {
|
||||
const r = await autoUnload.mutateAsync();
|
||||
toast({
|
||||
title: 'Auto-unload complete',
|
||||
description: `Processed ${r.processedCount}, skipped ${r.skippedCount}, failed ${r.failedCount}.`,
|
||||
});
|
||||
} catch {
|
||||
toast({ variant: 'destructive', title: 'Auto-unload failed' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnloadOne = async (item: ArrivalQueueItem) => {
|
||||
try {
|
||||
await unloadOne.mutateAsync({ bookingId: item.bookingId });
|
||||
toast({ title: 'Booking unloaded', description: `${item.bookingReference} stored as RECEIVED.` });
|
||||
} catch {
|
||||
toast({ variant: 'destructive', title: 'Unload failed' });
|
||||
}
|
||||
};
|
||||
|
||||
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="edr-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="edr-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>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<Text c="dimmed" ta="center" py="md" size="sm">
|
||||
No assigned bookings found for this train.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Arrival / Unloading Queue"
|
||||
subtitle="Arrived bookings ready to unload, store and inspect."
|
||||
action={
|
||||
<Button
|
||||
leftSection={<PackageOpen size={16} />}
|
||||
loading={autoUnload.isPending}
|
||||
onClick={handleAutoUnload}
|
||||
>
|
||||
Auto Unload Arrived Bookings
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<Text fw={600} mb="md">
|
||||
{items.length} arrived booking(s)
|
||||
</Text>
|
||||
|
||||
{!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
|
||||
opened={Boolean(inspectInventoryId)}
|
||||
onClose={() => setInspectInventoryId(null)}
|
||||
inventoryId={inspectInventoryId}
|
||||
/>
|
||||
</PageContainer>
|
||||
<Table highlightOnHover verticalSpacing="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Booking</Table.Th>
|
||||
<Table.Th>Customer</Table.Th>
|
||||
<Table.Th>Container</Table.Th>
|
||||
<Table.Th>Cargo</Table.Th>
|
||||
<Table.Th>Weight</Table.Th>
|
||||
<Table.Th>Arrival</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Pickup</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{items.map((item: ImportTrainItem) => (
|
||||
<Table.Tr key={item.bookingId}>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={600}>
|
||||
{item.bookingReference ?? item.bookingId.slice(0, 8)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{item.customerName ?? '-'}</Table.Td>
|
||||
<Table.Td>{item.containerNumber ?? '-'}</Table.Td>
|
||||
<Table.Td>{item.cargoType ?? '-'}</Table.Td>
|
||||
<Table.Td>{formatNumber(item.weight)}</Table.Td>
|
||||
<Table.Td>{formatDate(item.arrivalTime)}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
variant="light"
|
||||
color={item.currentStatus === 'UNLOADED' ? 'green' : 'orange'}
|
||||
size="sm"
|
||||
>
|
||||
{item.currentStatus ?? 'PENDING'}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>{item.pickupOption.replace(/_/g, ' ')}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
/** Arrived import trains awaiting unload into warehouse inventory. */
|
||||
export default function ArrivalQueuePage() {
|
||||
const { toast } = useToast();
|
||||
const { data: trains = [], isLoading } = useImportArriveQueue();
|
||||
const autoUnload = useAutoUnloadArrivedBookings();
|
||||
const [openScheduleId, setOpenScheduleId] = useState<string | null>(null);
|
||||
const [busyScheduleId, setBusyScheduleId] = useState<string | null>(null);
|
||||
|
||||
const unloadTrain = async (train: ImportTrain) => {
|
||||
setBusyScheduleId(train.scheduleId);
|
||||
try {
|
||||
const res = (await autoUnload.mutateAsync(train.scheduleId)) as {
|
||||
data: AutoUnloadArrivedResult;
|
||||
};
|
||||
const result = res.data;
|
||||
const details = [
|
||||
result.skippedCount ? `${result.skippedCount} skipped` : '',
|
||||
result.failedCount ? `${result.failedCount} failed` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
|
||||
toast({
|
||||
title: `${result.unloadedCount} booking(s) unloaded`,
|
||||
description: details || `${train.trainNumber ?? 'Train'} moved into warehouse inventory.`,
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Auto unload failed',
|
||||
description: getErrorMessage(error),
|
||||
});
|
||||
} finally {
|
||||
setBusyScheduleId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container size="xxl" py="lg">
|
||||
<Breadcrumbs items={[{ label: 'Arrival queue' }]} />
|
||||
|
||||
<Stack gap="lg" mt="sm">
|
||||
<PageHeader
|
||||
title="Arrival / Unloading Queue"
|
||||
subtitle="Arrived import trains ready to unload assigned bookings into warehouse inventory."
|
||||
/>
|
||||
|
||||
<WarehouseHero
|
||||
variant="container"
|
||||
secondaryVariant="warehouse"
|
||||
title="Arrival / Unloading Queue"
|
||||
subtitle="Arrived import trains ready to unload assigned bookings into warehouse inventory."
|
||||
/>
|
||||
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Text fw={600}>{trains.length} arrived import train(s)</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Open a train to review assigned bookings, then auto unload it.
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
) : trains.length === 0 ? (
|
||||
<VisualEmptyState
|
||||
variant="container"
|
||||
title="No arrived import trains"
|
||||
description="Import trains appear here once their train schedule status is ARRIVED."
|
||||
/>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={1150}>
|
||||
<Table verticalSpacing="sm" highlightOnHover striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Train</Table.Th>
|
||||
<Table.Th>Route</Table.Th>
|
||||
<Table.Th>Origin</Table.Th>
|
||||
<Table.Th>Destination</Table.Th>
|
||||
<Table.Th>Arrival</Table.Th>
|
||||
<Table.Th ta="center">Bookings</Table.Th>
|
||||
<Table.Th ta="center">Containers</Table.Th>
|
||||
<Table.Th ta="center">Cargoes</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th ta="right">Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{trains.map((train: ImportTrain) => {
|
||||
const isOpen = openScheduleId === train.scheduleId;
|
||||
return (
|
||||
<Fragment key={train.scheduleId}>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Stack gap={0}>
|
||||
<Text size="sm" fw={700}>
|
||||
{train.trainNumber ?? '-'}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{train.scheduleId.slice(0, 8)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Table.Td>
|
||||
<Table.Td>{train.route ?? '-'}</Table.Td>
|
||||
<Table.Td>{train.origin ?? '-'}</Table.Td>
|
||||
<Table.Td>{train.destination ?? '-'}</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs">{formatDate(train.arrivalTime)}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td ta="center">{train.totalBookings}</Table.Td>
|
||||
<Table.Td ta="center">{train.totalContainers}</Table.Td>
|
||||
<Table.Td ta="center">{train.totalCargoes}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge variant="light" color="teal" size="sm">
|
||||
{train.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={
|
||||
isOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />
|
||||
}
|
||||
onClick={() => setOpenScheduleId(isOpen ? null : train.scheduleId)}
|
||||
>
|
||||
Open
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="orange"
|
||||
leftSection={
|
||||
busyScheduleId === train.scheduleId ? (
|
||||
<PackageOpen size={14} />
|
||||
) : (
|
||||
<Truck size={14} />
|
||||
)
|
||||
}
|
||||
loading={busyScheduleId === train.scheduleId}
|
||||
onClick={() => unloadTrain(train)}
|
||||
>
|
||||
Auto Unload
|
||||
</Button>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
{isOpen && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={10} bg="var(--mantine-color-gray-0)">
|
||||
<ImportTrainDetailRows scheduleId={train.scheduleId} />
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,35 +3,30 @@ import { Button, Card, Center, Group, Loader, Select, Stack, TextInput } from '@
|
||||
import { Search } from 'lucide-react';
|
||||
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { VisualEmptyState, WarehouseInquiryTable, inventoryStatusOptions } from '@/components/warehouses';
|
||||
import { api } from '@/services/api';
|
||||
import type { InventoryInquiryFilter, InventoryStatus } from '@/types/warehouse';
|
||||
import {
|
||||
InventoryInquiryDetailModal,
|
||||
VisualEmptyState,
|
||||
WarehouseInquiryTable,
|
||||
inventoryStatusOptions,
|
||||
} from '@/components/warehouses';
|
||||
import {
|
||||
useAllWarehouseYards,
|
||||
useAllWarehouseZones,
|
||||
useInventoryInquiry,
|
||||
useWarehouses,
|
||||
} from '@/hooks/useWarehouses';
|
||||
import type { InventoryInquiryFilter, InventoryInquiryResult, InventoryStatus } from '@/types/warehouse';
|
||||
|
||||
export default function InventoryInquiryPage() {
|
||||
const [draft, setDraft] = useState<InventoryInquiryFilter>({});
|
||||
const [applied, setApplied] = useState<InventoryInquiryFilter>({});
|
||||
const [viewResult, setViewResult] = useState<InventoryInquiryResult | null>(null);
|
||||
|
||||
const warehousesQuery = useQuery(
|
||||
api.warehouses.list.queryOptions({ input: {} }),
|
||||
);
|
||||
const yardsQuery = useQuery(
|
||||
api.warehouses.listYards.queryOptions({
|
||||
input: { warehouseId: draft.warehouseId ?? '' },
|
||||
enabled: Boolean(draft.warehouseId),
|
||||
}),
|
||||
);
|
||||
const zonesQuery = useQuery(
|
||||
api.warehouses.listZones.queryOptions({
|
||||
input: { yardId: draft.yardId ?? '' },
|
||||
enabled: Boolean(draft.yardId),
|
||||
}),
|
||||
);
|
||||
const warehousesQuery = useWarehouses();
|
||||
const yardsQuery = useAllWarehouseYards();
|
||||
const zonesQuery = useAllWarehouseZones();
|
||||
|
||||
const { data, isFetching } = useQuery(
|
||||
api.warehouses.inquiry.queryOptions({ input: { filter: applied } }),
|
||||
);
|
||||
const { data, isFetching } = useInventoryInquiry(applied);
|
||||
const results = data ?? [];
|
||||
|
||||
const warehouseOptions = useMemo(
|
||||
@@ -39,15 +34,39 @@ export default function InventoryInquiryPage() {
|
||||
[warehousesQuery.data],
|
||||
);
|
||||
const yardOptions = useMemo(
|
||||
() => (yardsQuery.data ?? []).map((y) => ({ value: y.id, label: `${y.name} (${y.code})` })),
|
||||
[yardsQuery.data],
|
||||
() =>
|
||||
(yardsQuery.data ?? [])
|
||||
.filter((y) => !draft.warehouseId || y.warehouseId === draft.warehouseId)
|
||||
.map((y) => ({ value: y.id, label: `${y.name} (${y.code})` })),
|
||||
[draft.warehouseId, yardsQuery.data],
|
||||
);
|
||||
const zoneOptions = useMemo(
|
||||
() => (zonesQuery.data ?? []).map((z) => ({ value: z.id, label: `${z.name} (${z.code})` })),
|
||||
[zonesQuery.data],
|
||||
() => {
|
||||
const visibleYardIds = new Set(
|
||||
(yardsQuery.data ?? [])
|
||||
.filter((y) => !draft.warehouseId || y.warehouseId === draft.warehouseId)
|
||||
.map((y) => y.id),
|
||||
);
|
||||
return (zonesQuery.data ?? [])
|
||||
.filter((z) => {
|
||||
if (draft.yardId) return z.yardId === draft.yardId;
|
||||
if (draft.warehouseId) return visibleYardIds.has(z.yardId);
|
||||
return true;
|
||||
})
|
||||
.map((z) => ({ value: z.id, label: `${z.name} (${z.code})` }));
|
||||
},
|
||||
[draft.warehouseId, draft.yardId, yardsQuery.data, zonesQuery.data],
|
||||
);
|
||||
|
||||
const runSearch = () => setApplied(draft);
|
||||
const normalizeDraft = (filter: InventoryInquiryFilter): InventoryInquiryFilter => ({
|
||||
...filter,
|
||||
bookingReference: filter.bookingReference?.trim() || undefined,
|
||||
containerNumber: filter.containerNumber?.trim() || undefined,
|
||||
cargoType: filter.cargoType?.trim() || undefined,
|
||||
goodsName: filter.goodsName?.trim() || undefined,
|
||||
});
|
||||
|
||||
const runSearch = () => setApplied(normalizeDraft(draft));
|
||||
const reset = () => {
|
||||
setDraft({});
|
||||
setApplied({});
|
||||
@@ -60,101 +79,109 @@ export default function InventoryInquiryPage() {
|
||||
subtitle="Locate any cargo, container or goods inside the warehouse network."
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<Stack gap="md">
|
||||
<Group gap="sm" wrap="wrap">
|
||||
<TextInput
|
||||
label="Booking number"
|
||||
placeholder="e.g. BKG-00123"
|
||||
value={draft.bookingNumber ?? ''}
|
||||
onChange={(e) => { const v = e.currentTarget.value; setDraft((f) => ({ ...f, bookingNumber: v || undefined })); }}
|
||||
w={200}
|
||||
/>
|
||||
<TextInput
|
||||
label="Container number"
|
||||
placeholder="e.g. MSKU1234567"
|
||||
value={draft.containerNumber ?? ''}
|
||||
onChange={(e) => { const v = e.currentTarget.value; setDraft((f) => ({ ...f, containerNumber: v || undefined })); }}
|
||||
w={200}
|
||||
/>
|
||||
<TextInput
|
||||
label="Goods name"
|
||||
placeholder="e.g. Coffee"
|
||||
value={draft.goodsName ?? ''}
|
||||
onChange={(e) => { const v = e.currentTarget.value; setDraft((f) => ({ ...f, goodsName: v || undefined })); }}
|
||||
w={180}
|
||||
/>
|
||||
<Select
|
||||
label="Warehouse"
|
||||
placeholder="Any"
|
||||
clearable
|
||||
searchable
|
||||
data={warehouseOptions}
|
||||
value={draft.warehouseId ?? null}
|
||||
onChange={(value) =>
|
||||
setDraft((f) => ({ ...f, warehouseId: value ?? undefined, yardId: undefined, zoneId: undefined }))
|
||||
}
|
||||
w={200}
|
||||
/>
|
||||
<Select
|
||||
label="Yard"
|
||||
placeholder="Any"
|
||||
clearable
|
||||
searchable
|
||||
disabled={!draft.warehouseId}
|
||||
data={yardOptions}
|
||||
value={draft.yardId ?? null}
|
||||
onChange={(value) => setDraft((f) => ({ ...f, yardId: value ?? undefined, zoneId: undefined }))}
|
||||
w={180}
|
||||
/>
|
||||
<Select
|
||||
label="Zone"
|
||||
placeholder="Any"
|
||||
clearable
|
||||
searchable
|
||||
disabled={!draft.yardId}
|
||||
data={zoneOptions}
|
||||
value={draft.zoneId ?? null}
|
||||
onChange={(value) => setDraft((f) => ({ ...f, zoneId: value ?? undefined }))}
|
||||
w={180}
|
||||
/>
|
||||
<Select
|
||||
label="Status"
|
||||
placeholder="Any"
|
||||
clearable
|
||||
data={inventoryStatusOptions}
|
||||
value={draft.status ?? null}
|
||||
onChange={(value) => setDraft((f) => ({ ...f, status: (value as InventoryStatus) ?? undefined }))}
|
||||
w={180}
|
||||
/>
|
||||
</Group>
|
||||
<Stack gap="lg" mt="sm">
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Stack gap="md">
|
||||
<Group gap="sm" wrap="wrap">
|
||||
<TextInput
|
||||
label="Booking reference"
|
||||
placeholder="e.g. BKG-00123"
|
||||
value={draft.bookingReference ?? ''}
|
||||
onChange={(e) => { const v = e.currentTarget.value; setDraft((f) => ({ ...f, bookingReference: v || undefined })); }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') runSearch();
|
||||
}}
|
||||
w={200}
|
||||
/>
|
||||
<TextInput
|
||||
label="Container number"
|
||||
placeholder="e.g. MSKU1234567"
|
||||
value={draft.containerNumber ?? ''}
|
||||
onChange={(e) => { const v = e.currentTarget.value; setDraft((f) => ({ ...f, containerNumber: v || undefined })); }}
|
||||
w={200}
|
||||
/>
|
||||
<TextInput
|
||||
label="Goods name"
|
||||
placeholder="e.g. Coffee"
|
||||
value={draft.goodsName ?? ''}
|
||||
onChange={(e) => { const v = e.currentTarget.value; setDraft((f) => ({ ...f, goodsName: v || undefined })); }}
|
||||
w={180}
|
||||
/>
|
||||
<Select
|
||||
label="Warehouse"
|
||||
placeholder="Any"
|
||||
clearable
|
||||
searchable
|
||||
data={warehouseOptions}
|
||||
value={draft.warehouseId ?? null}
|
||||
onChange={(value) =>
|
||||
setDraft((f) => ({ ...f, warehouseId: value ?? undefined, yardId: undefined, zoneId: undefined }))
|
||||
}
|
||||
w={200}
|
||||
/>
|
||||
<Select
|
||||
label="Yard"
|
||||
placeholder="Any"
|
||||
clearable
|
||||
searchable
|
||||
data={yardOptions}
|
||||
value={draft.yardId ?? null}
|
||||
onChange={(value) => setDraft((f) => ({ ...f, yardId: value ?? undefined, zoneId: undefined }))}
|
||||
w={180}
|
||||
/>
|
||||
<Select
|
||||
label="Zone"
|
||||
placeholder="Any"
|
||||
clearable
|
||||
searchable
|
||||
data={zoneOptions}
|
||||
value={draft.zoneId ?? null}
|
||||
onChange={(value) => setDraft((f) => ({ ...f, zoneId: value ?? undefined }))}
|
||||
w={180}
|
||||
/>
|
||||
<Select
|
||||
label="Status"
|
||||
placeholder="Any"
|
||||
clearable
|
||||
data={inventoryStatusOptions}
|
||||
value={draft.status ?? null}
|
||||
onChange={(value) => setDraft((f) => ({ ...f, status: (value as InventoryStatus) ?? undefined }))}
|
||||
w={180}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group>
|
||||
<Button leftSection={<Search size={16} />} onClick={runSearch}>
|
||||
Search
|
||||
</Button>
|
||||
<Button variant="default" onClick={reset}>
|
||||
Reset
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
<Group>
|
||||
<Button leftSection={<Search size={16} />} onClick={runSearch}>
|
||||
Search
|
||||
</Button>
|
||||
<Button variant="default" onClick={reset}>
|
||||
Reset
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
{isFetching ? (
|
||||
<Center py="xl">
|
||||
<Loader />
|
||||
</Center>
|
||||
) : results.length === 0 ? (
|
||||
<VisualEmptyState
|
||||
variant="container"
|
||||
title="No items found"
|
||||
description="Adjust your filters and search to locate cargo, containers or goods across the warehouse network."
|
||||
/>
|
||||
) : (
|
||||
<WarehouseInquiryTable results={results} />
|
||||
)}
|
||||
</Card>
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
{isFetching ? (
|
||||
<Center py="xl">
|
||||
<Loader />
|
||||
</Center>
|
||||
) : results.length === 0 ? (
|
||||
<VisualEmptyState
|
||||
variant="container"
|
||||
title="No items found"
|
||||
description="Adjust your filters and search to locate cargo, containers or goods across the warehouse network."
|
||||
/>
|
||||
) : (
|
||||
<WarehouseInquiryTable results={results} onView={setViewResult} />
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
<InventoryInquiryDetailModal
|
||||
opened={Boolean(viewResult)}
|
||||
onClose={() => setViewResult(null)}
|
||||
result={viewResult}
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card, Center, Group, Loader, SimpleGrid, Text, ThemeIcon } from '@mantine/core';
|
||||
import { Card, Center, Group, Loader, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core';
|
||||
import {
|
||||
ClipboardCheck,
|
||||
ClipboardList,
|
||||
@@ -16,10 +16,8 @@ import {
|
||||
} from 'lucide-react';
|
||||
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { WarehouseDashboardCharts } from '@/components/warehouses';
|
||||
import { api } from '@/services/api';
|
||||
import { WarehouseDashboardCharts, WarehouseHero } from '@/components/warehouses';
|
||||
import { useWarehouseDashboard } from '@/hooks/useWarehouses';
|
||||
import type { WarehouseDashboard } from '@/types/warehouse';
|
||||
|
||||
interface Metric {
|
||||
@@ -31,8 +29,8 @@ interface Metric {
|
||||
theme: string;
|
||||
}
|
||||
|
||||
const ORANGE = '#f08c00';
|
||||
const GREEN = '#22c55e';
|
||||
const ORANGE = 'rgb(241, 147, 23)';
|
||||
const GREEN = '#084b21';
|
||||
|
||||
const METRICS: Metric[] = [
|
||||
{ key: 'totalWarehouses', label: 'Total Warehouses', icon: <WarehouseIcon size={22} />, to: '/dashboard/warehouses', theme: ORANGE },
|
||||
@@ -51,7 +49,7 @@ const METRICS: Metric[] = [
|
||||
|
||||
export default function WarehouseDashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
const { data, isLoading } = useQuery(api.warehouses.dashboard.queryOptions());
|
||||
const { data, isError, isLoading } = useWarehouseDashboard();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
@@ -60,45 +58,58 @@ export default function WarehouseDashboardPage() {
|
||||
subtitle="Live overview of warehouse capacity and inventory lifecycle."
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<Center py="xl">
|
||||
<Loader />
|
||||
</Center>
|
||||
) : (
|
||||
<>
|
||||
<SimpleGrid cols={{ base: 1, xs: 2, md: 4 }} spacing="md">
|
||||
{METRICS.map((metric) => (
|
||||
<Card
|
||||
key={metric.key}
|
||||
padding="lg"
|
||||
onClick={() => navigate(metric.to)}
|
||||
className="cursor-pointer transition-[transform,border-color] duration-150 hover:-translate-y-0.5 hover:border-edr-primary!"
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<div>
|
||||
<Text size="xs" c="edr-muted" tt="uppercase" fw={700} style={{ letterSpacing: 0.4 }}>
|
||||
{metric.label}
|
||||
</Text>
|
||||
<Text fw={800} fz={32} mt={8} c="edr-text" lh={1.1}>
|
||||
{data ? data[metric.key] : 0}
|
||||
</Text>
|
||||
</div>
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
size={46}
|
||||
radius="md"
|
||||
style={{ backgroundColor: `${metric.theme}1a`, color: metric.theme }}
|
||||
>
|
||||
{metric.icon}
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
<Stack gap="lg" mt="sm">
|
||||
<WarehouseHero
|
||||
variant="train"
|
||||
secondaryVariant="warehouse"
|
||||
title="Warehouse Dashboard"
|
||||
subtitle="Live overview of warehouse capacity and inventory lifecycle."
|
||||
/>
|
||||
|
||||
<WarehouseDashboardCharts data={data} />
|
||||
</>
|
||||
)}
|
||||
{isLoading ? (
|
||||
<Center py="xl">
|
||||
<Loader />
|
||||
</Center>
|
||||
) : isError ? (
|
||||
<Center py="xl">
|
||||
<Text c="red">Failed to load warehouse dashboard.</Text>
|
||||
</Center>
|
||||
) : (
|
||||
<>
|
||||
<SimpleGrid cols={{ base: 1, xs: 2, md: 4 }} spacing="md">
|
||||
{METRICS.map((metric) => (
|
||||
<Card
|
||||
key={metric.key}
|
||||
padding="lg"
|
||||
onClick={() => navigate(metric.to)}
|
||||
className="cursor-pointer transition-[transform,border-color] duration-150 hover:-translate-y-0.5 hover:border-edr-primary!"
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<div>
|
||||
<Text size="xs" c="edr-muted" tt="uppercase" fw={700} style={{ letterSpacing: 0.4 }}>
|
||||
{metric.label}
|
||||
</Text>
|
||||
<Text fw={800} fz={32} mt={8} c="edr-text" lh={1.1}>
|
||||
{data ? data[metric.key] : 0}
|
||||
</Text>
|
||||
</div>
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
size={46}
|
||||
radius="md"
|
||||
style={{ backgroundColor: `${metric.theme}1a`, color: metric.theme }}
|
||||
>
|
||||
{metric.icon}
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
<WarehouseDashboardCharts data={data} />
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,16 +5,15 @@ import {
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
Stack,
|
||||
Select,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
} from '@mantine/core';
|
||||
import { ArrowLeft, Boxes, LayoutGrid, Package, Pencil, Plus } from 'lucide-react';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||
|
||||
import { KpiStrip, PageContainer, PageHeader } from '@/components/page';
|
||||
@@ -27,8 +26,6 @@ import {
|
||||
formatCapacity,
|
||||
humanizeEnum,
|
||||
} from '@/components/warehouses';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { api } from '@/services/api';
|
||||
import type { WarehouseYard, WarehouseZone } from '@/types/warehouse';
|
||||
|
||||
@@ -51,7 +48,6 @@ export default function WarehouseDetailPage() {
|
||||
|
||||
const [yardModalOpen, setYardModalOpen] = useState(false);
|
||||
const [editingYard, setEditingYard] = useState<WarehouseYard | null>(null);
|
||||
|
||||
const [zoneModalOpen, setZoneModalOpen] = useState(false);
|
||||
const [editingZone, setEditingZone] = useState<WarehouseZone | null>(null);
|
||||
const [selectedYardId, setSelectedYardId] = useState<string | null>(null);
|
||||
@@ -169,14 +165,18 @@ export default function WarehouseDetailPage() {
|
||||
|
||||
if (!warehouse) {
|
||||
return (
|
||||
<Container size="sm" py="xl">
|
||||
<Stack align="center" gap="md">
|
||||
<PageContainer>
|
||||
<Stack align="center" gap="md" py="xl">
|
||||
<Text fw={700}>Warehouse not found</Text>
|
||||
<Button variant="default" leftSection={<ArrowLeft size={16} />} onClick={() => navigate('/dashboard/warehouses')}>
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<ArrowLeft size={16} />}
|
||||
onClick={() => navigate('/dashboard/warehouses')}
|
||||
>
|
||||
Back to warehouses
|
||||
</Button>
|
||||
</Stack>
|
||||
</Container>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -189,9 +189,7 @@ export default function WarehouseDetailPage() {
|
||||
]}
|
||||
backTo="/dashboard/warehouses"
|
||||
title={warehouse.name}
|
||||
subtitle={`${warehouse.code}${
|
||||
warehouse.locationName ? ` · ${warehouse.locationName}` : ''
|
||||
}`}
|
||||
subtitle={`${warehouse.code}${warehouse.locationName ? ` - ${warehouse.locationName}` : ''}`}
|
||||
meta={
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<WarehouseTypeBadge type={warehouse.type} />
|
||||
@@ -216,7 +214,6 @@ export default function WarehouseDetailPage() {
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
{/* OVERVIEW */}
|
||||
<Tabs.Panel value="overview" pt="lg">
|
||||
<KpiStrip
|
||||
items={[
|
||||
@@ -237,113 +234,105 @@ export default function WarehouseDetailPage() {
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* YARDS */}
|
||||
<Tabs.Panel value="yards" pt="lg">
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600}>Yards</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
leftSection={<Plus size={16} />}
|
||||
onClick={() => {
|
||||
setEditingYard(null);
|
||||
setYardModalOpen(true);
|
||||
}}
|
||||
>
|
||||
Create Yard
|
||||
</Button>
|
||||
</Group>
|
||||
<Tabs.Panel value="yards" pt="lg">
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600}>Yards</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
leftSection={<Plus size={16} />}
|
||||
onClick={() => {
|
||||
setEditingYard(null);
|
||||
setYardModalOpen(true);
|
||||
}}
|
||||
>
|
||||
Create Yard
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<DataTable
|
||||
columns={yardColumns}
|
||||
data={yards}
|
||||
status={
|
||||
yardsQuery.isLoading ? 'loading' : yardsQuery.isError ? 'error' : 'success'
|
||||
}
|
||||
emptyMessage="No yards yet."
|
||||
containerClassName="border-0 shadow-none"
|
||||
error={
|
||||
yardsQuery.isError
|
||||
? {
|
||||
message: 'Failed to load yards.',
|
||||
onRetry: () => void yardsQuery.refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="zones" pt="lg">
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="flex-end">
|
||||
<Select
|
||||
label="Yard"
|
||||
placeholder="Select a yard"
|
||||
data={yardOptions}
|
||||
value={selectedYardId}
|
||||
onChange={setSelectedYardId}
|
||||
w={280}
|
||||
searchable
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
leftSection={<Plus size={16} />}
|
||||
disabled={!selectedYardId}
|
||||
onClick={() => {
|
||||
setEditingZone(null);
|
||||
setZoneModalOpen(true);
|
||||
}}
|
||||
>
|
||||
Create Zone
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{!selectedYardId ? (
|
||||
<Text c="dimmed" ta="center" py="lg">
|
||||
Select a yard to view its zones.
|
||||
</Text>
|
||||
) : (
|
||||
<DataTable
|
||||
columns={yardColumns}
|
||||
data={yards}
|
||||
columns={zoneColumns}
|
||||
data={zonesQuery.data ?? []}
|
||||
status={
|
||||
yardsQuery.isLoading
|
||||
? 'loading'
|
||||
: yardsQuery.isError
|
||||
? 'error'
|
||||
: 'success'
|
||||
zonesQuery.isLoading ? 'loading' : zonesQuery.isError ? 'error' : 'success'
|
||||
}
|
||||
emptyMessage="No yards yet."
|
||||
emptyMessage="No zones in this yard yet."
|
||||
containerClassName="border-0 shadow-none"
|
||||
error={
|
||||
yardsQuery.isError
|
||||
zonesQuery.isError
|
||||
? {
|
||||
message: 'Failed to load yards.',
|
||||
onRetry: () => void yardsQuery.refetch(),
|
||||
message: 'Failed to load zones.',
|
||||
onRetry: () => void zonesQuery.refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Tabs.Panel>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* ZONES */}
|
||||
<Tabs.Panel value="zones" pt="lg">
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="flex-end">
|
||||
<Select
|
||||
label="Yard"
|
||||
placeholder="Select a yard"
|
||||
data={yardOptions}
|
||||
value={selectedYardId}
|
||||
onChange={setSelectedYardId}
|
||||
w={280}
|
||||
searchable
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
leftSection={<Plus size={16} />}
|
||||
disabled={!selectedYardId}
|
||||
onClick={() => {
|
||||
setEditingZone(null);
|
||||
setZoneModalOpen(true);
|
||||
}}
|
||||
>
|
||||
Create Zone
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{!selectedYardId ? (
|
||||
<Text c="dimmed" ta="center" py="lg">
|
||||
Select a yard to view its zones.
|
||||
</Text>
|
||||
) : (
|
||||
<DataTable
|
||||
columns={zoneColumns}
|
||||
data={zonesQuery.data ?? []}
|
||||
status={
|
||||
zonesQuery.isLoading
|
||||
? 'loading'
|
||||
: zonesQuery.isError
|
||||
? 'error'
|
||||
: 'success'
|
||||
}
|
||||
emptyMessage="No zones in this yard yet."
|
||||
containerClassName="border-0 shadow-none"
|
||||
error={
|
||||
zonesQuery.isError
|
||||
? {
|
||||
message: 'Failed to load zones.',
|
||||
onRetry: () => void zonesQuery.refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* INVENTORY */}
|
||||
<Tabs.Panel value="inventory" pt="lg">
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<InventoryWorkbench items={inventoryQuery.data ?? []} isLoading={inventoryQuery.isLoading} />
|
||||
</Card>
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="inventory" pt="lg">
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<InventoryWorkbench
|
||||
items={inventoryQuery.data ?? []}
|
||||
isLoading={inventoryQuery.isLoading}
|
||||
/>
|
||||
</Card>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
{id && (
|
||||
|
||||
@@ -10,9 +10,12 @@ import {
|
||||
ReceiveInventoryModal,
|
||||
inventoryStatusOptions,
|
||||
} from '@/components/warehouses';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { api } from '@/services/api';
|
||||
import {
|
||||
useWarehouseInventory,
|
||||
useWarehouseYards,
|
||||
useWarehouseZones,
|
||||
useWarehouses,
|
||||
} from '@/hooks/useWarehouses';
|
||||
import type { InventoryFilter, InventoryStatus } from '@/types/warehouse';
|
||||
|
||||
export default function WarehouseInventoryPage() {
|
||||
@@ -30,24 +33,10 @@ export default function WarehouseInventoryPage() {
|
||||
[filter, debouncedSearch],
|
||||
);
|
||||
|
||||
const warehousesQuery = useQuery(
|
||||
api.warehouses.list.queryOptions({ input: {} }),
|
||||
);
|
||||
const yardsQuery = useQuery(
|
||||
api.warehouses.listYards.queryOptions({
|
||||
input: { warehouseId: filter.warehouseId ?? '' },
|
||||
enabled: Boolean(filter.warehouseId),
|
||||
}),
|
||||
);
|
||||
const zonesQuery = useQuery(
|
||||
api.warehouses.listZones.queryOptions({
|
||||
input: { yardId: filter.yardId ?? '' },
|
||||
enabled: Boolean(filter.yardId),
|
||||
}),
|
||||
);
|
||||
const inventoryQuery = useQuery(
|
||||
api.warehouses.listInventory.queryOptions({ input: { filter: queryFilter } }),
|
||||
);
|
||||
const warehousesQuery = useWarehouses();
|
||||
const yardsQuery = useWarehouseYards(filter.warehouseId);
|
||||
const zonesQuery = useWarehouseZones(filter.yardId);
|
||||
const inventoryQuery = useWarehouseInventory(queryFilter);
|
||||
|
||||
const warehouseOptions = useMemo(
|
||||
() => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })),
|
||||
@@ -91,7 +80,12 @@ export default function WarehouseInventoryPage() {
|
||||
data={warehouseOptions}
|
||||
value={filter.warehouseId ?? null}
|
||||
onChange={(value) =>
|
||||
setFilter((f) => ({ ...f, warehouseId: value ?? undefined, yardId: undefined, zoneId: undefined }))
|
||||
setFilter((f) => ({
|
||||
...f,
|
||||
warehouseId: value ?? undefined,
|
||||
yardId: undefined,
|
||||
zoneId: undefined,
|
||||
}))
|
||||
}
|
||||
w={220}
|
||||
/>
|
||||
@@ -102,7 +96,9 @@ export default function WarehouseInventoryPage() {
|
||||
disabled={!filter.warehouseId}
|
||||
data={yardOptions}
|
||||
value={filter.yardId ?? null}
|
||||
onChange={(value) => setFilter((f) => ({ ...f, yardId: value ?? undefined, zoneId: undefined }))}
|
||||
onChange={(value) =>
|
||||
setFilter((f) => ({ ...f, yardId: value ?? undefined, zoneId: undefined }))
|
||||
}
|
||||
w={200}
|
||||
/>
|
||||
<Select
|
||||
@@ -120,7 +116,9 @@ export default function WarehouseInventoryPage() {
|
||||
clearable
|
||||
data={inventoryStatusOptions}
|
||||
value={filter.status ?? null}
|
||||
onChange={(value) => setFilter((f) => ({ ...f, status: (value as InventoryStatus) ?? undefined }))}
|
||||
onChange={(value) =>
|
||||
setFilter((f) => ({ ...f, status: (value as InventoryStatus) ?? undefined }))
|
||||
}
|
||||
w={200}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
@@ -37,7 +37,7 @@ const STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = {
|
||||
CANCELLED: 'gray',
|
||||
};
|
||||
|
||||
const fmt = (n: number, c: string) => `${Number(n).toLocaleString()} ${c}`;
|
||||
const fmt = (n: number, c: string) => `${Number(n).toLocaleString()} ${c === 'ETB' ? 'Birr (ETB)' : c}`;
|
||||
const fmtDate = (d?: string | null) => (d ? new Date(d).toLocaleDateString() : '—');
|
||||
|
||||
export default function WarehouseInvoicesPage() {
|
||||
|
||||
@@ -12,9 +12,7 @@ import {
|
||||
WarehouseTable,
|
||||
type WarehouseView,
|
||||
} from '@/components/warehouses';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { api } from '@/services/api';
|
||||
import { useWarehouses } from '@/hooks/useWarehouses';
|
||||
import type { Warehouse, WarehouseFilter } from '@/types/warehouse';
|
||||
|
||||
export default function WarehouseListPage() {
|
||||
@@ -30,9 +28,7 @@ export default function WarehouseListPage() {
|
||||
[filter, debouncedSearch],
|
||||
);
|
||||
|
||||
const { data, isLoading, isError } = useQuery(
|
||||
api.warehouses.list.queryOptions({ input: { filter: queryFilter } }),
|
||||
);
|
||||
const { data, isLoading, isError } = useWarehouses(queryFilter);
|
||||
const warehouses = data ?? [];
|
||||
|
||||
const openCreate = () => {
|
||||
|
||||
@@ -1,26 +1,34 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Select,
|
||||
Stack,
|
||||
Tabs,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||
import { Info, Plus, Trash2 } from 'lucide-react';
|
||||
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { api } from '@/services/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import {
|
||||
useAllWarehouseYards,
|
||||
useAllocationRules,
|
||||
useCreateAllocationRule,
|
||||
useCreateFeeRule,
|
||||
useDeleteAllocationRule,
|
||||
useDeleteFeeRule,
|
||||
useFeeRules,
|
||||
} from '@/hooks/useWarehouses';
|
||||
import { FEE_RULE_TYPES, type FeeRuleType } from '@/types/warehouse';
|
||||
|
||||
const FREIGHT = [
|
||||
@@ -32,15 +40,26 @@ const TRADE = [
|
||||
{ value: 'EXPORT', label: 'Export' },
|
||||
{ value: 'DOMESTIC', label: 'Domestic' },
|
||||
];
|
||||
const CURRENCIES = [
|
||||
{ value: 'USD', label: 'USD - Dollar' },
|
||||
{ value: 'ETB', label: 'ETB - Birr' },
|
||||
];
|
||||
|
||||
const clean = (s: string) => s.trim() || undefined;
|
||||
const selectValue = (value: string | null, fallback = '') => value ?? fallback;
|
||||
const numberValue = (value: string | number, fallback = 0) => {
|
||||
const next = Number(value);
|
||||
return Number.isFinite(next) ? next : fallback;
|
||||
};
|
||||
const anyLabel = (value: string, label: string) => value.trim() || `Any ${label}`;
|
||||
const dash = '-';
|
||||
|
||||
export default function WarehouseRulesPage() {
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Allocation & Fee Rules"
|
||||
subtitle="Configure deterministic yard allocation and storage / demurrage free time and rates."
|
||||
subtitle="Configure yard allocation and storage or demurrage free time and rates."
|
||||
/>
|
||||
<Card>
|
||||
<Tabs defaultValue="allocation">
|
||||
@@ -62,11 +81,10 @@ export default function WarehouseRulesPage() {
|
||||
|
||||
function AllocationRules() {
|
||||
const { toast } = useToast();
|
||||
const { data, isLoading } = useQuery(
|
||||
api.warehouses.allocationRules.queryOptions(),
|
||||
);
|
||||
const create = useMutation(api.warehouses.createAllocationRule.mutationOptions());
|
||||
const remove = useMutation(api.warehouses.deleteAllocationRule.mutationOptions());
|
||||
const { data, isLoading } = useAllocationRules();
|
||||
const { data: yards = [], isLoading: yardsLoading } = useAllWarehouseYards();
|
||||
const create = useCreateAllocationRule();
|
||||
const remove = useDeleteAllocationRule();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
@@ -78,13 +96,33 @@ function AllocationRules() {
|
||||
targetYardCode: '',
|
||||
storageType: '',
|
||||
});
|
||||
|
||||
const rules = data ?? [];
|
||||
const yardOptions = yards
|
||||
.filter((yard) => yard.code)
|
||||
.map((yard) => ({
|
||||
value: yard.code,
|
||||
label: `${yard.code} - ${yard.name}${yard.warehouse?.code ? ` (${yard.warehouse.code})` : ''}`,
|
||||
}));
|
||||
|
||||
const resetForm = () =>
|
||||
setForm({
|
||||
name: '',
|
||||
priority: 100,
|
||||
freightType: '',
|
||||
tradeDirection: '',
|
||||
cargoTypeCode: '',
|
||||
containerStatus: '',
|
||||
targetYardCode: '',
|
||||
storageType: '',
|
||||
});
|
||||
|
||||
const submit = async () => {
|
||||
if (!form.name.trim() || !form.targetYardCode.trim()) {
|
||||
toast({ variant: 'destructive', title: 'Name and target yard code are required' });
|
||||
toast({ variant: 'destructive', title: 'Name and target yard are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
await create.mutateAsync({
|
||||
name: form.name.trim(),
|
||||
priority: form.priority,
|
||||
@@ -98,77 +136,179 @@ function AllocationRules() {
|
||||
} as never);
|
||||
toast({ title: 'Allocation rule created' });
|
||||
setOpen(false);
|
||||
setForm({ name: '', priority: 100, freightType: '', tradeDirection: '', cargoTypeCode: '', containerStatus: '', targetYardCode: '', storageType: '' });
|
||||
resetForm();
|
||||
};
|
||||
|
||||
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 ? 'edr-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>
|
||||
<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>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rules}
|
||||
status={isLoading ? 'loading' : 'success'}
|
||||
emptyMessage="No allocation rules yet."
|
||||
containerClassName="border-0 shadow-none"
|
||||
/>
|
||||
<Alert icon={<Info size={16} />} color="orange" variant="light" mb="md">
|
||||
<Text size="sm">
|
||||
Allocation rules tell the system where to place a booking when it enters the warehouse.
|
||||
Lower priority numbers are checked first.
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
{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((rule) => (
|
||||
<Table.Tr key={rule.id}>
|
||||
<Table.Td>{rule.priority}</Table.Td>
|
||||
<Table.Td>{rule.name}</Table.Td>
|
||||
<Table.Td>{rule.freightType ?? dash}</Table.Td>
|
||||
<Table.Td>{rule.tradeDirection ?? dash}</Table.Td>
|
||||
<Table.Td>{rule.cargoTypeCode ?? dash}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge variant="light">{rule.targetYardCode}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={rule.isActive ? 'green' : 'gray'} variant="light">
|
||||
{rule.isActive ? 'Yes' : 'No'}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => remove.mutate(rule.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">
|
||||
<Stack gap="sm">
|
||||
<Stack gap="md">
|
||||
<Card withBorder radius="md" padding="sm" bg="gray.0">
|
||||
<Stack gap={4}>
|
||||
<Text size="xs" c="dimmed" fw={700} tt="uppercase">
|
||||
Rule preview
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
<b>When</b> {anyLabel(form.tradeDirection, 'trade direction').toLowerCase()} /{' '}
|
||||
{anyLabel(form.freightType, 'freight type').toLowerCase()} booking
|
||||
{form.cargoTypeCode.trim() ? ` with cargo code ${form.cargoTypeCode.trim()}` : ''}
|
||||
{form.containerStatus.trim() ? ` and container status ${form.containerStatus.trim()}` : ''}{' '}
|
||||
is received, <b>send it to</b> {form.targetYardCode || 'a selected target yard'}.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
<Group grow>
|
||||
<TextInput label="Name" required value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.currentTarget.value }))} />
|
||||
<NumberInput label="Priority" value={form.priority} onChange={(v) => setForm((f) => ({ ...f, priority: Number(v) || 100 }))} />
|
||||
<TextInput
|
||||
label="Rule name"
|
||||
placeholder="e.g. Import containers to open yard"
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.value;
|
||||
setForm((f) => ({ ...f, name: value }));
|
||||
}}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Priority"
|
||||
value={form.priority}
|
||||
onChange={(v) => setForm((f) => ({ ...f, priority: numberValue(v, 100) || 100 }))}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<Select label="Freight type" data={FREIGHT} value={form.freightType || null} onChange={(v) => setForm((f) => ({ ...f, freightType: v ?? '' }))} clearable />
|
||||
<Select label="Trade direction" data={TRADE} value={form.tradeDirection || null} onChange={(v) => setForm((f) => ({ ...f, tradeDirection: v ?? '' }))} clearable />
|
||||
<Select
|
||||
label="Freight type"
|
||||
placeholder="Any freight"
|
||||
data={FREIGHT}
|
||||
value={form.freightType || null}
|
||||
onChange={(v) => setForm((f) => ({ ...f, freightType: selectValue(v) }))}
|
||||
clearable
|
||||
/>
|
||||
<Select
|
||||
label="Trade direction"
|
||||
placeholder="Any direction"
|
||||
data={TRADE}
|
||||
value={form.tradeDirection || null}
|
||||
onChange={(v) => setForm((f) => ({ ...f, tradeDirection: selectValue(v) }))}
|
||||
clearable
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<TextInput label="Cargo type code" value={form.cargoTypeCode} onChange={(e) => setForm((f) => ({ ...f, cargoTypeCode: e.currentTarget.value }))} />
|
||||
<TextInput label="Container status" placeholder="e.g. MAINTENANCE" value={form.containerStatus} onChange={(e) => setForm((f) => ({ ...f, containerStatus: e.currentTarget.value }))} />
|
||||
<TextInput
|
||||
label="Cargo type code"
|
||||
placeholder="e.g. COFFEE"
|
||||
value={form.cargoTypeCode}
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.value;
|
||||
setForm((f) => ({ ...f, cargoTypeCode: value }));
|
||||
}}
|
||||
/>
|
||||
<TextInput
|
||||
label="Container status"
|
||||
placeholder="e.g. MAINTENANCE"
|
||||
value={form.containerStatus}
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.value;
|
||||
setForm((f) => ({ ...f, containerStatus: value }));
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<TextInput label="Target yard code" required value={form.targetYardCode} onChange={(e) => setForm((f) => ({ ...f, targetYardCode: e.currentTarget.value }))} />
|
||||
<TextInput label="Storage type" value={form.storageType} onChange={(e) => setForm((f) => ({ ...f, storageType: e.currentTarget.value }))} />
|
||||
<Select
|
||||
label="Target yard"
|
||||
required
|
||||
searchable
|
||||
clearable
|
||||
data={yardOptions}
|
||||
value={form.targetYardCode || null}
|
||||
placeholder={yardsLoading ? 'Loading yards...' : 'Select target yard'}
|
||||
nothingFoundMessage="No yards found"
|
||||
disabled={yardsLoading}
|
||||
onChange={(value) => setForm((f) => ({ ...f, targetYardCode: selectValue(value) }))}
|
||||
/>
|
||||
<TextInput
|
||||
label="Storage type"
|
||||
placeholder="e.g. OPEN_STACK"
|
||||
value={form.storageType}
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.value;
|
||||
setForm((f) => ({ ...f, storageType: value }));
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={() => setOpen(false)}>Cancel</Button>
|
||||
<Button loading={create.isPending} onClick={submit}>Create</Button>
|
||||
<Button variant="default" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button loading={create.isPending} onClick={submit}>
|
||||
Create
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
@@ -178,9 +318,9 @@ function AllocationRules() {
|
||||
|
||||
function FeeRules() {
|
||||
const { toast } = useToast();
|
||||
const { data, isLoading } = useQuery(api.warehouses.feeRules.queryOptions());
|
||||
const create = useMutation(api.warehouses.createFeeRule.mutationOptions());
|
||||
const remove = useMutation(api.warehouses.deleteFeeRule.mutationOptions());
|
||||
const { data, isLoading } = useFeeRules();
|
||||
const create = useCreateFeeRule();
|
||||
const remove = useDeleteFeeRule();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
@@ -199,6 +339,7 @@ function FeeRules() {
|
||||
toast({ variant: 'destructive', title: 'Name is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
await create.mutateAsync({
|
||||
name: form.name.trim(),
|
||||
ruleType: form.ruleType,
|
||||
@@ -208,86 +349,158 @@ function FeeRules() {
|
||||
freeDays: form.freeDays,
|
||||
ratePerDay: form.ratePerDay,
|
||||
currency: form.currency || 'USD',
|
||||
isActive: true,
|
||||
} as never);
|
||||
toast({ title: 'Fee rule created' });
|
||||
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 ? 'edr-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>
|
||||
<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>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rules}
|
||||
status={isLoading ? 'loading' : 'success'}
|
||||
emptyMessage="No fee rules yet."
|
||||
containerClassName="border-0 shadow-none"
|
||||
/>
|
||||
|
||||
{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((rule) => (
|
||||
<Table.Tr key={rule.id}>
|
||||
<Table.Td>
|
||||
<Badge color={rule.ruleType === 'DEMURRAGE_FEE' ? 'orange' : 'teal'} variant="light">
|
||||
{rule.ruleType === 'DEMURRAGE_FEE' ? 'Demurrage' : 'Storage'}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>{rule.name}</Table.Td>
|
||||
<Table.Td>{rule.freightType ?? dash}</Table.Td>
|
||||
<Table.Td>{rule.tradeDirection ?? dash}</Table.Td>
|
||||
<Table.Td>{rule.freeDays}</Table.Td>
|
||||
<Table.Td>
|
||||
{Number(rule.ratePerDay).toLocaleString()} {rule.currency}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={rule.isActive ? 'green' : 'gray'} variant="light">
|
||||
{rule.isActive ? 'Yes' : 'No'}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => remove.mutate(rule.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">
|
||||
<Stack gap="sm">
|
||||
<Group grow>
|
||||
<TextInput label="Name" required value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.currentTarget.value }))} />
|
||||
<Select label="Rule type" data={FEE_RULE_TYPES.map((t) => ({ value: t, label: t === 'DEMURRAGE_FEE' ? 'Demurrage' : 'Storage' }))} value={form.ruleType} onChange={(v) => setForm((f) => ({ ...f, ruleType: (v as FeeRuleType) ?? 'DEMURRAGE_FEE' }))} allowDeselect={false} />
|
||||
<TextInput
|
||||
label="Name"
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.value;
|
||||
setForm((f) => ({ ...f, name: value }));
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
label="Rule type"
|
||||
data={FEE_RULE_TYPES.map((type) => ({
|
||||
value: type,
|
||||
label: type === 'DEMURRAGE_FEE' ? 'Demurrage' : 'Storage',
|
||||
}))}
|
||||
value={form.ruleType}
|
||||
onChange={(value) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
ruleType: selectValue(value, 'DEMURRAGE_FEE') as FeeRuleType,
|
||||
}))
|
||||
}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<Select label="Freight type" data={FREIGHT} value={form.freightType || null} onChange={(v) => setForm((f) => ({ ...f, freightType: v ?? '' }))} clearable />
|
||||
<Select label="Trade direction" data={TRADE} value={form.tradeDirection || null} onChange={(v) => setForm((f) => ({ ...f, tradeDirection: v ?? '' }))} clearable />
|
||||
<TextInput label="Cargo type code" value={form.cargoTypeCode} onChange={(e) => setForm((f) => ({ ...f, cargoTypeCode: e.currentTarget.value }))} />
|
||||
<Select
|
||||
label="Freight type"
|
||||
data={FREIGHT}
|
||||
value={form.freightType || null}
|
||||
onChange={(value) => setForm((f) => ({ ...f, freightType: selectValue(value) }))}
|
||||
clearable
|
||||
/>
|
||||
<Select
|
||||
label="Trade direction"
|
||||
data={TRADE}
|
||||
value={form.tradeDirection || null}
|
||||
onChange={(value) => setForm((f) => ({ ...f, tradeDirection: selectValue(value) }))}
|
||||
clearable
|
||||
/>
|
||||
<TextInput
|
||||
label="Cargo type code"
|
||||
value={form.cargoTypeCode}
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.value;
|
||||
setForm((f) => ({ ...f, cargoTypeCode: value }));
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<NumberInput label="Free days" min={0} value={form.freeDays} onChange={(v) => setForm((f) => ({ ...f, freeDays: Number(v) || 0 }))} />
|
||||
<NumberInput label="Rate / day" min={0} value={form.ratePerDay} onChange={(v) => setForm((f) => ({ ...f, ratePerDay: Number(v) || 0 }))} />
|
||||
<TextInput label="Currency" value={form.currency} onChange={(e) => setForm((f) => ({ ...f, currency: e.currentTarget.value }))} />
|
||||
<NumberInput
|
||||
label="Free days"
|
||||
min={0}
|
||||
value={form.freeDays}
|
||||
onChange={(value) => setForm((f) => ({ ...f, freeDays: numberValue(value) }))}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Rate / day"
|
||||
min={0}
|
||||
value={form.ratePerDay}
|
||||
onChange={(value) => setForm((f) => ({ ...f, ratePerDay: numberValue(value) }))}
|
||||
/>
|
||||
<Select
|
||||
label="Currency"
|
||||
data={CURRENCIES}
|
||||
value={form.currency}
|
||||
onChange={(value) => setForm((f) => ({ ...f, currency: selectValue(value, 'USD') }))}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
</Group>
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={() => setOpen(false)}>Cancel</Button>
|
||||
<Button loading={create.isPending} onClick={submit}>Create</Button>
|
||||
<Button variant="default" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button loading={create.isPending} onClick={submit}>
|
||||
Create
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
Reference in New Issue
Block a user