mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
219 lines
7.6 KiB
TypeScript
219 lines
7.6 KiB
TypeScript
import { Badge, Button, Card, Divider, Group, Loader, Modal, Stack, Text } from '@mantine/core';
|
|
import { CalendarClock, Coins, DoorOpen, FileText } from 'lucide-react';
|
|
|
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
|
|
|
import { api } from '@/services/api';
|
|
import { useToast } from '@/hooks/use-toast';
|
|
import { warehouseService } from '@/services/warehouse.service';
|
|
import { extractErrorMessage } from './options';
|
|
import type { FeePreview, WarehouseInventoryItem, WarehouseInvoiceStatus } from '@/types/warehouse';
|
|
import { openPdfBlob } from './pdf';
|
|
|
|
const INVOICE_STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = {
|
|
DRAFT: 'gray',
|
|
ISSUED: 'orange',
|
|
PARTIALLY_PAID: 'yellow',
|
|
PAID: 'edr-green',
|
|
CANCELLED: 'gray',
|
|
};
|
|
|
|
interface FeePreviewModalProps {
|
|
opened: boolean;
|
|
onClose: () => void;
|
|
inventoryId: string | null;
|
|
}
|
|
|
|
const LABELS: Record<string, { label: string; color: string }> = {
|
|
DEMURRAGE_FEE: { label: 'Demurrage', color: 'orange' },
|
|
STORAGE_FEE: { label: 'Storage', color: 'teal' },
|
|
};
|
|
|
|
function fmtDate(iso: string | null) {
|
|
if (!iso) return '—';
|
|
return new Date(iso).toLocaleDateString();
|
|
}
|
|
|
|
const money = (amount: number, currency: string) =>
|
|
`${Number(amount).toLocaleString()} ${currency === 'ETB' ? 'Birr (ETB)' : currency}`;
|
|
|
|
function FeeCard({ fee }: { fee: FeePreview }) {
|
|
const meta = LABELS[fee.ruleType] ?? { label: fee.ruleType, color: 'gray' };
|
|
const configured = Boolean(fee.ruleId);
|
|
return (
|
|
<Card withBorder radius="md" padding="md" style={{ borderColor: `var(--mantine-color-${meta.color}-3)` }}>
|
|
<Group justify="space-between" mb="xs">
|
|
<Group gap="xs">
|
|
<Coins size={16} />
|
|
<Text fw={700}>{meta.label}</Text>
|
|
{fee.endIsOpen && (
|
|
<Badge size="xs" color={meta.color} variant="light">
|
|
accruing
|
|
</Badge>
|
|
)}
|
|
</Group>
|
|
<Text fw={800} size="lg" c={`${meta.color}.7`}>
|
|
{money(fee.amount, fee.currency)}
|
|
</Text>
|
|
</Group>
|
|
|
|
{!configured ? (
|
|
<Text size="xs" c="dimmed">
|
|
No active {meta.label.toLowerCase()} rule configured — amount shown as 0.
|
|
</Text>
|
|
) : (
|
|
<Stack gap={4}>
|
|
<Row label="Rule" value={fee.ruleName ?? '—'} />
|
|
<Row label="Free days" value={String(fee.freeDays)} />
|
|
<Row label="Rate / day" value={money(fee.ratePerDay, fee.currency)} />
|
|
<Row label="Period" value={`${fmtDate(fee.startDate)} → ${fmtDate(fee.endDate)}${fee.endIsOpen ? ' (today)' : ''}`} />
|
|
<Row label="Elapsed days" value={String(fee.elapsedDays)} />
|
|
<Row label="Chargeable days" value={`${fee.chargeableDays} (after ${fee.freeDays} free)`} />
|
|
<Row label="Containers" value={String(fee.containerCount ?? 1)} />
|
|
<Row label="Billable units" value={`${fee.billableUnits ?? fee.chargeableDays} container-day(s)`} />
|
|
</Stack>
|
|
)}
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function Row({ label, value }: { label: string; value: string }) {
|
|
return (
|
|
<Group justify="space-between" wrap="nowrap">
|
|
<Text size="xs" c="dimmed">
|
|
{label}
|
|
</Text>
|
|
<Text size="sm">{value}</Text>
|
|
</Group>
|
|
);
|
|
}
|
|
|
|
/** 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 enabledId = opened ? inventoryId ?? undefined : undefined;
|
|
const { data, isLoading } = useQuery(
|
|
api.warehouses.feePreview.queryOptions({
|
|
input: { inventoryId: enabledId ?? '' },
|
|
enabled: Boolean(enabledId),
|
|
}),
|
|
);
|
|
const { data: invoices } = useQuery(
|
|
api.warehouses.invoicesForInventory.queryOptions({
|
|
input: { inventoryId: enabledId ?? '' },
|
|
enabled: Boolean(enabledId),
|
|
}),
|
|
);
|
|
const generate = useMutation(api.warehouses.generateInvoice.mutationOptions());
|
|
const gateClear = useMutation(api.warehouses.gateClearance.mutationOptions());
|
|
|
|
const activeInvoice = (invoices ?? []).find((i) => i.status !== 'CANCELLED');
|
|
|
|
const handleGenerate = async (confirmZero = false) => {
|
|
if (!inventoryId) return;
|
|
try {
|
|
const inv = await generate.mutateAsync({ inventoryId, confirmZero });
|
|
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 });
|
|
}
|
|
};
|
|
|
|
const handleGateClearance = async () => {
|
|
if (!inventoryId) return;
|
|
const pdfWindow = window.open('', '_blank');
|
|
try {
|
|
const releasedItem = await gateClear.mutateAsync(inventoryId) as WarehouseInventoryItem;
|
|
const documentResponse = await warehouseService.downloadReleaseDocument(inventoryId);
|
|
const filename = `release-${releasedItem?.booking?.reference ?? releasedItem?.bookingId ?? inventoryId}.pdf`;
|
|
const opened = openPdfBlob(documentResponse.data, filename, pdfWindow);
|
|
toast({
|
|
title: 'Gate clearance recorded',
|
|
description: opened
|
|
? 'The release PDF opened in a browser tab.'
|
|
: 'The browser blocked the preview tab, so the PDF was downloaded.',
|
|
});
|
|
onClose();
|
|
} catch (error) {
|
|
pdfWindow?.close();
|
|
toast({ variant: 'destructive', title: 'Release blocked', description: extractErrorMessage(error) });
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Modal
|
|
opened={opened}
|
|
onClose={onClose}
|
|
title={
|
|
<Group gap="xs">
|
|
<CalendarClock size={18} />
|
|
<Text fw={700}>Storage & Demurrage Preview</Text>
|
|
</Group>
|
|
}
|
|
centered
|
|
size="md"
|
|
>
|
|
{isLoading ? (
|
|
<Group justify="center" py="xl">
|
|
<Loader />
|
|
</Group>
|
|
) : (
|
|
<Stack gap="md">
|
|
{(data ?? []).map((fee) => (
|
|
<FeeCard key={fee.ruleType} fee={fee} />
|
|
))}
|
|
|
|
<Divider label="Invoice & Release" labelPosition="left" />
|
|
|
|
{activeInvoice ? (
|
|
<Group justify="space-between">
|
|
<Group gap="xs">
|
|
<FileText size={16} />
|
|
<Text size="sm" fw={600}>{activeInvoice.invoiceNumber}</Text>
|
|
<Badge variant="light" color={INVOICE_STATUS_COLOR[activeInvoice.status]}>
|
|
{activeInvoice.status.replace(/_/g, ' ')}
|
|
</Badge>
|
|
</Group>
|
|
<Text size="sm">
|
|
{money(Number(activeInvoice.balanceAmount), activeInvoice.currency)} due
|
|
</Text>
|
|
</Group>
|
|
) : (
|
|
<Button
|
|
variant="light"
|
|
color="orange"
|
|
leftSection={<FileText size={16} />}
|
|
loading={generate.isPending}
|
|
onClick={() => handleGenerate(false)}
|
|
>
|
|
Generate Fee Invoice
|
|
</Button>
|
|
)}
|
|
|
|
<Button
|
|
variant="light"
|
|
color="edr-green"
|
|
leftSection={<DoorOpen size={16} />}
|
|
loading={gateClear.isPending}
|
|
onClick={handleGateClearance}
|
|
>
|
|
Gate Clearance / Release
|
|
</Button>
|
|
|
|
<Text size="xs" c="dimmed">
|
|
Charges accrue from arrival until gate clearance / release. Final release is blocked while a
|
|
demurrage/storage invoice is unpaid.
|
|
</Text>
|
|
</Stack>
|
|
)}
|
|
</Modal>
|
|
);
|
|
}
|