mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 05:58:18 +00:00
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
536 lines
19 KiB
TypeScript
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>
|
|
);
|
|
}
|