Files
edr-platform/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx
ghost2023 214f96dbae feat(freight-backoffice): support DJF in booking, contract and warehouse screens
Currency dropdowns/pickers (AdditionalPaymentsTab, ClearanceChargesTab,
PhasedClearanceActionPanel, AdviseDutyCard, ContractRequestsPage,
ruleEngine/resources, WarehouseRulesPage, VehicleDetailPage,
FeePreviewModal) offer DJF alongside ETB/USD; GlCreateBookingForm's
currency selector gets allowDjf next to allowUsd. Narrow 'ETB'|'USD'
type unions widened to include 'DJF' across the warehouse
billingCurrency plumbing (useWarehouses, warehouse.service, api.ts)
and the customer/invoice types.

Ad-hoc money() formatters (BookingTrucksPanel, AccrualDashboard,
ImportTrucksPage, EmptyReturnRequestsPage) and formatMoney call sites
that hardcoded 2 decimals (wagon-cancellation cards, BookingRequestDetailPage,
WagonCancellationsPage, PaymentsPage, WarehouseInvoicesPage) now use
currencyDecimals() from @edr/ui-common so DJF renders with 0 decimals
instead of forced cents. The 3 duplicate overview formatCurrency/
formatAmount helpers (typed 'ETB'|'USD') widen to accept any currency.

Two correctness fixes: OverviewRecentBookingsTable's currency==='USD'
? 'USD' : 'ETB' was mislabeling every non-USD currency as ETB; and
WarehouseInvoicesPage's gateway-method default now routes any
non-ETB currency (not just USD) to WAAFI, so DJF invoices get a
working default instead of TELEBIRR (ETB-only).

Claude-Session: https://claude.ai/code/session_01CZy77vCWhka3pnmVF9NDkL
2026-09-04 11:53:56 +03:00

536 lines
19 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react';
import {
ActionIcon,
Badge,
Button,
Card,
Divider,
Group,
Loader,
Modal,
NumberInput,
Select,
Stack,
Table,
Text,
TextInput,
} from '@mantine/core';
import { Ban, CreditCard, DoorOpen, Download, ExternalLink, Eye, Receipt } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { DataTable, type ColumnDef, currencyDecimals } from '@edr/ui-common';
import { applyClientFilters, FilterBar, useFilters, type FilterDef } from '@/components/filters';
import { PageContainer, PageHeader } from '@/components/page';
import { AccrualDashboard } from '@/components/warehouses';
import { useMutation, useQuery } from '@tanstack/react-query';
import { api } from '@/services/api';
import { bookingsService } from '@/services/bookings.service';
import { warehouseService } from '@/services/warehouse.service';
import { useToast } from '@/hooks/use-toast';
import {
WAREHOUSE_INVOICE_STATUSES,
type WarehouseFeeInvoice,
type WarehouseGatewayPaymentMethod,
type WarehouseInvoiceStatus,
} from '@/types/warehouse';
import { openPdfBlob } from '@/components/warehouses/pdf';
import { buildWarehouseExitPaperPdf } from '@/components/warehouses/warehousePdf';
import { extractErrorMessage } from '@/components/warehouses/options';
import { useAuth } from '@/auth/useAuth';
import { FREIGHT_PERMS, hasPermission } from '@/lib/permissions';
import { formatMoney, humanize } from '@/lib/format';
const STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = {
DRAFT: 'gray',
ISSUED: 'orange',
PAYMENT_PROCESSING: 'indigo',
PARTIALLY_PAID: 'yellow',
PAID: 'edr-green',
CANCELLED: 'gray',
};
const fmt = (n: number, c: string) => formatMoney(n, c, currencyDecimals(c));
const fmtDate = (d?: string | null) => (d ? new Date(d).toLocaleDateString() : '—');
const INVOICE_FILTER_DEFS: FilterDef[] = [
{
key: 'status',
label: 'Status',
type: 'enum',
multiple: false,
options: WAREHOUSE_INVOICE_STATUSES.map((s) => ({ value: s, label: s.replace(/_/g, ' ') })),
},
{ key: 'issuedAt', label: 'Issued', type: 'date' },
];
export default function WarehouseInvoicesPage() {
const [detailId, setDetailId] = useState<string | null>(null);
const controls = useFilters(INVOICE_FILTER_DEFS, { pageSize: 10 });
const status = (controls.values.status?.v[0] as WarehouseInvoiceStatus | undefined) ?? null;
const { data, isLoading } = useQuery(
api.warehouses.invoices.queryOptions({
input: { filter: status ? { status } : undefined },
}),
);
const invoices = data ?? [];
// Endpoint only filters by `status`; search + issued-date range are
// applied client-side (the client bridge — see components/filters/clientFilter.ts).
// Flip to server mode by deleting this call once the endpoint takes more params.
const filteredInvoices = applyClientFilters(
invoices,
INVOICE_FILTER_DEFS,
controls.values,
controls.searchText,
{ searchKeys: ['invoiceNumber', 'bookingReference', 'customerName', 'containerNumber'] },
);
const pagedInvoices = useMemo(
() => filteredInvoices.slice((controls.page - 1) * controls.pageSize, controls.page * controls.pageSize),
[filteredInvoices, controls.page, controls.pageSize],
);
const invoiceColumns: ColumnDef<WarehouseFeeInvoice>[] = [
{
id: 'invoiceNumber',
header: 'Invoice No',
cell: ({ row }) => (
<Text fw={600} size="sm">
{row.original.invoiceNumber}
</Text>
),
},
{ id: 'type', header: 'Type', cell: ({ row }) => humanize(row.original.invoiceType) },
{ id: 'total', header: 'Total', cell: ({ row }) => fmt(row.original.totalAmount, row.original.currency) },
{ id: 'paid', header: 'Paid', cell: ({ row }) => fmt(row.original.paidAmount, row.original.currency) },
{
id: 'balance',
header: 'Balance',
cell: ({ row }) => fmt(row.original.balanceAmount, row.original.currency),
},
{
id: 'status',
header: 'Status',
cell: ({ row }) => (
<Badge variant="light" color={STATUS_COLOR[row.original.status]}>
{row.original.status.replace(/_/g, ' ')}
</Badge>
),
},
{
id: 'issued',
header: 'Issued',
cell: ({ row }) => <Text size="xs">{fmtDate(row.original.issuedAt)}</Text>,
},
{
id: 'actions',
header: '',
cell: ({ row }) => (
<Group justify="flex-end" onClick={(e) => e.stopPropagation()}>
<ActionIcon
variant="subtle"
color="gray"
onClick={() => setDetailId(row.original.id)}
title="View"
>
<Eye size={16} />
</ActionIcon>
</Group>
),
},
];
return (
<PageContainer>
<PageHeader
title="Warehouse Fee Invoices"
subtitle="Demurrage & storage invoices generated from warehouse fee rules."
/>
<Stack gap="xs">
<Text fw={700} size="sm" tt="uppercase" c="dimmed">
Accruing now
</Text>
<AccrualDashboard />
</Stack>
<Card>
<FilterBar
defs={INVOICE_FILTER_DEFS}
controls={controls}
searchPlaceholder="Search invoice no / booking / customer"
viewId="warehouse-invoices"
/>
<DataTable
columns={invoiceColumns}
data={pagedInvoices}
status={isLoading ? 'loading' : 'success'}
emptyMessage="No invoices found."
containerClassName="border-0 shadow-none"
{...controls.tableProps(filteredInvoices.length)}
/>
</Card>
<InvoiceDetailModal id={detailId} onClose={() => setDetailId(null)} />
</PageContainer>
);
}
function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () => void }) {
const { user } = useAuth();
const mayRecordPayment = hasPermission(user, FREIGHT_PERMS.warehouseFeeInvoices.pay);
const canCancelInvoice = hasPermission(user, FREIGHT_PERMS.warehouseFeeInvoices.cancel);
const { toast } = useToast();
const navigate = useNavigate();
const { data: inv, isLoading } = useQuery(
api.warehouses.invoice.queryOptions({
input: { id: id ?? '' },
enabled: Boolean(id),
}),
);
const pay = useMutation(api.warehouses.payInvoice.mutationOptions());
const payOnline = useMutation(api.warehouses.payInvoiceOnline.mutationOptions());
const cancel = useMutation(api.warehouses.cancelInvoice.mutationOptions());
const gateClear = useMutation(api.warehouses.gateClearance.mutationOptions());
const [payAmount, setPayAmount] = useState<number | ''>('');
const [driverName, setDriverName] = useState('');
const [driverPhone, setDriverPhone] = useState('');
const [gatewayMethod, setGatewayMethod] = useState<WarehouseGatewayPaymentMethod>('TELEBIRR');
const [payerAccount, setPayerAccount] = useState('');
const canPay = inv && (inv.status === 'ISSUED' || inv.status === 'PARTIALLY_PAID');
const canGateClear = inv?.status === 'PAID' && Boolean(inv.inventoryId);
useEffect(() => {
// WAAFI settles USD and DJF; TELEBIRR is ETB-only.
setGatewayMethod(inv?.currency !== 'ETB' ? 'WAAFI' : 'TELEBIRR');
setPayerAccount('');
}, [inv?.id, inv?.currency]);
const downloadInvoicePdf = async (invoice: WarehouseFeeInvoice) => {
const pdfWindow = window.open('', '_blank');
try {
const { data } = await warehouseService.downloadInvoiceDocument(invoice.id);
openPdfBlob(data, `warehouse-invoice-${invoice.invoiceNumber}.pdf`, pdfWindow);
} catch (error) {
pdfWindow?.close();
toast({
variant: 'destructive',
title: 'Download failed',
description: extractErrorMessage(error),
});
}
};
const downloadReceiptPdf = async (invoice: WarehouseFeeInvoice) => {
const pdfWindow = window.open('', '_blank');
try {
const { data } = await warehouseService.downloadInvoiceReceipt(invoice.id);
openPdfBlob(data, `warehouse-receipt-${invoice.invoiceNumber}.pdf`, pdfWindow);
} catch (error) {
pdfWindow?.close();
toast({
variant: 'destructive',
title: 'Download failed',
description: extractErrorMessage(error),
});
}
};
const getExitPaperContext = async (invoice: WarehouseFeeInvoice) => {
const [booking, inventoryRows] = await Promise.all([
invoice.bookingId
? bookingsService.getById(invoice.bookingId).catch(() => null)
: Promise.resolve(null),
invoice.bookingId
? warehouseService.listInventory({ bookingId: invoice.bookingId }).then((response) => response.data).catch(() => [])
: Promise.resolve([]),
]);
const inventory = inventoryRows.find((item) => item.id === invoice.inventoryId) ?? inventoryRows[0] ?? undefined;
return { booking, inventory };
};
const handleGateClearance = async (invoice: WarehouseFeeInvoice) => {
if (!invoice.inventoryId) {
toast({
variant: 'destructive',
title: 'Gate clearance failed',
description: 'This invoice is not linked to an inventory item.',
});
return;
}
const pdfWindow = window.open('', '_blank');
try {
const releasedAt = new Date();
const releasedItem = await gateClear.mutateAsync(invoice.inventoryId);
let documentResponse: Awaited<ReturnType<typeof warehouseService.downloadReleaseDocument>>;
try {
documentResponse = await warehouseService.downloadReleaseDocument(invoice.inventoryId);
} catch (documentError) {
const context = await getExitPaperContext(invoice);
const fallbackBlob = buildWarehouseExitPaperPdf({
invoice,
releasedItem,
inventory: context.inventory,
booking: context.booking,
releasedAt,
});
const opened = openPdfBlob(
fallbackBlob,
`release-${releasedItem?.booking?.reference ?? releasedItem?.bookingId ?? invoice.inventoryId}.pdf`,
pdfWindow,
);
toast({
title: 'Gate clearance recorded',
description: opened
? 'The API exit paper failed, so a sealed fallback PDF opened instead.'
: `The API exit paper failed (${extractErrorMessage(documentError)}), so a sealed fallback PDF was downloaded.`,
});
onClose();
return;
}
const filename = `release-${releasedItem?.booking?.reference ?? releasedItem?.bookingId ?? invoice.inventoryId}.pdf`;
const opened = openPdfBlob(documentResponse.data, filename, pdfWindow);
toast({
title: 'Gate clearance recorded',
description: opened
? 'The exit paper opened in a browser tab.'
: 'The browser blocked the preview tab, so the exit paper was downloaded.',
});
onClose();
} catch (e) {
pdfWindow?.close();
toast({
variant: 'destructive',
title: 'Gate clearance failed',
description: extractErrorMessage(e),
});
}
};
const handlePay = async () => {
if (!inv || !payAmount) return;
try {
const paidInvoice = await pay.mutateAsync({
id: inv.id,
payload: {
amount: Number(payAmount),
method: 'MANUAL',
driverName: driverName.trim() || undefined,
driverPhone: driverPhone.trim() || undefined,
},
});
setPayAmount('');
setDriverName('');
setDriverPhone('');
if (paidInvoice.status === 'PAID') {
toast({ title: 'Payment recorded', description: 'Downloading receipt, then generating gate clearance and exit paper.' });
await downloadReceiptPdf(paidInvoice);
await handleGateClearance(paidInvoice);
} else {
toast({ title: 'Payment recorded' });
}
} catch (e) {
toast({ variant: 'destructive', title: 'Payment failed', description: extractErrorMessage(e) });
}
};
const handleOnlinePay = async () => {
if (!inv) return;
try {
const currentUrl = window.location.href;
const result = await payOnline.mutateAsync({
id: inv.id,
payload: {
method: gatewayMethod,
platform: 'web',
payerAccount: payerAccount.trim() || undefined,
returnUrl: currentUrl,
failureUrl: currentUrl,
},
});
const url = result.clientAction?.url;
if (url) {
window.location.href = url;
return;
}
toast({
title: 'Payment initiated',
description: 'No redirect URL was returned by the payment provider.',
});
} catch (e) {
toast({ variant: 'destructive', title: 'Payment failed', description: extractErrorMessage(e) });
}
};
const handleCancel = async () => {
if (!inv) return;
try {
await cancel.mutateAsync(inv.id);
toast({ title: 'Invoice cancelled' });
onClose();
} catch (e) {
toast({ variant: 'destructive', title: 'Cancel failed', description: extractErrorMessage(e) });
}
};
return (
<Modal opened={Boolean(id)} onClose={onClose} title="Fee invoice" centered size="lg">
{isLoading || !inv ? (
<Group justify="center" py="xl"><Loader /></Group>
) : (
<Stack gap="sm">
<Group justify="space-between">
<Text fw={700} size="lg">{inv.invoiceNumber}</Text>
<Badge variant="light" color={STATUS_COLOR[inv.status]} size="lg">{inv.status.replace(/_/g, ' ')}</Badge>
</Group>
<Table withRowBorders={false} verticalSpacing={4}>
<Table.Tbody>
{(inv.items ?? []).map((it) => (
<Table.Tr key={it.id}>
<Table.Td>
<Text size="sm">{it.description}</Text>
<Text size="xs" c="dimmed">{it.feeType.replace(/_/g, ' ')} · {it.chargeableDays ?? 0} day(s) @ {fmt(it.unitRate, it.currency)}</Text>
</Table.Td>
<Table.Td ta="right"><Text fw={600}>{fmt(it.amount, it.currency)}</Text></Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
<Divider />
<Group justify="space-between"><Text size="sm" c="dimmed">Subtotal</Text><Text>{fmt(inv.subtotalAmount, inv.currency)}</Text></Group>
<Group justify="space-between"><Text size="sm" c="dimmed">Tax</Text><Text>{fmt(inv.taxAmount, inv.currency)}</Text></Group>
<Group justify="space-between"><Text fw={700}>Total</Text><Text fw={700}>{fmt(inv.totalAmount, inv.currency)}</Text></Group>
<Group justify="space-between"><Text size="sm" c="dimmed">Paid</Text><Text>{fmt(inv.paidAmount, inv.currency)}</Text></Group>
<Group justify="space-between"><Text fw={600}>Balance</Text><Text fw={600}>{fmt(inv.balanceAmount, inv.currency)}</Text></Group>
{(inv.payments ?? []).length > 0 && (
<>
<Divider label="Payment history" labelPosition="left" />
{(inv.payments ?? []).map((p, i) => (
<Group key={i} justify="space-between">
<Text size="xs" c="dimmed">{fmtDate(p.paidAt)} · {p.method ?? '—'}{p.reference ? ` · ${p.reference}` : ''}</Text>
<Text size="sm">{fmt(p.amount, inv.currency)}</Text>
</Group>
))}
</>
)}
{canPay && (
<>
<Divider label="Online payment" labelPosition="left" />
<Group align="flex-end">
<Select
label="Provider"
value={gatewayMethod}
onChange={(v) => setGatewayMethod((v as WarehouseGatewayPaymentMethod) ?? 'TELEBIRR')}
data={[
{ value: 'TELEBIRR', label: 'Telebirr' },
{ value: 'WAAFI', label: 'Waafi' },
]}
style={{ flex: 1 }}
/>
<TextInput
label="Wallet phone / account"
value={payerAccount}
onChange={(e) => setPayerAccount(e.currentTarget.value)}
placeholder="Optional"
style={{ flex: 1 }}
/>
{mayRecordPayment && (
<Button leftSection={<CreditCard size={16} />} loading={payOnline.isPending} onClick={handleOnlinePay}>
Pay with {gatewayMethod === 'WAAFI' ? 'Waafi' : 'Telebirr'}
</Button>
)}
</Group>
<Divider label="Record manual payment" labelPosition="left" />
<Group align="flex-end">
<NumberInput
label="Amount"
min={0}
value={payAmount}
onChange={(v) => setPayAmount(v === '' ? '' : Number(v))}
style={{ flex: 1 }}
/>
<TextInput
label="Pickup driver"
value={driverName}
onChange={(e) => setDriverName(e.currentTarget.value)}
style={{ flex: 1 }}
/>
<TextInput
label="Driver phone"
value={driverPhone}
onChange={(e) => setDriverPhone(e.currentTarget.value)}
style={{ flex: 1 }}
/>
{mayRecordPayment && (
<Button leftSection={<CreditCard size={16} />} loading={pay.isPending} onClick={handlePay}>
Pay
</Button>
)}
</Group>
</>
)}
<Group justify="flex-end" mt="sm">
{inv.bookingId && (
<Button
variant="subtle"
color="gray"
leftSection={<ExternalLink size={16} />}
onClick={() =>
navigate(
`/dashboard/booking-requests/${inv.bookingId}#warehouse-payments`,
)
}
>
View booking
</Button>
)}
<Button
variant="light"
color="gray"
leftSection={<Download size={16} />}
onClick={() => downloadInvoicePdf(inv)}
>
Invoice PDF
</Button>
{Number(inv.paidAmount) > 0 && (
<Button
variant="light"
color="teal"
leftSection={<Receipt size={16} />}
onClick={() => downloadReceiptPdf(inv)}
>
Receipt PDF
</Button>
)}
{canGateClear && (
<Button
color="edr-green"
leftSection={<DoorOpen size={16} />}
loading={gateClear.isPending}
onClick={() => handleGateClearance(inv)}
>
Gate clearance & exit paper
</Button>
)}
{inv.status !== 'PAID' && inv.status !== 'CANCELLED' && canCancelInvoice && (
<Button variant="light" color="red" leftSection={<Ban size={16} />} loading={cancel.isPending} onClick={handleCancel}>
Cancel invoice
</Button>
)}
</Group>
</Stack>
)}
</Modal>
);
}