Files
edr-platform/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx
2026-07-07 06:25:58 +00:00

255 lines
9.1 KiB
TypeScript

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';
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' },
DOUBLE_HANDLING_FEE: { label: 'Double Handling', color: 'grape' },
TRUCK_DETENTION_FEE: { label: 'Truck Detention Cost', color: 'blue' },
};
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)`} />
{(fee.tiers ?? []).map((tier) => (
<Row
key={`${tier.appliedFromDay}-${tier.appliedToDay}-${tier.ratePerDay}`}
label={`Days ${tier.appliedFromDay}-${tier.appliedToDay}`}
value={`${tier.days} x ${money(tier.ratePerDay, fee.currency)} = ${money(tier.amount, fee.currency)}`}
/>
))}
</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 [billingCurrency, setBillingCurrency] = useState<'ETB' | 'USD'>('USD');
const enabledId = opened ? inventoryId ?? undefined : undefined;
const { data, isLoading } = useQuery(
api.warehouses.feePreview.queryOptions({
input: { inventoryId: enabledId ?? '', billingCurrency },
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 totalPreviewAmount = (data ?? []).reduce((sum, fee) => sum + Number(fee.amount || 0), 0);
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, billingCurrency });
toast({ title: 'Invoice generated', description: `${inv.invoiceNumber} - ${money(inv.totalAmount, inv.currency)}` });
} catch (error) {
toast({ variant: 'destructive', title: 'Generate failed', description: extractErrorMessage(error) });
}
};
const handleGateClearance = async () => {
if (!inventoryId) return;
const pdfWindow = window.open('', '_blank');
try {
const releasedItem = await gateClear.mutateAsync(inventoryId) as WarehouseInventoryItem;
try {
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.',
});
} catch (documentError) {
pdfWindow?.close();
toast({
title: 'Gate clearance recorded',
description: `Release paper could not be opened: ${extractErrorMessage(documentError)}`,
});
}
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 &amp; 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" />
<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">
<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}
>
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>
);
}