mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 17:38:12 +00:00
api
This commit is contained in:
@@ -64,6 +64,7 @@ import TrainDetailPage from "./pages/trains/TrainDetailPage";
|
||||
import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage";
|
||||
import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage";
|
||||
import ExportDjiboutiUnloadingQueuePage from "./pages/warehouses/ExportDjiboutiUnloadingQueuePage";
|
||||
import InterchangeDocumentsPage from "./pages/warehouses/InterchangeDocumentsPage";
|
||||
import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage";
|
||||
import LoadedInventoryPage from "./pages/warehouses/LoadedInventoryPage";
|
||||
import LoadingQueuePage from "./pages/warehouses/LoadingQueuePage";
|
||||
@@ -230,6 +231,11 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
href: "/dashboard/export-djibouti-unloading",
|
||||
icon: <PackageOpen />,
|
||||
},
|
||||
{
|
||||
label: "Interchange Documents",
|
||||
href: "/dashboard/interchange-documents",
|
||||
icon: <FileText />,
|
||||
},
|
||||
{
|
||||
label: "Inventory Inquiry",
|
||||
href: "/dashboard/inventory-inquiry",
|
||||
@@ -390,6 +396,7 @@ const App = () => {
|
||||
<Route path="loaded-inventory" element={<LoadedInventoryPage />} />
|
||||
<Route path="dispatch-queue" element={<DispatchQueuePage />} />
|
||||
<Route path="export-djibouti-unloading" element={<ExportDjiboutiUnloadingQueuePage />} />
|
||||
<Route path="interchange-documents" element={<InterchangeDocumentsPage />} />
|
||||
<Route path="inventory-inquiry" element={<InventoryInquiryPage />} />
|
||||
<Route path="warehouse-rules" element={<WarehouseRulesPage />} />
|
||||
<Route path="warehouse-fee-invoices" element={<WarehouseInvoicesPage />} />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Badge, Button, Card, Divider, Group, Loader, Modal, Stack, Text } from '@mantine/core';
|
||||
import { useState } from 'react';
|
||||
import { Badge, Button, Card, Divider, Group, Loader, Modal, SegmentedControl, Stack, Text } from '@mantine/core';
|
||||
import { CalendarClock, Coins, DoorOpen, FileText } from 'lucide-react';
|
||||
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
@@ -91,10 +92,11 @@ function Row({ label, value }: { label: string; value: string }) {
|
||||
/** Batch 5 fee preview + Batch 6 invoice generation / gate clearance for an inventory item. */
|
||||
export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModalProps) {
|
||||
const { toast } = useToast();
|
||||
const [billingCurrency, setBillingCurrency] = useState<'ETB' | 'USD'>('USD');
|
||||
const enabledId = opened ? inventoryId ?? undefined : undefined;
|
||||
const { data, isLoading } = useQuery(
|
||||
api.warehouses.feePreview.queryOptions({
|
||||
input: { inventoryId: enabledId ?? '' },
|
||||
input: { inventoryId: enabledId ?? '', billingCurrency },
|
||||
enabled: Boolean(enabledId),
|
||||
}),
|
||||
);
|
||||
@@ -108,21 +110,22 @@ export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModa
|
||||
const gateClear = useMutation(api.warehouses.gateClearance.mutationOptions());
|
||||
|
||||
const activeInvoice = (invoices ?? []).find((i) => i.status !== 'CANCELLED');
|
||||
const totalPreviewAmount = (data ?? []).reduce((sum, fee) => sum + Number(fee.amount || 0), 0);
|
||||
|
||||
const handleGenerate = async (confirmZero = false) => {
|
||||
const handleGenerate = async () => {
|
||||
if (!inventoryId) return;
|
||||
if (totalPreviewAmount <= 0) {
|
||||
toast({
|
||||
title: 'No fee to invoice',
|
||||
description: 'The item is still within the configured free days, or no active fee rule matched it.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const inv = await generate.mutateAsync({ inventoryId, confirmZero });
|
||||
const inv = await generate.mutateAsync({ inventoryId, billingCurrency });
|
||||
toast({ title: 'Invoice generated', description: `${inv.invoiceNumber} - ${money(inv.totalAmount, inv.currency)}` });
|
||||
} catch (error) {
|
||||
const msg = extractErrorMessage(error);
|
||||
if (/no payable warehouse fee/i.test(msg)) {
|
||||
if (window.confirm('No payable warehouse fee found. Create a zero-amount invoice anyway?')) {
|
||||
handleGenerate(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
toast({ variant: 'destructive', title: 'Generate failed', description: msg });
|
||||
toast({ variant: 'destructive', title: 'Generate failed', description: extractErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -172,6 +175,22 @@ export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModa
|
||||
|
||||
<Divider label="Invoice & Release" labelPosition="left" />
|
||||
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="sm" fw={600}>
|
||||
Billing currency
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={billingCurrency}
|
||||
onChange={(value) => setBillingCurrency(value as 'ETB' | 'USD')}
|
||||
data={[
|
||||
{ value: 'USD', label: 'USD' },
|
||||
{ value: 'ETB', label: 'Birr' },
|
||||
]}
|
||||
disabled={Boolean(activeInvoice)}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{activeInvoice ? (
|
||||
<Group justify="space-between">
|
||||
<Group gap="xs">
|
||||
@@ -191,7 +210,7 @@ export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModa
|
||||
color="orange"
|
||||
leftSection={<FileText size={16} />}
|
||||
loading={generate.isPending}
|
||||
onClick={() => handleGenerate(false)}
|
||||
onClick={handleGenerate}
|
||||
>
|
||||
Generate Fee Invoice
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import {
|
||||
Badge,
|
||||
Group,
|
||||
Loader,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
} from '@mantine/core';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { useInterchangeDocument } from '@/hooks/useInterchangeDocuments';
|
||||
import type { InterchangeDocumentStatus } from '@/types/interchangeDocument';
|
||||
import { formatDate, formatNumber } from './options';
|
||||
|
||||
const statusColor: Record<InterchangeDocumentStatus, string> = {
|
||||
DRAFT: 'gray',
|
||||
GENERATED: 'blue',
|
||||
ACKNOWLEDGED: 'green',
|
||||
DISPUTED: 'red',
|
||||
CANCELLED: 'gray',
|
||||
};
|
||||
|
||||
function DetailField({ label, value }: { label: string; value: ReactNode }) {
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{value || '-'}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export function InterchangeDocumentDetailPanel({ id }: { id: string }) {
|
||||
const { data: document, isLoading } = useInterchangeDocument(id);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
if (!document) return null;
|
||||
|
||||
const items = document.items ?? [];
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
|
||||
<DetailField label="Document No" value={document.documentNo} />
|
||||
<DetailField label="Direction" value={document.direction} />
|
||||
<DetailField label="Schedule" value={document.scheduleId?.slice(0, 8)} />
|
||||
<DetailField label="Train No" value={document.trainNo} />
|
||||
<DetailField label="Handover Location" value={document.handoverLocation} />
|
||||
<DetailField label="Handover From" value={document.handoverFrom} />
|
||||
<DetailField label="Handover To" value={document.handoverTo} />
|
||||
<DetailField
|
||||
label="Status"
|
||||
value={
|
||||
<Badge variant="light" color={statusColor[document.status]}>
|
||||
{document.status}
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
<DetailField label="Generated At" value={formatDate(document.generatedAt)} />
|
||||
<DetailField label="Acknowledged At" value={formatDate(document.acknowledgedAt)} />
|
||||
<DetailField label="Customs Ref" value={document.customsReference} />
|
||||
<DetailField label="Manifest Ref" value={document.manifestReference} />
|
||||
</SimpleGrid>
|
||||
|
||||
<Table.ScrollContainer minWidth={980}>
|
||||
<Table striped highlightOnHover verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Booking Reference</Table.Th>
|
||||
<Table.Th>Item Type</Table.Th>
|
||||
<Table.Th>Container Number</Table.Th>
|
||||
<Table.Th>Seal Number</Table.Th>
|
||||
<Table.Th>Cargo Type</Table.Th>
|
||||
<Table.Th>Weight</Table.Th>
|
||||
<Table.Th>Quantity</Table.Th>
|
||||
<Table.Th>Wagon Number</Table.Th>
|
||||
<Table.Th>Condition</Table.Th>
|
||||
<Table.Th>Damage Description</Table.Th>
|
||||
<Table.Th>Remarks</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{items.map((item) => (
|
||||
<Table.Tr key={item.id}>
|
||||
<Table.Td>{item.bookingReference ?? item.bookingId?.slice(0, 8) ?? '-'}</Table.Td>
|
||||
<Table.Td>{item.itemType}</Table.Td>
|
||||
<Table.Td>{item.containerNumber ?? '-'}</Table.Td>
|
||||
<Table.Td>{item.sealNumber ?? '-'}</Table.Td>
|
||||
<Table.Td>{item.cargoType ?? item.cargoDescription ?? '-'}</Table.Td>
|
||||
<Table.Td>{formatNumber(item.weight)}</Table.Td>
|
||||
<Table.Td>{formatNumber(item.quantity)}</Table.Td>
|
||||
<Table.Td>{item.wagonNumber ?? '-'}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
variant="light"
|
||||
color={
|
||||
item.conditionStatus === 'GOOD'
|
||||
? 'green'
|
||||
: item.conditionStatus === 'UNKNOWN'
|
||||
? 'gray'
|
||||
: 'orange'
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
{item.conditionStatus}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>{item.damageDescription ?? '-'}</Table.Td>
|
||||
<Table.Td>{item.remarks ?? '-'}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { api } from '@/services/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { lastMileService } from '@/services/last-mile.service';
|
||||
import { warehouseService } from '@/services/warehouse.service';
|
||||
import type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse';
|
||||
import { DeliverInventoryModal } from './DeliverInventoryModal';
|
||||
@@ -50,6 +51,9 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
||||
api.warehouses.markReadyForPickup.mutationOptions(),
|
||||
);
|
||||
const dispatchMutation = useMutation(api.warehouses.dispatch.mutationOptions());
|
||||
const lastMileMutation = useMutation({
|
||||
mutationFn: (bookingReference: string) => lastMileService.accept(bookingReference).then((r) => r.data),
|
||||
});
|
||||
const inspectMutation = useMutation(
|
||||
api.warehouses.bulkMarkInspected.mutationOptions(),
|
||||
);
|
||||
@@ -116,6 +120,34 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
||||
}
|
||||
};
|
||||
|
||||
const acceptLastMile = async (item: WarehouseInventoryItem) => {
|
||||
const reference = item.booking?.reference;
|
||||
if (!reference) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Last mile failed',
|
||||
description: 'Booking reference is missing for this inventory item.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
setBusyId(item.id);
|
||||
try {
|
||||
const record = await lastMileMutation.mutateAsync(reference);
|
||||
toast({
|
||||
title: 'Last mile accepted',
|
||||
description: `${reference} moved to last-mile queue (${record.status.replace(/_/g, ' ')}).`,
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Last mile failed',
|
||||
description: extractErrorMessage(error),
|
||||
});
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const storeInventory = async (item: WarehouseInventoryItem) => {
|
||||
setBusyId(item.id);
|
||||
try {
|
||||
@@ -195,7 +227,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
||||
onInspect={setInspectItem}
|
||||
onFeePreview={setFeeItem}
|
||||
onReleaseDocument={downloadReleaseDocument}
|
||||
onLastMile={onLastMile}
|
||||
onLastMile={onLastMile ? acceptLastMile : undefined}
|
||||
selectedIds={selected}
|
||||
onToggleSelect={toggleSelect}
|
||||
onToggleSelectAll={toggleSelectAll}
|
||||
|
||||
@@ -212,11 +212,7 @@ export function WarehouseInventoryTable({
|
||||
)}
|
||||
{onReleaseDocument && item.releaseDate && (
|
||||
<Tooltip label="View release exit paper" withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="orange"
|
||||
onClick={() => onReleaseDocument(item)}
|
||||
>
|
||||
<ActionIcon variant="subtle" color="orange" onClick={() => onReleaseDocument(item)}>
|
||||
<FileText size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
@@ -366,6 +366,15 @@ export const URL_CONSTANTS = {
|
||||
GATE_CLEARANCE: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/gate-clearance`,
|
||||
},
|
||||
|
||||
INTERCHANGE_DOCUMENTS: {
|
||||
BASE: '/interchange-documents',
|
||||
BY_ID: (id: string) => `/interchange-documents/${id}`,
|
||||
GENERATE_FROM_SCHEDULE: '/interchange-documents/generate-from-schedule',
|
||||
ACKNOWLEDGE: (id: string) => `/interchange-documents/${id}/acknowledge`,
|
||||
DISPUTE: (id: string) => `/interchange-documents/${id}/dispute`,
|
||||
CANCEL: (id: string) => `/interchange-documents/${id}/cancel`,
|
||||
},
|
||||
|
||||
VEHICLES: {
|
||||
BASE: '/vehicles',
|
||||
BY_ID: (id: string) => `/vehicles/${id}`,
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
// export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
|
||||
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
|
||||
|
||||
export const API_BASE_URL = 'http://localhost:3001';
|
||||
// export const API_BASE_URL = 'http://localhost:3001';
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { interchangeDocumentsService } from '@/services/interchange-documents.service';
|
||||
import type {
|
||||
GenerateInterchangeDocumentPayload,
|
||||
InterchangeDocumentFilter,
|
||||
} from '@/types/interchangeDocument';
|
||||
|
||||
export const interchangeDocumentKeys = {
|
||||
all: ['interchange-documents'] as const,
|
||||
list: (filter?: InterchangeDocumentFilter) =>
|
||||
['interchange-documents', 'list', filter ?? {}] as const,
|
||||
detail: (id?: string) => ['interchange-documents', 'detail', id ?? ''] as const,
|
||||
};
|
||||
|
||||
export function useInterchangeDocuments(filter?: InterchangeDocumentFilter, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: interchangeDocumentKeys.list(filter),
|
||||
queryFn: () => interchangeDocumentsService.list(filter).then((r) => r.data),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useInterchangeDocument(id?: string) {
|
||||
return useQuery({
|
||||
queryKey: interchangeDocumentKeys.detail(id),
|
||||
queryFn: () => interchangeDocumentsService.getById(id as string).then((r) => r.data),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
}
|
||||
|
||||
function useInterchangeInvalidation() {
|
||||
const qc = useQueryClient();
|
||||
return () => {
|
||||
qc.invalidateQueries({ queryKey: interchangeDocumentKeys.all });
|
||||
qc.invalidateQueries({ queryKey: ['warehouse-inventory', 'export-djibouti-arrival-queue'] });
|
||||
};
|
||||
}
|
||||
|
||||
export function useGenerateInterchangeDocument() {
|
||||
const onSuccess = useInterchangeInvalidation();
|
||||
return useMutation({
|
||||
mutationFn: (payload: GenerateInterchangeDocumentPayload) =>
|
||||
interchangeDocumentsService.generateFromSchedule(payload),
|
||||
onSuccess,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAcknowledgeInterchangeDocument() {
|
||||
const onSuccess = useInterchangeInvalidation();
|
||||
return useMutation({
|
||||
mutationFn: (args: { id: string; acknowledgedBy: string; remarks?: string }) =>
|
||||
interchangeDocumentsService.acknowledge(args.id, {
|
||||
acknowledgedBy: args.acknowledgedBy,
|
||||
remarks: args.remarks,
|
||||
}),
|
||||
onSuccess,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDisputeInterchangeDocument() {
|
||||
const onSuccess = useInterchangeInvalidation();
|
||||
return useMutation({
|
||||
mutationFn: (args: { id: string; remarks: string }) =>
|
||||
interchangeDocumentsService.dispute(args.id, { remarks: args.remarks }),
|
||||
onSuccess,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCancelInterchangeDocument() {
|
||||
const onSuccess = useInterchangeInvalidation();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => interchangeDocumentsService.cancel(id),
|
||||
onSuccess,
|
||||
});
|
||||
}
|
||||
@@ -301,8 +301,18 @@ export function useExportDjiboutiTrainItems(scheduleId?: string) {
|
||||
}
|
||||
|
||||
/** Unload eligible export items assigned to an arrived Djibouti-side train. */
|
||||
export const useAutoUnloadExportAtDjibouti = () =>
|
||||
useInventoryMutation((scheduleId: string) => warehouseService.autoUnloadExportAtDjibouti(scheduleId));
|
||||
export const useAutoUnloadExportAtDjibouti = () => {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (scheduleId: string) => warehouseService.autoUnloadExportAtDjibouti(scheduleId),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
|
||||
qc.invalidateQueries({ queryKey: ['warehouse-loadings'] });
|
||||
qc.invalidateQueries({ queryKey: warehouseKeys.all });
|
||||
qc.invalidateQueries({ queryKey: ['interchange-documents'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/** IMPORT inventory in the Unloaded Queue (UNLOADED / destination inspection). Read-only. */
|
||||
export function useImportUnloadedQueue(enabled = true) {
|
||||
@@ -490,10 +500,10 @@ export const useUpdateFeeRule = () =>
|
||||
export const useDeleteFeeRule = () =>
|
||||
useRuleMutation((id: string) => warehouseService.deleteFeeRule(id), ['warehouse-fee-rules']);
|
||||
|
||||
export function useFeePreview(inventoryId?: string) {
|
||||
export function useFeePreview(inventoryId?: string, billingCurrency: 'ETB' | 'USD' = 'USD') {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-inventory', inventoryId, 'fee-preview'],
|
||||
queryFn: () => warehouseService.feePreview(inventoryId as string).then((r) => r.data),
|
||||
queryKey: ['warehouse-inventory', inventoryId, 'fee-preview', billingCurrency],
|
||||
queryFn: () => warehouseService.feePreview(inventoryId as string, billingCurrency).then((r) => r.data),
|
||||
enabled: Boolean(inventoryId),
|
||||
});
|
||||
}
|
||||
@@ -534,8 +544,15 @@ function useInvoiceInvalidation() {
|
||||
export function useGenerateInvoice() {
|
||||
const onSuccess = useInvoiceInvalidation();
|
||||
return useMutation({
|
||||
mutationFn: ({ inventoryId, confirmZero }: { inventoryId: string; confirmZero?: boolean }) =>
|
||||
warehouseService.generateInvoice(inventoryId, confirmZero).then((r) => r.data),
|
||||
mutationFn: ({
|
||||
inventoryId,
|
||||
confirmZero,
|
||||
billingCurrency,
|
||||
}: {
|
||||
inventoryId: string;
|
||||
confirmZero?: boolean;
|
||||
billingCurrency?: 'ETB' | 'USD';
|
||||
}) => warehouseService.generateInvoice(inventoryId, confirmZero, billingCurrency).then((r) => r.data),
|
||||
onSuccess,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
} from '@mantine/core';
|
||||
import { ChevronDown, ChevronRight, PackageOpen, Truck } from 'lucide-react';
|
||||
|
||||
import { PageHeader } from '@/components/page';
|
||||
import Breadcrumbs from '@/components/ui/Breadcrumbs';
|
||||
import {
|
||||
VisualEmptyState,
|
||||
@@ -79,17 +78,13 @@ function ImportTrainDetailRows({ scheduleId }: { scheduleId: string }) {
|
||||
{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>{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"
|
||||
>
|
||||
<Badge variant="light" color={item.currentStatus === 'UNLOADED' ? 'green' : 'orange'} size="sm">
|
||||
{item.currentStatus ?? 'PENDING'}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
@@ -112,9 +107,7 @@ export default function ArrivalQueuePage() {
|
||||
const unloadTrain = async (train: ImportTrain) => {
|
||||
setBusyScheduleId(train.scheduleId);
|
||||
try {
|
||||
const res = (await autoUnload.mutateAsync(train.scheduleId)) as {
|
||||
data: AutoUnloadArrivedResult;
|
||||
};
|
||||
const res = (await autoUnload.mutateAsync(train.scheduleId)) as { data: AutoUnloadArrivedResult };
|
||||
const result = res.data;
|
||||
const details = [
|
||||
result.skippedCount ? `${result.skippedCount} skipped` : '',
|
||||
@@ -143,11 +136,6 @@ export default function ArrivalQueuePage() {
|
||||
<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"
|
||||
@@ -199,16 +187,16 @@ export default function ArrivalQueuePage() {
|
||||
<Table.Td>
|
||||
<Stack gap={0}>
|
||||
<Text size="sm" fw={700}>
|
||||
{train.trainNumber ?? '-'}
|
||||
{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>{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>
|
||||
@@ -225,9 +213,7 @@ export default function ArrivalQueuePage() {
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={
|
||||
isOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />
|
||||
}
|
||||
leftSection={isOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
onClick={() => setOpenScheduleId(isOpen ? null : train.scheduleId)}
|
||||
>
|
||||
Open
|
||||
@@ -235,13 +221,7 @@ export default function ArrivalQueuePage() {
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="orange"
|
||||
leftSection={
|
||||
busyScheduleId === train.scheduleId ? (
|
||||
<PackageOpen size={14} />
|
||||
) : (
|
||||
<Truck size={14} />
|
||||
)
|
||||
}
|
||||
leftSection={busyScheduleId === train.scheduleId ? <PackageOpen size={14} /> : <Truck size={14} />}
|
||||
loading={busyScheduleId === train.scheduleId}
|
||||
onClick={() => unloadTrain(train)}
|
||||
>
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
Text,
|
||||
} from '@mantine/core';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ChevronDown, ChevronRight, Eye, History, PackageOpen, Truck } from 'lucide-react';
|
||||
import { ChevronDown, ChevronRight, Eye, FileText, History, PackageOpen, Truck } from 'lucide-react';
|
||||
|
||||
import { PageHeader } from '@/components/page';
|
||||
import Breadcrumbs from '@/components/ui/Breadcrumbs';
|
||||
@@ -30,6 +30,10 @@ import {
|
||||
useExportDjiboutiArrivalQueue,
|
||||
useExportDjiboutiTrainItems,
|
||||
} from '@/hooks/useWarehouses';
|
||||
import {
|
||||
useGenerateInterchangeDocument,
|
||||
useInterchangeDocuments,
|
||||
} from '@/hooks/useInterchangeDocuments';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import type {
|
||||
AutoUnloadExportDjiboutiResult,
|
||||
@@ -162,13 +166,22 @@ function ExportTrainDetailRows({
|
||||
}
|
||||
|
||||
export default function ExportDjiboutiUnloadingQueuePage() {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const { data: trains = [], isLoading } = useExportDjiboutiArrivalQueue();
|
||||
const { data: interchangeDocuments = [] } = useInterchangeDocuments({ direction: 'EXPORT' });
|
||||
const autoUnload = useAutoUnloadExportAtDjibouti();
|
||||
const generateInterchange = useGenerateInterchangeDocument();
|
||||
const [openScheduleId, setOpenScheduleId] = useState<string | null>(null);
|
||||
const [busyScheduleId, setBusyScheduleId] = useState<string | null>(null);
|
||||
const [historyInventoryId, setHistoryInventoryId] = useState<string | null>(null);
|
||||
|
||||
const interchangeBySchedule = new Map(
|
||||
interchangeDocuments
|
||||
.filter((doc) => doc.scheduleId && doc.status !== 'CANCELLED')
|
||||
.map((doc) => [doc.scheduleId as string, doc]),
|
||||
);
|
||||
|
||||
const unloadTrain = async (train: ExportTrain) => {
|
||||
setBusyScheduleId(train.scheduleId);
|
||||
try {
|
||||
@@ -179,6 +192,9 @@ export default function ExportDjiboutiUnloadingQueuePage() {
|
||||
const details = [
|
||||
result.skippedCount ? `${result.skippedCount} skipped` : '',
|
||||
result.failedCount ? `${result.failedCount} failed` : '',
|
||||
result.interchangeDocument
|
||||
? `Interchange document ${result.interchangeDocument.documentNo} generated`
|
||||
: '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
@@ -198,6 +214,34 @@ export default function ExportDjiboutiUnloadingQueuePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const generateInterchangeDocument = async (train: ExportTrain) => {
|
||||
setBusyScheduleId(train.scheduleId);
|
||||
try {
|
||||
const res = await generateInterchange.mutateAsync({
|
||||
scheduleId: train.scheduleId,
|
||||
direction: 'EXPORT',
|
||||
handoverLocation: train.destination ?? 'Djibouti Port',
|
||||
handoverFrom: 'EDR',
|
||||
handoverTo: 'Djibouti Port Operator',
|
||||
portOperatorName: 'Doraleh Multipurpose Port',
|
||||
remarks: 'Generated after export unloading at Djibouti Port',
|
||||
});
|
||||
toast({
|
||||
title: 'Interchange document generated',
|
||||
description: res.data.documentNo,
|
||||
});
|
||||
navigate('/dashboard/interchange-documents');
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Interchange document generation failed',
|
||||
description: getErrorMessage(error),
|
||||
});
|
||||
} finally {
|
||||
setBusyScheduleId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container size="xxl" py="lg">
|
||||
<Breadcrumbs items={[{ label: 'Djibouti Arrival / Unloading Queue' }]} />
|
||||
@@ -255,6 +299,7 @@ export default function ExportDjiboutiUnloadingQueuePage() {
|
||||
<Table.Tbody>
|
||||
{trains.map((train: ExportTrain) => {
|
||||
const isOpen = openScheduleId === train.scheduleId;
|
||||
const interchangeDocument = interchangeBySchedule.get(train.scheduleId);
|
||||
return (
|
||||
<Fragment key={train.scheduleId}>
|
||||
<Table.Tr>
|
||||
@@ -304,6 +349,28 @@ export default function ExportDjiboutiUnloadingQueuePage() {
|
||||
>
|
||||
Auto Unload Export Items
|
||||
</Button>
|
||||
{interchangeDocument ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="blue"
|
||||
leftSection={<Eye size={14} />}
|
||||
onClick={() => navigate('/dashboard/interchange-documents')}
|
||||
>
|
||||
View Document
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="green"
|
||||
leftSection={<FileText size={14} />}
|
||||
loading={busyScheduleId === train.scheduleId && generateInterchange.isPending}
|
||||
onClick={() => generateInterchangeDocument(train)}
|
||||
>
|
||||
Generate Interchange Document
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import { CheckCircle2, Eye, FileText, Search, XCircle } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
import { VisualEmptyState, formatDate, formatNumber } from '@/components/warehouses';
|
||||
import {
|
||||
useAcknowledgeInterchangeDocument,
|
||||
useCancelInterchangeDocument,
|
||||
useDisputeInterchangeDocument,
|
||||
useInterchangeDocument,
|
||||
useInterchangeDocuments,
|
||||
} from '@/hooks/useInterchangeDocuments';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import type { InterchangeDocument, InterchangeDocumentStatus } from '@/types/interchangeDocument';
|
||||
|
||||
const statusColor: Record<InterchangeDocumentStatus, string> = {
|
||||
DRAFT: 'gray',
|
||||
GENERATED: 'blue',
|
||||
ACKNOWLEDGED: 'green',
|
||||
DISPUTED: 'red',
|
||||
CANCELLED: 'gray',
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
function DetailField({ label, value }: { label: string; value: ReactNode }) {
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{value || '-'}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function InterchangeDocumentDetail({ id }: { id: string }) {
|
||||
const { data: document, isLoading } = useInterchangeDocument(id);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
if (!document) return null;
|
||||
|
||||
const items = document.items ?? [];
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
|
||||
<DetailField label="Document No" value={document.documentNo} />
|
||||
<DetailField label="Direction" value={document.direction} />
|
||||
<DetailField label="Schedule" value={document.scheduleId?.slice(0, 8)} />
|
||||
<DetailField label="Train No" value={document.trainNo} />
|
||||
<DetailField label="Handover Location" value={document.handoverLocation} />
|
||||
<DetailField label="Handover From" value={document.handoverFrom} />
|
||||
<DetailField label="Handover To" value={document.handoverTo} />
|
||||
<DetailField
|
||||
label="Status"
|
||||
value={
|
||||
<Badge variant="light" color={statusColor[document.status]}>
|
||||
{document.status}
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
<DetailField label="Generated At" value={formatDate(document.generatedAt)} />
|
||||
<DetailField label="Acknowledged At" value={formatDate(document.acknowledgedAt)} />
|
||||
<DetailField label="Customs Ref" value={document.customsReference} />
|
||||
<DetailField label="Manifest Ref" value={document.manifestReference} />
|
||||
</SimpleGrid>
|
||||
|
||||
<Table.ScrollContainer minWidth={980}>
|
||||
<Table striped highlightOnHover verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Booking Reference</Table.Th>
|
||||
<Table.Th>Item Type</Table.Th>
|
||||
<Table.Th>Container Number</Table.Th>
|
||||
<Table.Th>Seal Number</Table.Th>
|
||||
<Table.Th>Cargo Type</Table.Th>
|
||||
<Table.Th>Weight</Table.Th>
|
||||
<Table.Th>Quantity</Table.Th>
|
||||
<Table.Th>Wagon Number</Table.Th>
|
||||
<Table.Th>Condition</Table.Th>
|
||||
<Table.Th>Damage Description</Table.Th>
|
||||
<Table.Th>Remarks</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{items.map((item) => (
|
||||
<Table.Tr key={item.id}>
|
||||
<Table.Td>{item.bookingReference ?? item.bookingId?.slice(0, 8) ?? '-'}</Table.Td>
|
||||
<Table.Td>{item.itemType}</Table.Td>
|
||||
<Table.Td>{item.containerNumber ?? '-'}</Table.Td>
|
||||
<Table.Td>{item.sealNumber ?? '-'}</Table.Td>
|
||||
<Table.Td>{item.cargoType ?? item.cargoDescription ?? '-'}</Table.Td>
|
||||
<Table.Td>{formatNumber(item.weight)}</Table.Td>
|
||||
<Table.Td>{formatNumber(item.quantity)}</Table.Td>
|
||||
<Table.Td>{item.wagonNumber ?? '-'}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
variant="light"
|
||||
color={item.conditionStatus === 'GOOD' ? 'green' : item.conditionStatus === 'UNKNOWN' ? 'gray' : 'orange'}
|
||||
size="sm"
|
||||
>
|
||||
{item.conditionStatus}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>{item.damageDescription ?? '-'}</Table.Td>
|
||||
<Table.Td>{item.remarks ?? '-'}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default function InterchangeDocumentsPage() {
|
||||
const { toast } = useToast();
|
||||
const [search, setSearch] = useState('');
|
||||
const [viewId, setViewId] = useState<string | null>(null);
|
||||
const filter = useMemo(() => ({ search: search.trim() || undefined }), [search]);
|
||||
const { data: documents = [], isLoading } = useInterchangeDocuments(filter);
|
||||
const acknowledge = useAcknowledgeInterchangeDocument();
|
||||
const dispute = useDisputeInterchangeDocument();
|
||||
const cancel = useCancelInterchangeDocument();
|
||||
|
||||
const run = async (fn: () => Promise<unknown>, title: string) => {
|
||||
try {
|
||||
await fn();
|
||||
toast({ title });
|
||||
} catch (error) {
|
||||
toast({ variant: 'destructive', title: 'Action failed', description: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
const acknowledgeDocument = (document: InterchangeDocument) => {
|
||||
const acknowledgedBy = window.prompt('Acknowledged by');
|
||||
if (!acknowledgedBy) return;
|
||||
run(
|
||||
() => acknowledge.mutateAsync({ id: document.id, acknowledgedBy }),
|
||||
'Interchange document acknowledged',
|
||||
);
|
||||
};
|
||||
|
||||
const disputeDocument = (document: InterchangeDocument) => {
|
||||
const remarks = window.prompt('Dispute reason');
|
||||
if (!remarks) return;
|
||||
run(() => dispute.mutateAsync({ id: document.id, remarks }), 'Interchange document disputed');
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Interchange Documents"
|
||||
subtitle="Official freight handover documents with booking, container and cargo line items."
|
||||
/>
|
||||
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Text fw={600}>{documents.length} document(s)</Text>
|
||||
<TextInput
|
||||
w={{ base: '100%', sm: 320 }}
|
||||
leftSection={<Search size={16} />}
|
||||
placeholder="Search documents"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.currentTarget.value)}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
) : 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>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>{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 !== '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>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Modal opened={Boolean(viewId)} onClose={() => setViewId(null)} title="Interchange Document" size="90%">
|
||||
{viewId ? <InterchangeDocumentDetail id={viewId} /> : null}
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -749,12 +749,12 @@ export const api = {
|
||||
() => ["warehouse-fee-rules"],
|
||||
),
|
||||
|
||||
feePreview: endpoint<{ inventoryId: string }, FeePreview[]>(
|
||||
feePreview: endpoint<{ inventoryId: string; billingCurrency?: 'ETB' | 'USD' }, FeePreview[]>(
|
||||
"warehouse-inventory",
|
||||
"fee-preview",
|
||||
({ inventoryId }) =>
|
||||
warehouseService.feePreview(inventoryId).then((r) => r.data),
|
||||
({ inventoryId }) => ["warehouse-inventory", inventoryId, "fee-preview"],
|
||||
({ inventoryId, billingCurrency }) =>
|
||||
warehouseService.feePreview(inventoryId, billingCurrency).then((r) => r.data),
|
||||
({ inventoryId, billingCurrency }) => ["warehouse-inventory", inventoryId, "fee-preview", billingCurrency ?? 'USD'],
|
||||
),
|
||||
|
||||
invoices: endpoint<{ filter?: WarehouseInvoiceFilter }, WarehouseFeeInvoice[]>(
|
||||
@@ -1045,14 +1045,14 @@ export const api = {
|
||||
|
||||
// ── Invoices ───────────────────────────────────────────────────────────
|
||||
generateInvoice: endpoint<
|
||||
{ inventoryId: string; confirmZero?: boolean },
|
||||
{ inventoryId: string; confirmZero?: boolean; billingCurrency?: 'ETB' | 'USD' },
|
||||
WarehouseFeeInvoice
|
||||
>(
|
||||
"warehouse-fee-invoices",
|
||||
"generate",
|
||||
({ inventoryId, confirmZero }) =>
|
||||
({ inventoryId, confirmZero, billingCurrency }) =>
|
||||
warehouseService
|
||||
.generateInvoice(inventoryId, confirmZero)
|
||||
.generateInvoice(inventoryId, confirmZero, billingCurrency)
|
||||
.then((r) => r.data),
|
||||
undefined,
|
||||
() => [["warehouse-fee-invoices"], ["warehouse-inventory"]],
|
||||
@@ -1980,4 +1980,4 @@ export const api = {
|
||||
({ range }) => overviewService.getDashboard(range),
|
||||
),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { api as apiClient } from '../auth/http';
|
||||
|
||||
import { URL_CONSTANTS } from '@/constants/URLS';
|
||||
import type {
|
||||
GenerateInterchangeDocumentPayload,
|
||||
InterchangeDocument,
|
||||
InterchangeDocumentFilter,
|
||||
} from '@/types/interchangeDocument';
|
||||
|
||||
const cleanParams = (params: object) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(params).filter(([, value]) => value !== undefined && value !== '' && value !== null),
|
||||
);
|
||||
|
||||
export const interchangeDocumentsService = {
|
||||
list: (filter?: InterchangeDocumentFilter) =>
|
||||
apiClient.get<InterchangeDocument[]>(URL_CONSTANTS.INTERCHANGE_DOCUMENTS.BASE, {
|
||||
params: cleanParams(filter ?? {}),
|
||||
}),
|
||||
getById: (id: string) =>
|
||||
apiClient.get<InterchangeDocument>(URL_CONSTANTS.INTERCHANGE_DOCUMENTS.BY_ID(id)),
|
||||
generateFromSchedule: (payload: GenerateInterchangeDocumentPayload) =>
|
||||
apiClient.post<InterchangeDocument>(
|
||||
URL_CONSTANTS.INTERCHANGE_DOCUMENTS.GENERATE_FROM_SCHEDULE,
|
||||
payload,
|
||||
),
|
||||
acknowledge: (id: string, payload: { acknowledgedBy: string; remarks?: string }) =>
|
||||
apiClient.patch<InterchangeDocument>(URL_CONSTANTS.INTERCHANGE_DOCUMENTS.ACKNOWLEDGE(id), payload),
|
||||
dispute: (id: string, payload: { remarks: string }) =>
|
||||
apiClient.patch<InterchangeDocument>(URL_CONSTANTS.INTERCHANGE_DOCUMENTS.DISPUTE(id), payload),
|
||||
cancel: (id: string) =>
|
||||
apiClient.patch<InterchangeDocument>(URL_CONSTANTS.INTERCHANGE_DOCUMENTS.CANCEL(id), {}),
|
||||
};
|
||||
@@ -63,5 +63,5 @@ export const lastMileService = {
|
||||
update: (id: string, data: { status?: LastMileApiStatus; vehicleId?: string | null }) =>
|
||||
api.patch<LastMileRecord>(LM.BY_ID(id), data),
|
||||
accept: (bookingReference: string) =>
|
||||
api.post<LastMileRecord>(LM.ACCEPT(bookingReference)),
|
||||
api.post<LastMileRecord>(LM.ACCEPT(encodeURIComponent(bookingReference))),
|
||||
};
|
||||
|
||||
@@ -251,8 +251,10 @@ export const warehouseService = {
|
||||
updateFeeRule: (id: string, payload: Partial<SaveFeeRulePayload>) =>
|
||||
apiClient.patch<FeeRule>(URL_CONSTANTS.WAREHOUSE_RULES.FEES_BY_ID(id), payload),
|
||||
deleteFeeRule: (id: string) => apiClient.delete(URL_CONSTANTS.WAREHOUSE_RULES.FEES_BY_ID(id)),
|
||||
feePreview: (inventoryId: string) =>
|
||||
apiClient.get<FeePreview[]>(URL_CONSTANTS.WAREHOUSE_RULES.FEE_PREVIEW(inventoryId)),
|
||||
feePreview: (inventoryId: string, billingCurrency?: 'ETB' | 'USD') =>
|
||||
apiClient.get<FeePreview[]>(URL_CONSTANTS.WAREHOUSE_RULES.FEE_PREVIEW(inventoryId), {
|
||||
params: cleanParams({ billingCurrency }),
|
||||
}),
|
||||
|
||||
// ── Batch 6: Warehouse fee invoices ────────────────────────────────────────
|
||||
listInvoices: (filter?: WarehouseInvoiceFilter) =>
|
||||
@@ -265,8 +267,11 @@ export const warehouseService = {
|
||||
apiClient.get<WarehouseFeeInvoice[]>(URL_CONSTANTS.WAREHOUSE_INVOICES.FOR_INVENTORY(inventoryId)),
|
||||
invoicesForBooking: (bookingId: string) =>
|
||||
apiClient.get<WarehouseFeeInvoice[]>(URL_CONSTANTS.WAREHOUSE_INVOICES.FOR_BOOKING(bookingId)),
|
||||
generateInvoice: (inventoryId: string, confirmZero = false) =>
|
||||
apiClient.post<WarehouseFeeInvoice>(URL_CONSTANTS.WAREHOUSE_INVOICES.GENERATE(inventoryId), { confirmZero }),
|
||||
generateInvoice: (inventoryId: string, confirmZero = false, billingCurrency?: 'ETB' | 'USD') =>
|
||||
apiClient.post<WarehouseFeeInvoice>(URL_CONSTANTS.WAREHOUSE_INVOICES.GENERATE(inventoryId), {
|
||||
confirmZero,
|
||||
billingCurrency,
|
||||
}),
|
||||
cancelInvoice: (id: string) =>
|
||||
apiClient.patch<WarehouseFeeInvoice>(URL_CONSTANTS.WAREHOUSE_INVOICES.CANCEL(id)),
|
||||
payInvoice: (id: string, payload: PayInvoicePayload) =>
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
export type InterchangeDirection = 'IMPORT' | 'EXPORT';
|
||||
export type InterchangeDocumentStatus =
|
||||
| 'DRAFT'
|
||||
| 'GENERATED'
|
||||
| 'ACKNOWLEDGED'
|
||||
| 'DISPUTED'
|
||||
| 'CANCELLED';
|
||||
export type InterchangeItemType = 'CONTAINER' | 'CARGO';
|
||||
export type InterchangeConditionStatus =
|
||||
| 'GOOD'
|
||||
| 'DAMAGED'
|
||||
| 'SHORTAGE'
|
||||
| 'EXCESS'
|
||||
| 'HOLD'
|
||||
| 'UNKNOWN';
|
||||
|
||||
export interface InterchangeDocumentItem {
|
||||
id: string;
|
||||
interchangeDocumentId: string;
|
||||
bookingId: string | null;
|
||||
bookingReference: string | null;
|
||||
itemType: InterchangeItemType;
|
||||
bookingContainerId: string | null;
|
||||
bookingCargoId: string | null;
|
||||
containerNumber: string | null;
|
||||
sealNumber: string | null;
|
||||
cargoId: string | null;
|
||||
cargoType: string | null;
|
||||
cargoDescription: string | null;
|
||||
weight: number | null;
|
||||
quantity: number | null;
|
||||
packageCount: number | null;
|
||||
wagonNumber: string | null;
|
||||
conditionStatus: InterchangeConditionStatus;
|
||||
damageDescription: string | null;
|
||||
remarks: string | null;
|
||||
}
|
||||
|
||||
export interface InterchangeDocument {
|
||||
id: string;
|
||||
documentNo: string;
|
||||
direction: InterchangeDirection;
|
||||
scheduleId: string | null;
|
||||
trainNo: string | null;
|
||||
routeId: string | null;
|
||||
originFacilityId: string | null;
|
||||
destinationFacilityId: string | null;
|
||||
handoverLocation: string;
|
||||
handoverFrom: string;
|
||||
handoverTo: string;
|
||||
operatorName: string | null;
|
||||
portOperatorName: string | null;
|
||||
shippingLineName: string | null;
|
||||
customsReference: string | null;
|
||||
manifestReference: string | null;
|
||||
status: InterchangeDocumentStatus;
|
||||
generatedAt: string | null;
|
||||
acknowledgedAt: string | null;
|
||||
generatedBy: string | null;
|
||||
acknowledgedBy: string | null;
|
||||
remarks: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
items?: InterchangeDocumentItem[];
|
||||
}
|
||||
|
||||
export interface InterchangeDocumentFilter {
|
||||
direction?: InterchangeDirection;
|
||||
status?: InterchangeDocumentStatus;
|
||||
scheduleId?: string;
|
||||
documentNo?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export interface GenerateInterchangeDocumentPayload {
|
||||
scheduleId: string;
|
||||
direction: InterchangeDirection;
|
||||
handoverLocation: string;
|
||||
handoverFrom: string;
|
||||
handoverTo: string;
|
||||
operatorName?: string;
|
||||
portOperatorName?: string;
|
||||
shippingLineName?: string;
|
||||
customsReference?: string;
|
||||
manifestReference?: string;
|
||||
generatedBy?: string;
|
||||
remarks?: string;
|
||||
}
|
||||
@@ -466,6 +466,11 @@ export interface AutoUnloadExportDjiboutiResult {
|
||||
unloadedCount: number;
|
||||
skippedCount: number;
|
||||
failedCount: number;
|
||||
interchangeDocument?: {
|
||||
id: string;
|
||||
documentNo: string;
|
||||
status: string;
|
||||
};
|
||||
results: Array<{
|
||||
bookingId: string;
|
||||
itemType: 'CONTAINER' | 'CARGO';
|
||||
@@ -660,6 +665,8 @@ export interface FeePreview {
|
||||
freeDays: number;
|
||||
ratePerDay: number;
|
||||
currency: string;
|
||||
ruleCurrency?: string | null;
|
||||
billingCurrency?: string;
|
||||
startDate: string | null;
|
||||
endDate: string;
|
||||
endIsOpen: boolean;
|
||||
|
||||
Reference in New Issue
Block a user