shipping line

This commit is contained in:
Marshal
2026-08-13 18:56:52 +00:00
parent 0e00a98ef3
commit b9ba830a09
48 changed files with 6766 additions and 639 deletions

View File

@@ -21,6 +21,7 @@ import {
formatMoney,
humanize,
} from "@/components/customers";
import CreditInvoiceActions from "@/components/shipping-lines/CreditInvoiceActions";
import { api } from "@/services/api";
import type { Invoice } from "@/types/invoice";
import {
@@ -58,6 +59,26 @@ export default function InvoicesPanel() {
const total = data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
// Shipping-line credit invoices carry makerchecker actions (mark paid /
// cancel). One batched lookup fetches the visible rows' pending requests.
const creditInvoiceIds = useMemo(
() =>
rows
.filter((inv) => inv.source === "shipping_line_credit")
.map((inv) => inv.id),
[rows],
);
const { data: pendingActions } = useQuery(
api.shippingLineCredits.pendingInvoiceActions.queryOptions({
input: { invoiceIds: creditInvoiceIds },
enabled: creditInvoiceIds.length > 0,
}),
);
const pendingByInvoice = useMemo(
() => new Map((pendingActions ?? []).map((p) => [p.invoiceId, p])),
[pendingActions],
);
const columns: ColumnDef<Invoice>[] = useMemo(
() => [
{
@@ -122,8 +143,30 @@ export default function InvoicesPanel() {
</Text>
),
},
{
id: "actions",
header: "Actions",
cell: ({ row }) => {
const inv = row.original;
// Only shipping-line credit invoices have manual makerchecker
// actions; every other source settles through its own flow.
if (inv.source !== "shipping_line_credit") {
return (
<Text size="sm" c="dimmed">
</Text>
);
}
return (
<CreditInvoiceActions
invoice={inv}
pendingAction={pendingByInvoice.get(inv.id) ?? null}
/>
);
},
},
],
[],
[pendingByInvoice],
);
return (

View File

@@ -0,0 +1,279 @@
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>
);
}

View File

@@ -0,0 +1,65 @@
import { Stack, Tabs } from "@mantine/core";
import { HandCoins, Receipt } from "lucide-react";
import { useSearchParams } from "react-router-dom";
import { PageContainer, PageHeader } from "@/components/page";
import ShippingLineCreditInvoicesPanel from "./ShippingLineCreditInvoicesPanel";
import ShippingLineCreditsPanel from "./ShippingLineCreditsPanel";
/**
* Finance's view of what shipping lines owe. Two URL-linkable tabs (?tab=,
* FinanceHubPage convention): the credit ledger (select unbilled credits →
* generate an invoice) and the invoices minted from it (makerchecker
* mark-paid / cancel actions).
*/
export default function ShippingLineCreditsPage() {
const [searchParams, setSearchParams] = useSearchParams();
const activeTab =
searchParams.get("tab") === "invoices" ? "invoices" : "credits";
const handleTabChange = (value: string | null) => {
if (!value) return;
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
next.set("tab", value);
return next;
},
{ replace: true },
);
};
return (
<PageContainer>
<Stack gap="lg">
<PageHeader
title="Shipping Line Credits"
subtitle={
activeTab === "invoices"
? "Invoices billed from credit batches. Manual mark-paid / cancel actions need a second approver."
: "What each line owes — outstanding totals and the full credit ledger."
}
/>
<Tabs value={activeTab} onChange={handleTabChange} keepMounted={false}>
<Tabs.List>
<Tabs.Tab value="credits" leftSection={<HandCoins size={16} />}>
Credits
</Tabs.Tab>
<Tabs.Tab value="invoices" leftSection={<Receipt size={16} />}>
Invoices
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="credits" pt="lg">
<ShippingLineCreditsPanel />
</Tabs.Panel>
<Tabs.Panel value="invoices" pt="lg">
<ShippingLineCreditInvoicesPanel />
</Tabs.Panel>
</Tabs>
</Stack>
</PageContainer>
);
}

View File

@@ -0,0 +1,553 @@
import {
Badge,
Box,
Button,
Card,
Checkbox,
Divider,
Group,
Modal,
NumberInput,
Select,
Stack,
Text,
} from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
Calendar,
Clock,
FilterX,
HandCoins,
Receipt,
RefreshCw,
Ship,
} from "lucide-react";
import { useMemo, useState } from "react";
import { useAuth } from "@/auth/useAuth";
import { bookingTable } from "@/components/bookings/booking-ui.styles";
import { formatDate, formatMoney } from "@/components/customers";
import { KpiStrip } from "@/components/page";
import { useToast } from "@/hooks/use-toast";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { api } from "@/services/api";
import type {
ShippingLineCredit,
ShippingLineCreditStatus,
} from "@/types/shippingLineCredit";
import {
DataTable,
DataTableFooter,
usePagination,
type ColumnDef,
} from "@edr/ui-common";
const STATUS_META: Record<
ShippingLineCreditStatus,
{ label: string; color: string }
> = {
UNBILLED: { label: "Unbilled", color: "orange" },
BILLED: { label: "Billed", color: "blue" },
PAID: { label: "Paid", color: "green" },
CANCELLED: { label: "Cancelled", color: "gray" },
};
const STATUS_OPTIONS = Object.entries(STATUS_META).map(([value, meta]) => ({
value,
label: meta.label,
}));
/**
* Every shipping line's credits in one list — finance's landing view, styled
* to match the booking-requests page. Summary cells total the current filter
* scope (all lines by default); the selects narrow both cells and ledger.
*/
export default function ShippingLineCreditsPanel() {
const { user } = useAuth();
const { toast } = useToast();
const [shippingLineId, setShippingLineId] = useState<string | null>(null);
const [status, setStatus] = useState<ShippingLineCreditStatus | null>(null);
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const canInvoice = hasPermission(
user,
FREIGHT_PERMS.shippingLineCredits.invoice,
);
// Selection for batch invoicing, kept as id → credit so it survives page
// changes and can total itself. One invoice has one payer, so everything
// selected must belong to the same shipping line — enforced here so the
// API's rejection is never the first time staff hears about it.
const [selected, setSelected] = useState<Map<string, ShippingLineCredit>>(
new Map(),
);
const [invoiceOpen, setInvoiceOpen] = useState(false);
const [dueInDays, setDueInDays] = useState<number | "">("");
const selectedCredits = useMemo(() => [...selected.values()], [selected]);
const selectedLineId = selectedCredits[0]?.shippingLineCompanyId ?? null;
const selectedTotal = selectedCredits.reduce(
(sum, c) => sum + Number(c.amount),
0,
);
const toggleSelected = (credit: ShippingLineCredit) =>
setSelected((prev) => {
const next = new Map(prev);
if (next.has(credit.id)) next.delete(credit.id);
else next.set(credit.id, credit);
return next;
});
const clearSelection = () => setSelected(new Map());
// ponytail: first 100 lines in the picker; server-side search when a real
// deployment outgrows that.
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.scacCode ? `${sl.name} (${sl.scacCode})` : sl.name,
})),
[companies],
);
const {
data: summary,
isLoading: summaryLoading,
refetch: refetchSummary,
} = useQuery(
api.shippingLineCredits.summary.queryOptions({
input: { shippingLineId: shippingLineId ?? undefined },
}),
);
const {
data: ledger,
isLoading,
isError,
error,
refetch,
isFetching,
} = useQuery(
api.shippingLineCredits.list.queryOptions({
input: {
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
status: status ?? undefined,
shippingLineId: shippingLineId ?? undefined,
},
}),
);
const rows = ledger?.items ?? [];
const total = ledger?.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 clearFilters = () => {
setShippingLineId(null);
setStatus(null);
resetPage();
};
const handleRefresh = () => {
void refetch();
void refetchSummary();
};
const { mutate: generateInvoice, isPending: isInvoicing } = useMutation(
api.shippingLineCredits.generateInvoice.mutationOptions({
onSuccess: (invoice) => {
setInvoiceOpen(false);
clearSelection();
setDueInDays("");
toast({
title: `Invoice ${invoice.invoiceNumber} generated`,
description: `${formatMoney(Number(invoice.totalAmount), invoice.currency)} billed across ${selectedCredits.length} credit${selectedCredits.length === 1 ? "" : "s"}.`,
});
},
onError: (err) => {
toast({
title: "Could not generate invoice",
description: err.message,
variant: "destructive",
});
// A concurrent edit (someone else billed a selected credit) is the
// usual cause — resync so stale rows drop out of the list.
handleRefresh();
},
}),
);
const columns: ColumnDef<ShippingLineCredit>[] = useMemo(
() => [
...(canInvoice
? [
{
id: "select",
size: 40,
header: () => null,
cell: ({ row }: { row: { original: ShippingLineCredit } }) => {
const credit = row.original;
const selectable =
credit.status === "UNBILLED" &&
(selectedLineId === null ||
credit.shippingLineCompanyId === selectedLineId);
return (
<Checkbox
size="sm"
checked={selected.has(credit.id)}
disabled={!selectable}
title={
credit.status !== "UNBILLED"
? "Only unbilled credits can be invoiced"
: !selectable
? "One invoice has one payer — selection already holds another line's credits"
: undefined
}
onChange={() => toggleSelected(credit)}
aria-label="Select credit for invoicing"
/>
);
},
},
]
: []),
{
id: "shippingLine",
header: () => (
<span className={bookingTable.headerCell}>Shipping line</span>
),
cell: ({ row }) => {
const credit = 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-medium text-foreground">
{credit.shippingLineCompany?.name ?? "—"}
</p>
<p className="mt-0.5 truncate font-mono text-xs text-muted-foreground">
{credit.booking?.reference ?? "—"}
</p>
</div>
</div>
);
},
},
{
id: "description",
header: () => (
<span className={bookingTable.headerCell}>Description</span>
),
cell: ({ row }) => (
<span className="block max-w-[16rem] truncate py-1 text-sm text-muted-foreground">
{row.original.description ?? "—"}
</span>
),
},
{
id: "amount",
header: () => <span className={bookingTable.headerCell}>Amount</span>,
cell: ({ row }) => (
<span className="text-sm font-semibold text-foreground">
{formatMoney(Number(row.original.amount), row.original.currency)}
</span>
),
},
{
id: "status",
header: () => <span className={bookingTable.headerCell}>Status</span>,
cell: ({ row }) => {
const meta = STATUS_META[row.original.status];
return (
<Badge variant="light" color={meta.color}>
{meta.label}
</Badge>
);
},
},
{
id: "invoice",
header: () => <span className={bookingTable.headerCell}>Invoice</span>,
cell: ({ row }) => {
const inv = row.original.invoice;
return inv ? (
<span className="truncate font-mono text-xs text-foreground">
{inv.invoiceNumber}
</span>
) : (
<span className="text-xs text-muted-foreground"></span>
);
},
},
{
id: "createdAt",
header: () => <span className={bookingTable.headerCell}>Recorded</span>,
cell: ({ row }) => (
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
<Calendar className="size-3.5" />
{formatDate(row.original.createdAt)}
</span>
),
},
],
// Selection state drives the checkbox column's checked/disabled rendering.
// eslint-disable-next-line react-hooks/exhaustive-deps
[canInvoice, selected, selectedLineId],
);
return (
<Stack gap="lg">
<KpiStrip
loading={summaryLoading}
items={[
{
label: "Total outstanding",
value: summary
? formatMoney(summary.totalOutstanding, summary.currency)
: "—",
hint: "unbilled + billed",
icon: HandCoins,
color: "edr-green",
},
{
label: "Unbilled",
value: summary
? formatMoney(summary.unbilledAmount, summary.currency)
: "—",
hint: summary ? `${summary.unbilledCount} credits` : undefined,
icon: Clock,
color: "yellow",
},
{
label: "Billed",
value: summary
? formatMoney(summary.billedAmount, summary.currency)
: "—",
hint: summary ? `${summary.billedCount} on invoices` : undefined,
icon: Receipt,
color: "blue",
},
]}
/>
<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 as ShippingLineCreditStatus | null) ?? null);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 160 }}
/>
{activeFilterCount > 0 ? (
<Button
variant="subtle"
color="gray"
radius="lg"
leftSection={<FilterX size={16} />}
onClick={clearFilters}
>
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={handleRefresh}
>
Refresh
</Button>
</Group>
</Group>
</Box>
{selectedCredits.length > 0 ? (
<>
<Divider />
<Group
px="md"
py="sm"
justify="space-between"
wrap="wrap"
bg="var(--mantine-color-edr-green-0)"
>
<Text size="sm" fw={600}>
{selectedCredits.length} credit
{selectedCredits.length === 1 ? "" : "s"} selected ·{" "}
{formatMoney(selectedTotal, selectedCredits[0].currency)}
{" — "}
{selectedCredits[0].shippingLineCompany?.name ?? ""}
</Text>
<Group gap="sm">
<Button
variant="subtle"
color="gray"
size="compact-sm"
onClick={clearSelection}
>
Clear selection
</Button>
<Button
size="compact-sm"
leftSection={<Receipt size={14} />}
onClick={() => setInvoiceOpen(true)}
>
Generate invoice
</Button>
</Group>
</Group>
</>
) : null}
<Box style={{ overflowX: "auto" }} w="100%">
<DataTable
columns={columns}
data={rows}
status={isLoading ? "loading" : isError ? "error" : "success"}
emptyMessage="No credits match this filter."
error={
isError
? {
message: error?.message ?? "Failed to load credits.",
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>
<Modal
opened={invoiceOpen}
onClose={() => setInvoiceOpen(false)}
title="Generate invoice"
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
One invoice for{" "}
<Text component="span" fw={600} c="edr-text">
{selectedCredits[0]?.shippingLineCompany?.name ?? "this line"}
</Text>{" "}
billing the selected credits. The line pays it at any CBE channel
there is no payment window.
</Text>
<Stack gap={6}>
{selectedCredits.map((credit) => (
<Group key={credit.id} justify="space-between" wrap="nowrap">
<Text size="sm" truncate>
{credit.booking?.reference ?? credit.description ?? credit.id}
</Text>
<Text size="sm" fw={500} style={{ whiteSpace: "nowrap" }}>
{formatMoney(Number(credit.amount), credit.currency)}
</Text>
</Group>
))}
<Divider my={4} />
<Group justify="space-between">
<Text size="sm" fw={700}>
Total
</Text>
<Text size="sm" fw={700}>
{formatMoney(
selectedTotal,
selectedCredits[0]?.currency ?? "ETB",
)}
</Text>
</Group>
</Stack>
<NumberInput
label="Due in days"
description="Optional — defaults to the standard invoice term."
placeholder="14"
min={1}
value={dueInDays}
onChange={(v) => setDueInDays(typeof v === "number" ? v : "")}
/>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
onClick={() => setInvoiceOpen(false)}
disabled={isInvoicing}
>
Cancel
</Button>
<Button
loading={isInvoicing}
onClick={() =>
generateInvoice({
creditIds: selectedCredits.map((c) => c.id),
...(typeof dueInDays === "number"
? { dueInDays }
: {}),
})
}
>
Generate &amp; issue
</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
}