mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
280 lines
9.0 KiB
TypeScript
280 lines
9.0 KiB
TypeScript
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
|
||
* maker–checker 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>
|
||
);
|
||
}
|