diff --git a/src/app/dashboard/invoices/BulkPayAccepted.test.tsx b/src/app/dashboard/invoices/BulkPayAccepted.test.tsx index 9ba9780c..afb4b067 100644 --- a/src/app/dashboard/invoices/BulkPayAccepted.test.tsx +++ b/src/app/dashboard/invoices/BulkPayAccepted.test.tsx @@ -39,9 +39,16 @@ const PREPARED = { }, }; -/** The component takes labelled rows now; tests still think in ids. */ +/** The component takes labelled rows now; tests still think in ids. Each id + * gets its own payee by default, so filtering can be exercised. */ function payable(ids: string[]) { - return ids.map((id, i) => ({ id, label: `Worker ${i} — Gig ${i}`, amountUsd: 50 })); + return ids.map((id, i) => ({ + id, + label: `Worker ${i} — Gig ${i}`, + amountUsd: 50, + payeeId: `worker-${i}`, + payeeName: `Worker ${i}`, + })); } /** Install a fake `window.coinpay`, as the extension would. */ @@ -450,9 +457,9 @@ describe("BulkPayAccepted", () => { // the prepare call — not just the list on screen. installWallet(); const invoices = [ - { id: "inv-big", label: "Big", amountUsd: 90 }, - { id: "inv-small", label: "Small", amountUsd: 1 }, - { id: "inv-mid", label: "Mid", amountUsd: 20 }, + { id: "inv-big", label: "Big", amountUsd: 90, payeeId: "w1", payeeName: "Ada" }, + { id: "inv-small", label: "Small", amountUsd: 1, payeeId: "w2", payeeName: "Grace" }, + { id: "inv-mid", label: "Mid", amountUsd: 20, payeeId: "w1", payeeName: "Ada" }, ]; const fetchMock = vi.fn(async (url: string, init?: RequestInit) => { const ids = JSON.parse(String(init?.body ?? "{}")).invoice_ids as string[]; @@ -508,10 +515,10 @@ describe("BulkPayAccepted", () => { render( @@ -571,3 +578,107 @@ describe("BulkPayAccepted", () => { expect(wallet.payBatch).not.toHaveBeenCalled(); }); }); + +/** + * Paying everyone at once is the default, but settling up with a single agent + * — or in a single coin — should not mean unticking seventy-nine boxes. + */ +describe("BulkPayAccepted — filtering", () => { + const MIXED = [ + { id: "a1", label: "Ada — Fix login", amountUsd: 10, payeeId: "ada", payeeName: "Ada", currency: "usdc_sol", amountCrypto: null }, + { id: "a2", label: "Ada — Ship compiler", amountUsd: 20, payeeId: "ada", payeeName: "Ada", currency: "sol", amountCrypto: null }, + { id: "g1", label: "Grace — Debug", amountUsd: 30, payeeId: "grace", payeeName: "Grace", currency: "usdc_sol", amountCrypto: null }, + ]; + + beforeEach(() => { + mockFetch(); + installWallet(); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + delete (window as any).coinpay; + }); + + it("selects everyone by default, so paying all of it stays one click", () => { + render(); + + expect(screen.getByRole("button", { name: /Pay 3/ })).toBeInTheDocument(); + expect(screen.getByText(/3 of 3 selected/)).toBeInTheDocument(); + }); + + it("narrows the payment to one agent when that agent is picked", () => { + render(); + + fireEvent.change(screen.getByLabelText(/Filter invoices by who gets paid/), { + target: { value: "ada" }, + }); + + // Ada has two invoices worth $30 — Grace's $30 must not be in the run. + expect(screen.getByRole("button", { name: /Pay 2/ })).toBeInTheDocument(); + expect(screen.getByText(/2 of 3 selected/)).toBeInTheDocument(); + }); + + it("narrows to a single coin", () => { + render(); + + fireEvent.change(screen.getByLabelText(/Filter invoices by coin/), { + target: { value: "usdc_sol" }, + }); + + expect(screen.getByRole("button", { name: /Pay 2/ })).toBeInTheDocument(); + }); + + it("combines the two filters", () => { + render(); + + fireEvent.change(screen.getByLabelText(/Filter invoices by who gets paid/), { + target: { value: "ada" }, + }); + fireEvent.change(screen.getByLabelText(/Filter invoices by coin/), { + target: { value: "sol" }, + }); + + // Only Ada's SOL invoice survives both. + expect(screen.getByRole("button", { name: /Pay 1/ })).toBeInTheDocument(); + }); + + it("restores everyone when the filters are cleared", () => { + render(); + + fireEvent.change(screen.getByLabelText(/Filter invoices by who gets paid/), { + target: { value: "ada" }, + }); + fireEvent.click(screen.getByRole("button", { name: /Clear filters/ })); + + expect(screen.getByRole("button", { name: /Pay 3/ })).toBeInTheDocument(); + }); + + it("keeps select-all scoped to the filtered rows, never paying a hidden one", () => { + render(); + + fireEvent.change(screen.getByLabelText(/Filter invoices by who gets paid/), { + target: { value: "ada" }, + }); + // Untick, then re-tick: the toggle must not reach across the filter and + // pull Grace's invoice into a run the payer is not looking at. + fireEvent.click(screen.getByLabelText(/Deselect all invoices/)); + expect(screen.getByText(/0 of 3 selected/)).toBeInTheDocument(); + + fireEvent.click(screen.getByLabelText(/Select all invoices/)); + expect(screen.getByRole("button", { name: /Pay 2/ })).toBeInTheDocument(); + }); + + it("hides the filter bar when there is only one payee and one coin", () => { + render( + + ); + + expect(screen.queryByLabelText(/Filter invoices by who gets paid/)).not.toBeInTheDocument(); + expect(screen.queryByLabelText(/Filter invoices by coin/)).not.toBeInTheDocument(); + }); +}); diff --git a/src/app/dashboard/invoices/BulkPayAccepted.tsx b/src/app/dashboard/invoices/BulkPayAccepted.tsx index b4d7ed80..910f74c2 100644 --- a/src/app/dashboard/invoices/BulkPayAccepted.tsx +++ b/src/app/dashboard/invoices/BulkPayAccepted.tsx @@ -43,6 +43,9 @@ export interface PayableInvoice { /** Who is owed, and for what — enough to recognise a row without opening it. */ label: string; amountUsd: number; + /** Who gets paid. Drives the payee filter — paying one agent at a time. */ + payeeId: string; + payeeName: string; /** CoinPay currency the worker receives in, e.g. `usdc_sol`. */ currency?: string | null; /** @@ -53,6 +56,16 @@ export interface PayableInvoice { amountCrypto?: string | number | null; } +/** + * `usdc_sol` → `USDC · SOL`. Keeps the chain visible, since USDC on two chains + * is not interchangeable and must not collapse into one filter entry. + */ +function coinLabel(currency: string): string { + const [symbol, chain] = currency.split("_"); + const head = (symbol ?? currency).toUpperCase(); + return chain ? `${head} · ${chain.toUpperCase()}` : head; +} + /** * `0.013851 SOL` for a row whose request has been quoted. Returns null when no * quote exists yet — showing a currency with no amount, or an amount derived @@ -105,6 +118,11 @@ export function BulkPayAccepted({ invoices, totalUsd }: Props) { // settles the most invoices that way, and the big ones can wait for a // top-up. This drives the actual payment order, not just the display. const [sortDir, setSortDir] = useState<"asc" | "desc">("asc"); + // Filters, empty meaning unfiltered. The default is everyone and every coin, + // because settling the whole list in one confirmation is the common case; + // narrowing to a single agent (or a single coin) is the exception. + const [payeeFilter, setPayeeFilter] = useState(""); + const [coinFilter, setCoinFilter] = useState(""); const [phase, setPhase] = useState("idle"); const [payments, setPayments] = useState([]); const [skipped, setSkipped] = useState([]); @@ -145,6 +163,46 @@ export function BulkPayAccepted({ invoices, totalUsd }: Props) { [invoices, sortDir] ); + const visibleInvoices = useMemo( + () => + orderedInvoices.filter( + (i) => + (!payeeFilter || i.payeeId === payeeFilter) && + (!coinFilter || i.currency === coinFilter) + ), + [orderedInvoices, payeeFilter, coinFilter] + ); + + // Filter options carry their own count and total, so the payer can see what + // picking one would commit to before picking it. + const payeeOptions = useMemo(() => { + const byId = new Map(); + for (const i of invoices) { + const row = byId.get(i.payeeId) ?? { + id: i.payeeId, + name: i.payeeName, + count: 0, + total: 0, + }; + row.count += 1; + row.total += i.amountUsd; + byId.set(i.payeeId, row); + } + return [...byId.values()].sort((a, b) => a.name.localeCompare(b.name)); + }, [invoices]); + + const coinOptions = useMemo(() => { + const byCode = new Map(); + for (const i of invoices) { + if (!i.currency) continue; + const row = byCode.get(i.currency) ?? { code: i.currency, count: 0, total: 0 }; + row.count += 1; + row.total += i.amountUsd; + byCode.set(i.currency, row); + } + return [...byCode.values()].sort((a, b) => a.code.localeCompare(b.code)); + }, [invoices]); + // Derived from the sorted list, so the order the payer sees is the order the // wallet is handed — pay-cheapest-first only means anything if it reaches // `payBatch` that way. @@ -156,7 +214,11 @@ export function BulkPayAccepted({ invoices, totalUsd }: Props) { () => invoices.filter((i) => selected.has(i.id)).reduce((sum, i) => sum + i.amountUsd, 0), [invoices, selected] ); - const allSelected = selectedIds.length === invoiceIds.length && invoiceIds.length > 0; + // Select-all applies to what is on screen, not to invoices hidden behind a + // filter — ticking a box must never commit money the payer cannot see. + const visibleIds = useMemo(() => visibleInvoices.map((i) => i.id), [visibleInvoices]); + const allSelected = + visibleIds.length > 0 && visibleIds.every((id) => selected.has(id)); const toggleOne = useCallback((id: string) => { setSelected((current) => { @@ -168,10 +230,37 @@ export function BulkPayAccepted({ invoices, totalUsd }: Props) { }, []); const toggleAll = useCallback(() => { - setSelected((current) => - current.size === invoiceIds.length ? new Set() : new Set(invoiceIds) - ); - }, [invoiceIds]); + setSelected((current) => { + const next = new Set(current); + if (visibleIds.every((id) => next.has(id))) { + for (const id of visibleIds) next.delete(id); + } else { + for (const id of visibleIds) next.add(id); + } + return next; + }); + }, [visibleIds]); + + /** + * Changing a filter re-points the selection at exactly what the new filter + * shows. Picking an agent is a statement of intent — "pay this one" — so it + * should take one click, not a filter plus a select-all. Nothing outside the + * new view stays selected, so the summary can never bill for a hidden row. + */ + const applyFilters = useCallback( + (payee: string, coin: string) => { + setPayeeFilter(payee); + setCoinFilter(coin); + setSelected( + new Set( + invoices + .filter((i) => (!payee || i.payeeId === payee) && (!coin || i.currency === coin)) + .map((i) => i.id) + ) + ); + }, + [invoices] + ); /** * Mint payment requests for the given invoices (the current selection by @@ -399,6 +488,60 @@ export function BulkPayAccepted({ invoices, totalUsd }: Props) { of what they owe — or testing with a handful — needs the rows. */} {phase === "idle" && ( + {(payeeOptions.length > 1 || coinOptions.length > 1) && ( + + {payeeOptions.length > 1 && ( + + Pay + applyFilters(e.target.value, coinFilter)} + className="rounded border border-border bg-background px-2 py-1 text-xs text-foreground" + aria-label="Filter invoices by who gets paid" + > + + everyone ({acceptedCount} · ${totalUsd.toFixed(2)}) + + {payeeOptions.map((p) => ( + + {p.name} ({p.count} · ${p.total.toFixed(2)}) + + ))} + + + )} + + {coinOptions.length > 1 && ( + + in + applyFilters(payeeFilter, e.target.value)} + className="rounded border border-border bg-background px-2 py-1 text-xs text-foreground" + aria-label="Filter invoices by coin" + > + any coin + {coinOptions.map((c) => ( + + {coinLabel(c.code)} ({c.count} · ${c.total.toFixed(2)}) + + ))} + + + )} + + {(payeeFilter || coinFilter) && ( + applyFilters("", "")} + className="text-xs text-muted-foreground hover:text-foreground hover:underline" + > + Clear filters + + )} + + )} + - {showRows ? "Hide invoices" : `Show ${acceptedCount} invoices`} + {showRows ? "Hide invoices" : `Show ${visibleInvoices.length} invoices`} {showRows && ( - {orderedInvoices.map((invoice) => ( + {visibleInvoices.map((invoice) => (