mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 09:30:59 +00:00
@@ -65,6 +65,7 @@ import InvoiceDetailPage from "./pages/invoices/InvoiceDetailPage";
|
||||
import InvoicesPage from "./pages/invoices/InvoicesPage";
|
||||
import MyProfilePage from "./pages/dashboard/MyProfilePage";
|
||||
import OverviewPage from "./pages/dashboard/OverviewPage";
|
||||
import AiBookingMockTestPage from "./pages/ai/AiBookingMockTestPage";
|
||||
import PaymentsPage from "./pages/payments/PaymentsPage";
|
||||
//import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage";
|
||||
import { RequirePermission } from "./components/auth/RequirePermission";
|
||||
@@ -673,6 +674,11 @@ const App = () => {
|
||||
/>
|
||||
<Route path="/dashboard" element={<DashboardShell />}>
|
||||
<Route path="overview" element={<OverviewPage />} />
|
||||
{/* Dev/testing page for the mock AI booking assistant. */}
|
||||
<Route
|
||||
path="ai-booking-mock-test"
|
||||
element={<AiBookingMockTestPage />}
|
||||
/>
|
||||
<Route path="profile" element={<MyProfilePage />} />
|
||||
|
||||
<Route path="booking-requests" element={<BookingRequestsPage />} />
|
||||
|
||||
@@ -1550,6 +1550,18 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
|
||||
const [trainPickerOpen, setTrainPickerOpen] = useState(false);
|
||||
const [expandedRow, setExpandedRow] = useState<string | null>(null);
|
||||
const [targetScheduleId, setTargetScheduleId] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
|
||||
const allSelected = rows.length > 0 && selected.size === rows.length;
|
||||
const someSelected = selected.size > 0 && !allSelected;
|
||||
const toggleAll = () => setSelected(allSelected ? new Set() : new Set(rows.map((r) => r.id)));
|
||||
const toggleOne = (id: string) =>
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
// Loading is always onto a SPECIFIC pre-dispatch train. No train -> no auto-load.
|
||||
const { data: trains = [], isLoading: trainsLoading } = useQuery({
|
||||
queryKey: ['warehouse-inventory', 'loadable-trains'],
|
||||
@@ -1557,11 +1569,20 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
|
||||
enabled: enabled && trainPickerOpen,
|
||||
});
|
||||
const loadOntoTrain = useMutation({
|
||||
mutationFn: async (scheduleId: string) => {
|
||||
mutationFn: async ({ scheduleId, onlyIds }: { scheduleId: string; onlyIds: string[] }) => {
|
||||
const items = await warehouseService.getTrainLoadableItems(scheduleId);
|
||||
const loadableIds = items.filter((i) => i.loadable).map((i) => i.id);
|
||||
let loadableIds = items.filter((i) => i.loadable).map((i) => i.id);
|
||||
// When rows are checked, load only those; otherwise load every loadable item.
|
||||
if (onlyIds.length) {
|
||||
const picked = new Set(onlyIds);
|
||||
loadableIds = loadableIds.filter((id) => picked.has(id));
|
||||
}
|
||||
if (!loadableIds.length) {
|
||||
throw new Error('No ready items with an allocated wagon on this train');
|
||||
throw new Error(
|
||||
onlyIds.length
|
||||
? 'None of the selected items have an allocated wagon on this train'
|
||||
: 'No ready items with an allocated wagon on this train',
|
||||
);
|
||||
}
|
||||
return warehouseService.loadItemsOntoTrain(scheduleId, loadableIds);
|
||||
},
|
||||
@@ -1578,7 +1599,10 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = await loadOntoTrain.mutateAsync(targetScheduleId);
|
||||
const r = await loadOntoTrain.mutateAsync({
|
||||
scheduleId: targetScheduleId,
|
||||
onlyIds: [...selected],
|
||||
});
|
||||
const train = trains.find((t) => t.scheduleId === targetScheduleId);
|
||||
toast({
|
||||
title: `${r.loadedCount} item(s) loaded onto train ${train?.trainNumber ?? ''}`.trim(),
|
||||
@@ -1588,6 +1612,7 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
|
||||
});
|
||||
setTrainPickerOpen(false);
|
||||
setTargetScheduleId(null);
|
||||
setSelected(new Set());
|
||||
onChanged?.();
|
||||
} catch (error) {
|
||||
toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(error) });
|
||||
@@ -1598,7 +1623,11 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
|
||||
<Stack gap="sm" mt="sm">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
<b>{rows.length}</b> item{rows.length !== 1 ? 's' : ''} ready to load
|
||||
{selected.size > 0 ? (
|
||||
<><b>{selected.size}</b> of {rows.length} selected</>
|
||||
) : (
|
||||
<><b>{rows.length}</b> item{rows.length !== 1 ? 's' : ''} ready to load</>
|
||||
)}
|
||||
</Text>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
@@ -1608,7 +1637,7 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
|
||||
disabled={rows.length === 0}
|
||||
onClick={() => setTrainPickerOpen(true)}
|
||||
>
|
||||
Auto Load Ready Items
|
||||
{selected.size > 0 ? `Load Selected (${selected.size})` : 'Auto Load Ready Items'}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -1670,6 +1699,14 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
|
||||
<Table highlightOnHover verticalSpacing="xs" striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th w={40}>
|
||||
<Checkbox
|
||||
aria-label="Select all"
|
||||
checked={allSelected}
|
||||
indeterminate={someSelected}
|
||||
onChange={toggleAll}
|
||||
/>
|
||||
</Table.Th>
|
||||
<Table.Th w={34} />
|
||||
<Table.Th>Booking Ref</Table.Th>
|
||||
<Table.Th>GRN</Table.Th>
|
||||
@@ -1686,6 +1723,13 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
|
||||
{rows.map((r: ReadyToLoadRow) => (
|
||||
<Fragment key={r.id}>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Checkbox
|
||||
aria-label={`Select ${r.bookingReference ?? r.id}`}
|
||||
checked={selected.has(r.id)}
|
||||
onChange={() => toggleOne(r.id)}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Code,
|
||||
Group,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Textarea,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import axios from "axios";
|
||||
|
||||
import {
|
||||
AiBookingExtractResult,
|
||||
extractBookingFromText,
|
||||
} from "@/services/ai.service";
|
||||
|
||||
const EXAMPLE_TEXT =
|
||||
"Book 2x40ft containers from Djibouti to Indode. Cargo electronics. Customer ABC Logistics.";
|
||||
|
||||
const formatValue = (value: string | number | boolean | null): string => {
|
||||
if (value === null) return "—";
|
||||
if (typeof value === "boolean") return value ? "Yes" : "No";
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const EXTRACTED_FIELD_LABELS: Array<{
|
||||
key: keyof AiBookingExtractResult["extracted"];
|
||||
label: string;
|
||||
}> = [
|
||||
{ key: "customerName", label: "Customer Name" },
|
||||
{ key: "origin", label: "Origin" },
|
||||
{ key: "destination", label: "Destination" },
|
||||
{ key: "cargoType", label: "Cargo Type" },
|
||||
{ key: "containerType", label: "Container Type" },
|
||||
{ key: "quantity", label: "Quantity" },
|
||||
{ key: "direction", label: "Direction" },
|
||||
{ key: "weightKg", label: "Weight (kg)" },
|
||||
{ key: "pickupRequired", label: "Pickup Required" },
|
||||
{ key: "deliveryRequired", label: "Delivery Required" },
|
||||
];
|
||||
|
||||
export default function AiBookingMockTestPage() {
|
||||
const [text, setText] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<AiBookingExtractResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showRawJson, setShowRawJson] = useState(false);
|
||||
|
||||
const handleTest = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
try {
|
||||
setResult(await extractBookingFromText(text));
|
||||
} catch (err) {
|
||||
const backendMessage = axios.isAxiosError(err)
|
||||
? (err.response?.data as { message?: string | string[] } | undefined)
|
||||
?.message
|
||||
: null;
|
||||
setError(
|
||||
backendMessage
|
||||
? `Mock AI request failed: ${
|
||||
Array.isArray(backendMessage)
|
||||
? backendMessage.join(", ")
|
||||
: backendMessage
|
||||
}`
|
||||
: "Mock AI request failed",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateDraftBooking = () => {
|
||||
window.alert("Draft booking creation will be connected in the next step.");
|
||||
};
|
||||
|
||||
const canCreateDraft = Boolean(result?.validation.valid);
|
||||
|
||||
return (
|
||||
<Stack gap="lg" p="md" maw={860}>
|
||||
<Title order={2}>Mock AI Booking Assistant</Title>
|
||||
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Stack gap="sm">
|
||||
<Textarea
|
||||
label="Customer booking request"
|
||||
placeholder="Enter customer booking request..."
|
||||
description={`Example: ${EXAMPLE_TEXT}`}
|
||||
minRows={4}
|
||||
autosize
|
||||
value={text}
|
||||
onChange={(event) => setText(event.currentTarget.value)}
|
||||
/>
|
||||
<Group>
|
||||
<Button
|
||||
onClick={handleTest}
|
||||
loading={loading}
|
||||
disabled={text.trim().length < 5}
|
||||
>
|
||||
{loading ? "Testing..." : "Test Mock AI"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
color="gray"
|
||||
onClick={() => setText(EXAMPLE_TEXT)}
|
||||
>
|
||||
Use example
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{error && (
|
||||
<Alert color="red" title="Request failed">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<>
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between">
|
||||
<Title order={4}>Extracted Booking Data</Title>
|
||||
<Badge variant="light" color="gray">
|
||||
provider: {result.provider}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Table withTableBorder={false} verticalSpacing="xs">
|
||||
<Table.Tbody>
|
||||
{EXTRACTED_FIELD_LABELS.map(({ key, label }) => (
|
||||
<Table.Tr key={key}>
|
||||
<Table.Td w={200}>
|
||||
<Text size="sm" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={500}>
|
||||
{formatValue(result.extracted[key])}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Stack gap="sm">
|
||||
<Group>
|
||||
<Title order={4}>Validation</Title>
|
||||
<Badge color={result.validation.valid ? "green" : "red"}>
|
||||
{result.validation.valid ? "Valid" : "Invalid"}
|
||||
</Badge>
|
||||
</Group>
|
||||
{result.validation.errors.length > 0 && (
|
||||
<Stack gap={4}>
|
||||
{result.validation.errors.map((message) => (
|
||||
<Text key={message} size="sm" c="red">
|
||||
• {message}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Stack gap="sm">
|
||||
<Group>
|
||||
<Title order={4}>Recommendation</Title>
|
||||
<Badge
|
||||
color={
|
||||
result.recommendation.action === "CREATE_DRAFT_BOOKING"
|
||||
? "green"
|
||||
: "yellow"
|
||||
}
|
||||
>
|
||||
{result.recommendation.action}
|
||||
</Badge>
|
||||
<Badge variant="light">
|
||||
confidence {Math.round(result.recommendation.confidence * 100)}%
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="sm">{result.recommendation.message}</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Group>
|
||||
<Button
|
||||
color="green"
|
||||
disabled={!canCreateDraft}
|
||||
onClick={handleCreateDraftBooking}
|
||||
>
|
||||
Create Draft Booking
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => setShowRawJson((open) => !open)}
|
||||
>
|
||||
{showRawJson ? "Hide raw JSON" : "Show raw JSON"}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{showRawJson && (
|
||||
<Code block>{JSON.stringify(result, null, 2)}</Code>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
Select,
|
||||
@@ -13,10 +12,9 @@ import {
|
||||
} from '@mantine/core';
|
||||
import { ChevronDown, ChevronRight, PackageOpen, Truck } from 'lucide-react';
|
||||
|
||||
import Breadcrumbs from '@/components/ui/Breadcrumbs';
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
import {
|
||||
VisualEmptyState,
|
||||
WarehouseHero,
|
||||
formatDate,
|
||||
formatNumber,
|
||||
} from '@/components/warehouses';
|
||||
@@ -286,18 +284,14 @@ export default function ArrivalQueuePage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Container size="xxl" py="lg">
|
||||
<Breadcrumbs items={[{ label: 'Arrival queue' }]} />
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Arrival / Unloading Queue"
|
||||
subtitle="Arrived import trains ready to unload assigned bookings into warehouse inventory."
|
||||
breadcrumbs={[{ label: 'Arrival queue' }]}
|
||||
/>
|
||||
|
||||
<Stack gap="lg" mt="sm">
|
||||
<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">
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Stack gap={2}>
|
||||
<Text fw={600}>{trains.length} arrived import train(s)</Text>
|
||||
@@ -428,8 +422,7 @@ export default function ArrivalQueuePage() {
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
</Container>
|
||||
</Card>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { Fragment, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Alert,
|
||||
Stack,
|
||||
Table,
|
||||
Tabs,
|
||||
Text,
|
||||
@@ -26,13 +24,11 @@ import {
|
||||
Truck,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { PageHeader } from '@/components/page';
|
||||
import Breadcrumbs from '@/components/ui/Breadcrumbs';
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
import {
|
||||
ActivityTimeline,
|
||||
InventoryMovementHistoryTable,
|
||||
VisualEmptyState,
|
||||
WarehouseHero,
|
||||
formatDate,
|
||||
formatNumber,
|
||||
} from '@/components/warehouses';
|
||||
@@ -289,23 +285,14 @@ export default function ExportDjiboutiUnloadingQueuePage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Container size="xxl" py="lg">
|
||||
<Breadcrumbs items={[{ label: 'Djibouti Arrival / Unloading Queue' }]} />
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Djibouti Arrival / Unloading Queue"
|
||||
subtitle="Arrived export trains at Djibouti-side destinations, auto unloading, and signed interchange handover."
|
||||
breadcrumbs={[{ label: 'Djibouti Arrival / Unloading Queue' }]}
|
||||
/>
|
||||
|
||||
<Stack gap="lg" mt="sm">
|
||||
<PageHeader
|
||||
title="Djibouti Arrival / Unloading Queue"
|
||||
subtitle="Arrived export trains at Djibouti-side destinations, auto unloading, and signed interchange handover."
|
||||
/>
|
||||
|
||||
<WarehouseHero
|
||||
variant="train"
|
||||
secondaryVariant="container"
|
||||
title="Export Unloading at Djibouti Port"
|
||||
subtitle="Review arrived trains, auto unload eligible export items, then generate the EDR and Djibouti Port signed interchange document."
|
||||
/>
|
||||
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Text fw={600}>{trains.length} arrived export train(s)</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
@@ -451,8 +438,7 @@ export default function ExportDjiboutiUnloadingQueuePage() {
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
opened={Boolean(historyInventoryId)}
|
||||
@@ -475,6 +461,6 @@ export default function ExportDjiboutiUnloadingQueuePage() {
|
||||
</Tabs>
|
||||
) : null}
|
||||
</Modal>
|
||||
</Container>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
import { CheckCircle2, Download, Eye, FileText, Printer, Search, XCircle } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
import { VisualEmptyState, formatDate, formatNumber } from '@/components/warehouses';
|
||||
import {
|
||||
@@ -323,6 +325,132 @@ export default function InterchangeDocumentsPage() {
|
||||
run(() => dispute.mutateAsync({ id: document.id, remarks }), 'Interchange document disputed');
|
||||
};
|
||||
|
||||
const documentColumns: ColumnDef<InterchangeDocument>[] = [
|
||||
{
|
||||
id: 'documentNo',
|
||||
header: 'Document No',
|
||||
cell: ({ row }) => (
|
||||
<Text fw={700} size="sm">
|
||||
{row.original.documentNo}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{ id: 'direction', header: 'Direction', cell: ({ row }) => row.original.direction },
|
||||
{
|
||||
id: 'train',
|
||||
header: 'Train No / Schedule',
|
||||
cell: ({ row }) => (
|
||||
<Stack gap={0}>
|
||||
<Text size="sm">{row.original.trainNo ?? '-'}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{row.original.scheduleId?.slice(0, 8) ?? '-'}
|
||||
</Text>
|
||||
</Stack>
|
||||
),
|
||||
},
|
||||
{ id: 'route', header: 'Route', cell: ({ row }) => row.original.routeId?.slice(0, 8) ?? '-' },
|
||||
{ id: 'handoverLocation', header: 'Handover Location', cell: ({ row }) => row.original.handoverLocation },
|
||||
{ id: 'handoverFrom', header: 'Handover From', cell: ({ row }) => row.original.handoverFrom },
|
||||
{ id: 'handoverTo', header: 'Handover To', cell: ({ row }) => row.original.handoverTo },
|
||||
{
|
||||
id: 'status',
|
||||
header: 'Status',
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="light" color={statusColor[row.original.status]}>
|
||||
{row.original.status}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'signedBy',
|
||||
header: 'Signed By',
|
||||
cell: ({ row }) => (
|
||||
<Stack gap={0}>
|
||||
<Text size="sm">{row.original.generatedBy ?? '-'}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{row.original.acknowledgedBy ?? 'Awaiting Djibouti Port'}
|
||||
</Text>
|
||||
</Stack>
|
||||
),
|
||||
},
|
||||
{ id: 'generatedAt', header: 'Generated At', cell: ({ row }) => formatDate(row.original.generatedAt) },
|
||||
{
|
||||
id: 'actions',
|
||||
header: '',
|
||||
meta: { headerClassName: 'text-right', cellClassName: 'text-right' },
|
||||
cell: ({ row }) => {
|
||||
const doc = row.original;
|
||||
return (
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={<Eye size={14} />}
|
||||
onClick={() => setViewId(doc.id)}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
{doc.status !== 'ACKNOWLEDGED' && doc.status !== 'CANCELLED' ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="green"
|
||||
variant="light"
|
||||
leftSection={<CheckCircle2 size={14} />}
|
||||
onClick={() => acknowledgeDocument(doc)}
|
||||
>
|
||||
Acknowledge
|
||||
</Button>
|
||||
) : null}
|
||||
{doc.status === 'ACKNOWLEDGED' ? (
|
||||
<>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="blue"
|
||||
variant="light"
|
||||
leftSection={<Printer size={14} />}
|
||||
onClick={() => run(() => printDocument(doc), 'Print view opened')}
|
||||
>
|
||||
Print
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="blue"
|
||||
variant="light"
|
||||
leftSection={<Download size={14} />}
|
||||
onClick={() => run(() => downloadDocument(doc), 'Document downloaded')}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
{doc.status !== 'CANCELLED' ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="orange"
|
||||
variant="light"
|
||||
leftSection={<FileText size={14} />}
|
||||
onClick={() => disputeDocument(doc)}
|
||||
>
|
||||
Dispute
|
||||
</Button>
|
||||
) : null}
|
||||
{doc.status === 'DRAFT' || doc.status === 'GENERATED' ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="red"
|
||||
variant="light"
|
||||
leftSection={<XCircle size={14} />}
|
||||
onClick={() => run(() => cancel.mutateAsync(doc.id), 'Interchange document cancelled')}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
@@ -342,143 +470,19 @@ export default function InterchangeDocumentsPage() {
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
) : documents.length === 0 ? (
|
||||
{!isLoading && documents.length === 0 ? (
|
||||
<VisualEmptyState
|
||||
variant="container"
|
||||
title="No interchange documents"
|
||||
description="Generated freight handover documents appear here."
|
||||
/>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={1060}>
|
||||
<Table striped highlightOnHover verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Document No</Table.Th>
|
||||
<Table.Th>Direction</Table.Th>
|
||||
<Table.Th>Train No / Schedule</Table.Th>
|
||||
<Table.Th>Route</Table.Th>
|
||||
<Table.Th>Handover Location</Table.Th>
|
||||
<Table.Th>Handover From</Table.Th>
|
||||
<Table.Th>Handover To</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Signed By</Table.Th>
|
||||
<Table.Th>Generated At</Table.Th>
|
||||
<Table.Th ta="right">Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{documents.map((document) => (
|
||||
<Table.Tr key={document.id}>
|
||||
<Table.Td>
|
||||
<Text fw={700} size="sm">
|
||||
{document.documentNo}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{document.direction}</Table.Td>
|
||||
<Table.Td>
|
||||
<Stack gap={0}>
|
||||
<Text size="sm">{document.trainNo ?? '-'}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{document.scheduleId?.slice(0, 8) ?? '-'}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Table.Td>
|
||||
<Table.Td>{document.routeId?.slice(0, 8) ?? '-'}</Table.Td>
|
||||
<Table.Td>{document.handoverLocation}</Table.Td>
|
||||
<Table.Td>{document.handoverFrom}</Table.Td>
|
||||
<Table.Td>{document.handoverTo}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge variant="light" color={statusColor[document.status]}>
|
||||
{document.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Stack gap={0}>
|
||||
<Text size="sm">{document.generatedBy ?? '-'}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{document.acknowledgedBy ?? 'Awaiting Djibouti Port'}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Table.Td>
|
||||
<Table.Td>{formatDate(document.generatedAt)}</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={<Eye size={14} />}
|
||||
onClick={() => setViewId(document.id)}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
{document.status !== 'ACKNOWLEDGED' && document.status !== 'CANCELLED' ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="green"
|
||||
variant="light"
|
||||
leftSection={<CheckCircle2 size={14} />}
|
||||
onClick={() => acknowledgeDocument(document)}
|
||||
>
|
||||
Acknowledge
|
||||
</Button>
|
||||
) : null}
|
||||
{document.status === 'ACKNOWLEDGED' ? (
|
||||
<>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="blue"
|
||||
variant="light"
|
||||
leftSection={<Printer size={14} />}
|
||||
onClick={() => run(() => printDocument(document), 'Print view opened')}
|
||||
>
|
||||
Print
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="blue"
|
||||
variant="light"
|
||||
leftSection={<Download size={14} />}
|
||||
onClick={() => run(() => downloadDocument(document), 'Document downloaded')}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
{document.status !== 'CANCELLED' ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="orange"
|
||||
variant="light"
|
||||
leftSection={<FileText size={14} />}
|
||||
onClick={() => disputeDocument(document)}
|
||||
>
|
||||
Dispute
|
||||
</Button>
|
||||
) : null}
|
||||
{document.status === 'DRAFT' || document.status === 'GENERATED' ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="red"
|
||||
variant="light"
|
||||
leftSection={<XCircle size={14} />}
|
||||
onClick={() =>
|
||||
run(() => cancel.mutateAsync(document.id), 'Interchange document cancelled')
|
||||
}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
<DataTable
|
||||
columns={documentColumns}
|
||||
data={documents}
|
||||
status={isLoading ? 'loading' : 'success'}
|
||||
containerClassName="border-0 shadow-none"
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card, Center, Group, Loader, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core';
|
||||
import { Card, Center, Group, Loader, SimpleGrid, Text, ThemeIcon } from '@mantine/core';
|
||||
import {
|
||||
ClipboardCheck,
|
||||
ClipboardList,
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
import { WarehouseDashboardCharts, WarehouseHero } from '@/components/warehouses';
|
||||
import { WarehouseDashboardCharts } from '@/components/warehouses';
|
||||
import { useWarehouseDashboard } from '@/hooks/useWarehouses';
|
||||
import type { WarehouseDashboard } from '@/types/warehouse';
|
||||
|
||||
@@ -58,58 +58,49 @@ export default function WarehouseDashboardPage() {
|
||||
subtitle="Live overview of warehouse capacity and inventory lifecycle."
|
||||
/>
|
||||
|
||||
<Stack gap="lg" mt="sm">
|
||||
<WarehouseHero
|
||||
variant="train"
|
||||
secondaryVariant="warehouse"
|
||||
title="Warehouse Dashboard"
|
||||
subtitle="Live overview of warehouse capacity and inventory lifecycle."
|
||||
/>
|
||||
{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>
|
||||
|
||||
{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>
|
||||
<WarehouseDashboardCharts data={data} />
|
||||
</>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,13 +6,11 @@ import {
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Select,
|
||||
Stack,
|
||||
Tabs,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
@@ -20,6 +18,8 @@ import { Info, Pencil, Plus, Trash2 } from 'lucide-react';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import {
|
||||
@@ -209,6 +209,48 @@ function AllocationRules() {
|
||||
}
|
||||
};
|
||||
|
||||
const allocationColumns: ColumnDef<AllocationRule>[] = [
|
||||
{ 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 ?? dash },
|
||||
{ id: 'trade', header: 'Trade', cell: ({ row }) => row.original.tradeDirection ?? dash },
|
||||
{ id: 'cargoCode', header: 'Cargo code', cell: ({ row }) => row.original.cargoTypeCode ?? dash },
|
||||
{
|
||||
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: '',
|
||||
meta: { headerClassName: 'text-right', cellClassName: 'text-right' },
|
||||
cell: ({ row }) => (
|
||||
<Group gap={4} justify="flex-end" wrap="nowrap">
|
||||
<ActionIcon variant="subtle" color="blue" onClick={() => startEdit(row.original)} title="Edit">
|
||||
<Pencil size={16} />
|
||||
</ActionIcon>
|
||||
<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">
|
||||
@@ -226,62 +268,13 @@ function AllocationRules() {
|
||||
</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">
|
||||
<Group gap={4} justify="flex-end" wrap="nowrap">
|
||||
<ActionIcon variant="subtle" color="blue" onClick={() => startEdit(rule)} title="Edit">
|
||||
<Pencil size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => remove.mutate(rule.id)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
<DataTable
|
||||
columns={allocationColumns}
|
||||
data={rules}
|
||||
status={isLoading ? 'loading' : 'success'}
|
||||
emptyMessage="No allocation rules yet. Create one to route inventory to a yard automatically."
|
||||
containerClassName="border-0 shadow-none"
|
||||
/>
|
||||
|
||||
<Modal opened={open} onClose={() => { setOpen(false); resetForm(); }} title={editingId ? 'Edit allocation rule' : 'New allocation rule'} centered size="lg">
|
||||
<Stack gap="md">
|
||||
@@ -576,6 +569,75 @@ function FeeRules() {
|
||||
}
|
||||
};
|
||||
|
||||
const feeColumns: ColumnDef<FeeRule>[] = [
|
||||
{
|
||||
id: 'type',
|
||||
header: 'Type',
|
||||
cell: ({ row }) => (
|
||||
<Badge color={RULE_TYPE_COLOR[row.original.ruleType] ?? 'gray'} variant="light">
|
||||
{FEE_RULE_TYPE_LABELS[row.original.ruleType] ?? row.original.ruleType}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{ id: 'name', header: 'Name', cell: ({ row }) => row.original.name },
|
||||
{ id: 'freight', header: 'Freight', cell: ({ row }) => row.original.freightType ?? dash },
|
||||
{ id: 'trade', header: 'Trade', cell: ({ row }) => row.original.tradeDirection ?? dash },
|
||||
{ id: 'cargo', header: 'Cargo', cell: ({ row }) => row.original.cargoTypeCode ?? dash },
|
||||
{ id: 'container', header: 'Container', cell: ({ row }) => row.original.containerType ?? dash },
|
||||
{
|
||||
id: 'scope',
|
||||
header: 'Location scope',
|
||||
cell: ({ row }) => {
|
||||
const rule = row.original;
|
||||
const hasScope = [rule.facilityId, rule.warehouseId, rule.yardId, rule.zoneId].some(Boolean);
|
||||
if (!hasScope) return dash;
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
{rule.facilityId && <Text size="xs">Facility: {rule.facilityId}</Text>}
|
||||
{rule.warehouseId && <Text size="xs">Warehouse: {rule.warehouseId}</Text>}
|
||||
{rule.yardId && <Text size="xs">Yard: {rule.yardId}</Text>}
|
||||
{rule.zoneId && <Text size="xs">Zone: {rule.zoneId}</Text>}
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
},
|
||||
{ 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: '',
|
||||
meta: { headerClassName: 'text-right', cellClassName: 'text-right' },
|
||||
cell: ({ row }) => (
|
||||
<Group gap={4} justify="flex-end" wrap="nowrap">
|
||||
<ActionIcon variant="subtle" color="blue" onClick={() => startEdit(row.original)} title="Edit">
|
||||
<Pencil size={16} />
|
||||
</ActionIcon>
|
||||
<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">
|
||||
@@ -587,83 +649,13 @@ function FeeRules() {
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={1100}>
|
||||
<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>Cargo</Table.Th>
|
||||
<Table.Th>Container</Table.Th>
|
||||
<Table.Th>Location scope</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_TYPE_COLOR[rule.ruleType] ?? 'gray'} variant="light">
|
||||
{FEE_RULE_TYPE_LABELS[rule.ruleType] ?? rule.ruleType}
|
||||
</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.cargoTypeCode ?? dash}</Table.Td>
|
||||
<Table.Td>{rule.containerType ?? dash}</Table.Td>
|
||||
<Table.Td>
|
||||
{[rule.facilityId, rule.warehouseId, rule.yardId, rule.zoneId].some(Boolean) ? (
|
||||
<Stack gap={2}>
|
||||
{rule.facilityId && <Text size="xs">Facility: {rule.facilityId}</Text>}
|
||||
{rule.warehouseId && <Text size="xs">Warehouse: {rule.warehouseId}</Text>}
|
||||
{rule.yardId && <Text size="xs">Yard: {rule.yardId}</Text>}
|
||||
{rule.zoneId && <Text size="xs">Zone: {rule.zoneId}</Text>}
|
||||
</Stack>
|
||||
) : (
|
||||
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">
|
||||
<Group gap={4} justify="flex-end" wrap="nowrap">
|
||||
<ActionIcon variant="subtle" color="blue" onClick={() => startEdit(rule)} title="Edit">
|
||||
<Pencil size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => remove.mutate(rule.id)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
<DataTable
|
||||
columns={feeColumns}
|
||||
data={rules}
|
||||
status={isLoading ? 'loading' : 'success'}
|
||||
emptyMessage="No storage or demurrage fee rules yet."
|
||||
containerClassName="border-0 shadow-none"
|
||||
/>
|
||||
|
||||
<Modal opened={open} onClose={() => { setOpen(false); resetForm(); }} title={editingId ? 'Edit fee rule' : 'New fee rule'} centered size="lg">
|
||||
<Stack gap="sm">
|
||||
|
||||
48
apps/edr-freight-web/backoffice/src/services/ai.service.ts
Normal file
48
apps/edr-freight-web/backoffice/src/services/ai.service.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { api as client } from "../auth/http";
|
||||
|
||||
export interface AiExtractedBooking {
|
||||
customerName: string | null;
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
cargoType: string | null;
|
||||
containerType: "20FT" | "40FT" | "BULK" | "RO_RO" | null;
|
||||
quantity: number | null;
|
||||
direction: "IMPORT" | "EXPORT" | null;
|
||||
weightKg: number | null;
|
||||
pickupRequired: boolean | null;
|
||||
deliveryRequired: boolean | null;
|
||||
}
|
||||
|
||||
export interface AiValidationResult {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface AiRecommendation {
|
||||
action: "CREATE_DRAFT_BOOKING" | "REQUEST_MISSING_INFORMATION";
|
||||
message: string;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
export interface AiBookingExtractResult {
|
||||
provider: "mock";
|
||||
extracted: AiExtractedBooking;
|
||||
validation: AiValidationResult;
|
||||
recommendation: AiRecommendation;
|
||||
}
|
||||
|
||||
interface AiBookingExtractResponse {
|
||||
success: boolean;
|
||||
data: AiBookingExtractResult;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export const extractBookingFromText = async (
|
||||
text: string,
|
||||
): Promise<AiBookingExtractResult> => {
|
||||
const response = await client.post<AiBookingExtractResponse>(
|
||||
"/ai/booking/extract",
|
||||
{ text },
|
||||
);
|
||||
return response.data.data;
|
||||
};
|
||||
Reference in New Issue
Block a user