This commit is contained in:
hagiye
2026-06-25 11:00:40 +03:00
parent 503878110f
commit f4a90755c2
42 changed files with 2100 additions and 98 deletions

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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}

View File

@@ -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>