mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 20:38:17 +00:00
shipping line
This commit is contained in:
@@ -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 maker–checker 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 maker–checker
|
||||
// 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 (
|
||||
|
||||
@@ -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
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
@@ -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 (maker–checker
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
@@ -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 & issue
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user