mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 16:28:12 +00:00
api
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user