Files
edr-platform/apps/edr-freight-web/backoffice/src/pages/shipping-lines/ShippingLineCreditInvoicesPanel.tsx
2026-08-13 18:56:52 +00:00

280 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
Badge,
Box,
Button,
Card,
Group,
Select,
Stack,
Text,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { Calendar, FilterX, RefreshCw, Ship } from "lucide-react";
import { useMemo, useState } from "react";
import { bookingTable } from "@/components/bookings/booking-ui.styles";
import { formatDate, formatMoney } from "@/components/customers";
import CreditInvoiceActions from "@/components/shipping-lines/CreditInvoiceActions";
import { api } from "@/services/api";
import type { CreditInvoice } from "@/types/shippingLineCredit";
import {
DataTable,
DataTableFooter,
usePagination,
type ColumnDef,
} from "@edr/ui-common";
const INVOICE_STATUS_META: Record<string, { label: string; color: string }> = {
DRAFT: { label: "Draft", color: "gray" },
ISSUED: { label: "Issued", color: "orange" },
PENDING: { label: "Pending", color: "orange" },
PAYMENT_PROCESSING: { label: "Processing", color: "blue" },
PARTIALLY_PAID: { label: "Partially paid", color: "yellow" },
PAID: { label: "Paid", color: "green" },
OVERDUE: { label: "Overdue", color: "red" },
CANCELLED: { label: "Cancelled", color: "gray" },
REFUNDED: { label: "Refunded", color: "blue" },
EXPIRED: { label: "Expired", color: "red" },
};
const STATUS_OPTIONS = Object.entries(INVOICE_STATUS_META).map(
([value, meta]) => ({ value, label: meta.label }),
);
/**
* Invoices minted from credit batches. The actions column is the shared
* makerchecker component (also embedded on the Finance hub's invoice list):
* finance requests mark-paid / cancel, a chief approves or rejects.
*/
export default function ShippingLineCreditInvoicesPanel() {
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [status, setStatus] = useState<string | null>(null);
const [shippingLineId, setShippingLineId] = useState<string | null>(null);
const { data: companies } = useQuery(
api.shippingLineCompanies.list.queryOptions({
input: { page: 1, limit: 100 },
}),
);
const lineOptions = useMemo(
() =>
(companies?.items ?? []).map((sl) => ({ value: sl.id, label: sl.name })),
[companies],
);
const { data, isLoading, isError, error, refetch, isFetching } = useQuery(
api.shippingLineCredits.listInvoices.queryOptions({
input: {
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
status: status ?? undefined,
shippingLineId: shippingLineId ?? undefined,
},
}),
);
const rows = data?.items ?? [];
const total = data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const activeFilterCount = (shippingLineId ? 1 : 0) + (status ? 1 : 0);
const resetPage = () =>
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
const columns: ColumnDef<CreditInvoice>[] = useMemo(
() => [
{
id: "invoice",
header: () => <span className={bookingTable.headerCell}>Invoice</span>,
cell: ({ row }) => {
const inv = row.original;
return (
<div className="flex items-center gap-3 py-1.5">
<div className={bookingTable.rowIcon}>
<Ship className="size-4" strokeWidth={1.75} />
</div>
<div className="min-w-0">
<p className="truncate font-mono text-sm font-semibold text-foreground">
{inv.invoiceNumber}
</p>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{inv.shippingLineName ?? "—"}
</p>
</div>
</div>
);
},
},
{
id: "issued",
header: () => <span className={bookingTable.headerCell}>Issued</span>,
cell: ({ row }) => (
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
<Calendar className="size-3.5" />
{row.original.issuedAt ? formatDate(row.original.issuedAt) : "—"}
</span>
),
},
{
id: "due",
header: () => <span className={bookingTable.headerCell}>Due</span>,
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">
{row.original.dueAt ? formatDate(row.original.dueAt) : "—"}
</span>
),
},
{
id: "amount",
header: () => <span className={bookingTable.headerCell}>Amount</span>,
cell: ({ row }) => (
<span className="text-sm font-semibold text-foreground">
{formatMoney(
Number(row.original.totalAmount),
row.original.currency,
)}
</span>
),
},
{
id: "balance",
header: () => <span className={bookingTable.headerCell}>Balance</span>,
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">
{formatMoney(
Number(row.original.balanceAmount ?? row.original.totalAmount),
row.original.currency,
)}
</span>
),
},
{
id: "status",
header: () => <span className={bookingTable.headerCell}>Status</span>,
cell: ({ row }) => {
const meta = INVOICE_STATUS_META[row.original.status] ?? {
label: row.original.status,
color: "gray",
};
return (
<Badge variant="light" color={meta.color}>
{meta.label}
</Badge>
);
},
},
{
id: "actions",
header: () => <span className={bookingTable.headerCell}>Actions</span>,
cell: ({ row }) => (
<CreditInvoiceActions
invoice={row.original}
pendingAction={row.original.pendingAction}
/>
),
},
],
[],
);
return (
<Stack gap="lg">
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<Group justify="space-between" gap="md" wrap="wrap">
<Group gap="sm" wrap="wrap">
<Select
placeholder="All shipping lines"
data={lineOptions}
value={shippingLineId}
onChange={(v) => {
setShippingLineId(v);
resetPage();
}}
clearable
searchable
radius="lg"
style={{ minWidth: 220 }}
/>
<Select
placeholder="All statuses"
data={STATUS_OPTIONS}
value={status}
onChange={(v) => {
setStatus(v);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 160 }}
/>
{activeFilterCount > 0 ? (
<Button
variant="subtle"
color="gray"
radius="lg"
leftSection={<FilterX size={16} />}
onClick={() => {
setShippingLineId(null);
setStatus(null);
resetPage();
}}
>
Clear filters ({activeFilterCount})
</Button>
) : null}
</Group>
<Group gap="sm" wrap="nowrap">
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
<Button
variant="default"
size="compact-sm"
leftSection={<RefreshCw size={14} />}
loading={isFetching}
onClick={() => void refetch()}
>
Refresh
</Button>
</Group>
</Group>
</Box>
<Box style={{ overflowX: "auto" }} w="100%">
<DataTable
columns={columns}
data={rows}
status={isLoading ? "loading" : isError ? "error" : "success"}
emptyMessage="No credit invoices yet — generate one from the Credits tab."
error={
isError
? {
message: error?.message ?? "Failed to load invoices.",
onRetry: () => void refetch(),
}
: undefined
}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>
</Box>
</Stack>
</Card>
</Stack>
);
}