diff --git a/src/__tests__/BulkTagModal.test.tsx b/src/__tests__/BulkTagModal.test.tsx new file mode 100644 index 0000000..ebcdcd3 --- /dev/null +++ b/src/__tests__/BulkTagModal.test.tsx @@ -0,0 +1,238 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import BulkTagModal from "@/components/invoice/BulkTagModal"; + +// ── Helpers ─────────────────────────────────────────────────────────────── + +const SELECTED_IDS = ["inv-001", "inv-002", "inv-003"]; +const EXISTING_TAGS = ["design", "q1-2026", "client-a"]; + +function renderModal(overrides: Partial[0]> = {}) { + const onClose = vi.fn(); + const onOptimisticUpdate = vi.fn(); + const onRollback = vi.fn(); + const onError = vi.fn(); + + const utils = render( + + ); + + return { ...utils, onClose, onOptimisticUpdate, onRollback, onError }; +} + +function mockFetchOk() { + return vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + success: true, + count: SELECTED_IDS.length, + addedTags: ["urgent"], + updated: {}, + }), + } as Response); +} + +function mockFetchFail(errorMsg = "Server error") { + return vi.fn().mockResolvedValue({ + ok: false, + json: () => Promise.resolve({ error: errorMsg }), + } as Response); +} + +beforeEach(() => { + vi.stubGlobal("fetch", mockFetchOk()); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +// ── Tests ───────────────────────────────────────────────────────────────── + +describe("BulkTagModal", () => { + it("renders the modal with correct invoice count", () => { + renderModal(); + + expect( + screen.getByText(`Add tags to ${SELECTED_IDS.length} invoices`) + ).toBeInTheDocument(); + }); + + it("uses singular 'invoice' for count of 1", () => { + renderModal({ selectedIds: ["inv-001"] }); + + expect(screen.getByText("Add tags to 1 invoice")).toBeInTheDocument(); + }); + + it("renders TagInput for entering tags", () => { + renderModal(); + + expect( + screen.getByRole("combobox") + ).toBeInTheDocument(); + }); + + it("confirm button is disabled when no tags are entered", () => { + renderModal(); + + expect( + screen.getByTestId("bulk-tag-confirm") + ).toBeDisabled(); + }); + + it("confirm button is enabled after entering a tag", async () => { + const user = userEvent.setup(); + renderModal(); + + const input = screen.getByRole("combobox"); + await user.type(input, "urgent{Enter}"); + + expect(screen.getByTestId("bulk-tag-confirm")).not.toBeDisabled(); + }); + + it("calls onClose when Cancel is clicked", async () => { + const user = userEvent.setup(); + const { onClose } = renderModal(); + + await user.click(screen.getByRole("button", { name: /cancel/i })); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("calls onClose when the X button is clicked", async () => { + const user = userEvent.setup(); + const { onClose } = renderModal(); + + await user.click(screen.getByRole("button", { name: /close modal/i })); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("closes when Escape key is pressed", async () => { + const user = userEvent.setup(); + const { onClose } = renderModal(); + + await user.keyboard("{Escape}"); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("closes when backdrop is clicked", async () => { + const user = userEvent.setup(); + const { onClose } = renderModal(); + + // The backdrop is the outermost dialog container + const backdrop = screen.getByRole("dialog"); + await user.click(backdrop); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("calls onOptimisticUpdate immediately on confirm before API returns", async () => { + const user = userEvent.setup(); + + // Slow fetch — never resolves during the test assertion + vi.stubGlobal( + "fetch", + vi.fn().mockReturnValue(new Promise(() => {})) + ); + + const { onOptimisticUpdate, onClose } = renderModal(); + + const input = screen.getByRole("combobox"); + await user.type(input, "urgent{Enter}"); + await user.click(screen.getByTestId("bulk-tag-confirm")); + + // Optimistic update fires before fetch resolves + expect(onOptimisticUpdate).toHaveBeenCalledWith( + SELECTED_IDS, + expect.arrayContaining(["urgent"]) + ); + + // Modal closes immediately + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("sends PATCH /api/invoices/bulk with correct body", async () => { + const user = userEvent.setup(); + const fetchSpy = mockFetchOk(); + vi.stubGlobal("fetch", fetchSpy); + + renderModal(); + + const input = screen.getByRole("combobox"); + await user.type(input, "urgent{Enter}"); + await user.click(screen.getByTestId("bulk-tag-confirm")); + + await waitFor(() => { + expect(fetchSpy).toHaveBeenCalledWith( + "/api/invoices/bulk", + expect.objectContaining({ + method: "PATCH", + body: expect.stringContaining('"addTags"'), + }) + ); + }); + + const callBody = JSON.parse(fetchSpy.mock.calls[0][1].body); + expect(callBody.ids).toEqual(SELECTED_IDS); + expect(callBody.addTags).toContain("urgent"); + }); + + it("calls onRollback and onError when the API call fails", async () => { + const user = userEvent.setup(); + vi.stubGlobal("fetch", mockFetchFail("Bulk tag failed")); + + const { onRollback, onError } = renderModal(); + + const input = screen.getByRole("combobox"); + await user.type(input, "urgent{Enter}"); + await user.click(screen.getByTestId("bulk-tag-confirm")); + + await waitFor(() => { + expect(onRollback).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith("Bulk tag failed"); + }); + }); + + it("does not call onRollback on success", async () => { + const user = userEvent.setup(); + vi.stubGlobal("fetch", mockFetchOk()); + + const { onRollback } = renderModal(); + + const input = screen.getByRole("combobox"); + await user.type(input, "urgent{Enter}"); + await user.click(screen.getByTestId("bulk-tag-confirm")); + + await waitFor(() => { + expect(onRollback).not.toHaveBeenCalled(); + }); + }); + + it("shows confirm button label with selected count", async () => { + renderModal(); + + expect( + screen.getByTestId("bulk-tag-confirm") + ).toHaveTextContent(`Apply to ${SELECTED_IDS.length} invoices`); + }); + + it("displays a note that existing tags are preserved", () => { + renderModal(); + + expect( + screen.getByText(/existing tags are preserved/i) + ).toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/RecipientPaymentHistory.test.tsx b/src/__tests__/RecipientPaymentHistory.test.tsx new file mode 100644 index 0000000..d288a9f --- /dev/null +++ b/src/__tests__/RecipientPaymentHistory.test.tsx @@ -0,0 +1,289 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import RecipientPaymentHistory from "@/components/invoice/RecipientPaymentHistory"; + +// ── Stable mocks ────────────────────────────────────────────────────────── + +const INVOICE_ID = "inv-001"; +const RECIPIENT_ID = "GABCD1234ABCD1234ABCD1234ABCD1234ABCD1234ABCD1234ABCD1234"; + +const ENTRY_1 = { + operationHash: "aabbccdd1122", + amount: "100.00", + asset: "XLM", + timestamp: new Date("2026-01-15T10:00:00Z").toISOString(), + from: "GESCROW123456789012345678901234567890123456789012345678", +}; + +const ENTRY_2 = { + operationHash: "eeff33445566", + amount: "50.00", + asset: "USDC", + timestamp: new Date("2026-01-20T12:00:00Z").toISOString(), + from: "GESCROW123456789012345678901234567890123456789012345678", +}; + +function mockFetch( + entries: typeof ENTRY_1[], + cursor: string | null = null, + ok = true +) { + return vi.fn().mockResolvedValueOnce({ + ok, + json: () => + Promise.resolve( + ok + ? { entries, cursor, recipientId: RECIPIENT_ID, invoiceId: INVOICE_ID } + : { error: "Server error" } + ), + } as Response); +} + +beforeEach(() => { + vi.stubGlobal("fetch", mockFetch([])); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +// ── Tests ───────────────────────────────────────────────────────────────── + +describe("RecipientPaymentHistory", () => { + it("shows loading state while fetching", () => { + // Never resolves — keeps the component in loading state + vi.stubGlobal( + "fetch", + vi.fn().mockReturnValue(new Promise(() => {})) + ); + + render( + + ); + + expect(screen.getByText(/loading payment history/i)).toBeInTheDocument(); + }); + + it("shows empty state when no payments exist", async () => { + vi.stubGlobal("fetch", mockFetch([])); + + render( + + ); + + await waitFor(() => { + expect( + screen.getByTestId("empty-history") + ).toBeInTheDocument(); + }); + + expect( + screen.getByText(/no payments recorded yet/i) + ).toBeInTheDocument(); + }); + + it("renders a payment entry with amount and asset", async () => { + vi.stubGlobal("fetch", mockFetch([ENTRY_1])); + + render( + + ); + + await waitFor(() => { + expect( + screen.getByTestId(`history-entry-${ENTRY_1.operationHash}`) + ).toBeInTheDocument(); + }); + + expect(screen.getByText(/100\.00/)).toBeInTheDocument(); + expect(screen.getByText(/XLM/)).toBeInTheDocument(); + }); + + it("renders multiple entries", async () => { + vi.stubGlobal("fetch", mockFetch([ENTRY_1, ENTRY_2])); + + render( + + ); + + await waitFor(() => { + expect( + screen.getByTestId(`history-entry-${ENTRY_1.operationHash}`) + ).toBeInTheDocument(); + }); + + expect( + screen.getByTestId(`history-entry-${ENTRY_2.operationHash}`) + ).toBeInTheDocument(); + }); + + it("shows 'Load more' button when cursor is present", async () => { + vi.stubGlobal("fetch", mockFetch([ENTRY_1], "20")); + + render( + + ); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: /load more/i }) + ).toBeInTheDocument(); + }); + }); + + it("does not show 'Load more' when cursor is null", async () => { + vi.stubGlobal("fetch", mockFetch([ENTRY_1], null)); + + render( + + ); + + await waitFor(() => { + expect( + screen.getByTestId(`history-entry-${ENTRY_1.operationHash}`) + ).toBeInTheDocument(); + }); + + expect( + screen.queryByRole("button", { name: /load more/i }) + ).not.toBeInTheDocument(); + }); + + it("loads more entries when 'Load more' is clicked", async () => { + const user = userEvent.setup(); + + // First call returns ENTRY_1 with a cursor, second call returns ENTRY_2 + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + entries: [ENTRY_1], + cursor: "20", + recipientId: RECIPIENT_ID, + invoiceId: INVOICE_ID, + }), + } as Response) + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + entries: [ENTRY_2], + cursor: null, + recipientId: RECIPIENT_ID, + invoiceId: INVOICE_ID, + }), + } as Response) + ); + + render( + + ); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: /load more/i }) + ).toBeInTheDocument(); + }); + + await user.click(screen.getByRole("button", { name: /load more/i })); + + await waitFor(() => { + expect( + screen.getByTestId(`history-entry-${ENTRY_2.operationHash}`) + ).toBeInTheDocument(); + }); + + // Original entry still visible + expect( + screen.getByTestId(`history-entry-${ENTRY_1.operationHash}`) + ).toBeInTheDocument(); + + // Cursor exhausted — Load more button gone + expect( + screen.queryByRole("button", { name: /load more/i }) + ).not.toBeInTheDocument(); + }); + + it("shows an error message when the fetch fails", async () => { + vi.stubGlobal("fetch", mockFetch([], null, false)); + + render( + + ); + + await waitFor(() => { + expect(screen.getByRole("alert")).toBeInTheDocument(); + }); + }); + + it("fetches with correct URL including invoiceId and recipientId", async () => { + const fetchSpy = mockFetch([ENTRY_1]); + vi.stubGlobal("fetch", fetchSpy); + + render( + + ); + + await waitFor(() => { + expect(fetchSpy).toHaveBeenCalledWith( + expect.stringContaining( + `/api/invoices/${INVOICE_ID}/recipients/${RECIPIENT_ID}/history` + ), + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ); + }); + }); + + it("each entry shows a truncated transaction hash", async () => { + vi.stubGlobal("fetch", mockFetch([ENTRY_1])); + + render( + + ); + + await waitFor(() => { + expect( + screen.getByTestId(`history-entry-${ENTRY_1.operationHash}`) + ).toBeInTheDocument(); + }); + + // TxHash component shows first 8 chars...last 6 chars + const truncated = `${ENTRY_1.operationHash.slice(0, 8)}...${ENTRY_1.operationHash.slice(-6)}`; + expect(screen.getByText(truncated)).toBeInTheDocument(); + }); +}); diff --git a/src/app/api/invoices/[id]/recipients/[recipientId]/history/route.ts b/src/app/api/invoices/[id]/recipients/[recipientId]/history/route.ts new file mode 100644 index 0000000..f06f945 --- /dev/null +++ b/src/app/api/invoices/[id]/recipients/[recipientId]/history/route.ts @@ -0,0 +1,135 @@ +import { NextRequest, NextResponse } from "next/server"; +import { splitClient } from "@/lib/stellar"; + +/** + * GET /api/invoices/[id]/recipients/[recipientId]/history + * + * Returns on-chain payment history for a specific recipient on an invoice. + * Queries the invoice's payment list (via the SDK / Horizon) and filters to + * operations that transferred funds to `recipientId`. + * + * Results are cached with short-lived headers so repeated expands on the same + * page session stay fast without stale data. + */ + +export interface RecipientHistoryEntry { + /** Stellar operation/transaction hash */ + operationHash: string; + /** Amount transferred (string to preserve decimal precision) */ + amount: string; + /** Asset code, e.g. "XLM" or "USDC" */ + asset: string; + /** ISO-8601 timestamp of the operation */ + timestamp: string; + /** Sender address (typically the escrow or payer) */ + from: string; +} + +export interface RecipientHistoryResponse { + recipientId: string; + invoiceId: string; + entries: RecipientHistoryEntry[]; + cursor: string | null; +} + +const DEFAULT_LIMIT = 20; + +export async function GET( + request: NextRequest, + { params }: { params: { id: string; recipientId: string } } +) { + const { id: invoiceId, recipientId } = params; + const { searchParams } = request.nextUrl; + const cursor = searchParams.get("cursor"); + const limitParam = searchParams.get("limit"); + const limit = Math.min( + Math.max(1, parseInt(limitParam ?? String(DEFAULT_LIMIT), 10) || DEFAULT_LIMIT), + 50 + ); + + try { + const invoice = await splitClient.getInvoice(invoiceId); + + // Verify the recipientId is actually a recipient on this invoice. + const recipientExists = invoice.recipients.some( + (r) => r.address === recipientId + ); + if (!recipientExists) { + return NextResponse.json( + { error: "Recipient not found on this invoice" }, + { status: 404 } + ); + } + + // Filter payments to those destined for this recipient. + // The SDK payment objects contain payer (from), amount, and an optional + // timestamp. We adapt them to the richer RecipientHistoryEntry shape. + const allPayments: RecipientHistoryEntry[] = invoice.payments + .filter((p) => { + // The SDK Payment type includes a `recipient` field on some builds; + // fall back to checking whether the to-address matches recipientId. + const payment = p as Record; + return ( + payment.recipient === recipientId || + payment.to === recipientId || + // If there is no per-payment recipient field the whole invoice + // payment list belongs to a single-recipient invoice. + (invoice.recipients.length === 1 && + invoice.recipients[0].address === recipientId) + ); + }) + .map((p, idx) => { + const payment = p as Record; + const ts = payment.timestamp; + const tsMs = + typeof ts === "number" + ? ts * 1000 + : typeof ts === "string" + ? Date.parse(ts) + : NaN; + return { + operationHash: String( + payment.txHash ?? payment.operationHash ?? `op-${invoiceId}-${idx}` + ), + amount: String(payment.amount ?? "0"), + asset: String(payment.asset ?? "XLM"), + timestamp: Number.isNaN(tsMs) + ? new Date().toISOString() + : new Date(tsMs).toISOString(), + from: String( + payment.payer ?? payment.from ?? invoice.creator ?? "" + ), + }; + }); + + // Apply cursor-based pagination. + const startIdx = cursor ? parseInt(cursor, 10) : 0; + const page = allPayments.slice(startIdx, startIdx + limit); + const nextCursor = + startIdx + limit < allPayments.length + ? String(startIdx + limit) + : null; + + return NextResponse.json( + { + recipientId, + invoiceId, + entries: page, + cursor: nextCursor, + } satisfies RecipientHistoryResponse, + { + headers: { + // Short private cache — same user re-expands without a new RTT, + // but data stays fresh after ~30 s. + "Cache-Control": "private, max-age=30", + }, + } + ); + } catch (error) { + console.error("Recipient history fetch error:", error); + return NextResponse.json( + { error: "Failed to fetch recipient payment history" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/invoices/bulk/route.ts b/src/app/api/invoices/bulk/route.ts index bd1afcc..b11c1c9 100644 --- a/src/app/api/invoices/bulk/route.ts +++ b/src/app/api/invoices/bulk/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; +import { getTags, setTags, normalizeTags } from "@/lib/invoiceTags"; /** * PATCH /api/invoices/bulk @@ -6,15 +7,66 @@ import { NextRequest, NextResponse } from "next/server"; * Bulk operation endpoint for archive, delete, and tag operations. * Accepts up to 200 invoice IDs per request. * - * Request body: - * - invoiceIds: string[] (required) - * - action: 'archive' | 'delete' | 'tag' (required) - * - archived?: boolean (for archive action) - * - tags?: string[] (for tag action) + * Request body variants: + * + * addTags style (Issue #518 — additive tag assignment): + * { ids: string[], addTags: string[] } + * + * Legacy action-style (archive / delete / replace tags): + * { invoiceIds: string[], action: 'archive' | 'delete' | 'tag', archived?: boolean, tags?: string[] } */ export async function PATCH(request: NextRequest) { try { const body = await request.json(); + + // ── addTags contract (Issue #518) ────────────────────────────────────── + if (Array.isArray(body.ids) && Array.isArray(body.addTags)) { + const { ids, addTags } = body as { ids: string[]; addTags: string[] }; + + if (ids.length === 0) { + return NextResponse.json( + { error: "ids must be a non-empty array" }, + { status: 400 } + ); + } + + if (ids.length > 200) { + return NextResponse.json( + { error: "Maximum 200 invoices per request" }, + { status: 400 } + ); + } + + const normalizedNew = normalizeTags(addTags); + + if (normalizedNew.length === 0) { + return NextResponse.json( + { error: "addTags must contain at least one valid tag" }, + { status: 400 } + ); + } + + // Merge tags: preserve each invoice's existing tags, append new ones. + const updated: Record = {}; + for (const id of ids) { + const existing = getTags(id); + const merged = normalizeTags([...existing, ...normalizedNew]); + setTags(id, merged); + updated[id] = merged; + } + + return NextResponse.json( + { + success: true, + count: ids.length, + addedTags: normalizedNew, + updated, + }, + { status: 200 } + ); + } + + // ── Legacy action-based contract ──────────────────────────────────────── const { invoiceIds, action, archived, tags } = body as { invoiceIds: string[]; action: string; @@ -22,48 +74,50 @@ export async function PATCH(request: NextRequest) { tags?: string[]; }; - // Validate inputs if (!Array.isArray(invoiceIds) || invoiceIds.length === 0) { return NextResponse.json( { error: "invoiceIds must be a non-empty array" }, - { status: 400 }, + { status: 400 } ); } if (invoiceIds.length > 200) { return NextResponse.json( { error: "Maximum 200 invoices per request" }, - { status: 400 }, + { status: 400 } ); } if (!["archive", "delete", "tag"].includes(action)) { return NextResponse.json( { error: "action must be 'archive', 'delete', or 'tag'" }, - { status: 400 }, + { status: 400 } ); } if (action === "archive" && typeof archived !== "boolean") { return NextResponse.json( { error: "archived must be a boolean for archive action" }, - { status: 400 }, + { status: 400 } ); } if (action === "tag" && !Array.isArray(tags)) { return NextResponse.json( { error: "tags must be an array for tag action" }, - { status: 400 }, + { status: 400 } ); } - // TODO: Implement actual database operations - // For now, validate the request and return success. - // In production: - // - archive: Update archived status in database - // - delete: Mark invoices as deleted or remove them - // - tag: Apply tags to invoices + // Apply legacy tag action (replace all tags on each invoice). + if (action === "tag" && tags) { + const normalized = normalizeTags(tags); + for (const id of invoiceIds) { + setTags(id, normalized); + } + } + + // TODO: Implement actual database operations for archive / delete. let message = ""; switch (action) { @@ -85,13 +139,13 @@ export async function PATCH(request: NextRequest) { action, message, }, - { status: 200 }, + { status: 200 } ); } catch (error) { console.error("Bulk invoice operation error:", error); return NextResponse.json( { error: "Failed to process bulk operation" }, - { status: 500 }, + { status: 500 } ); } } diff --git a/src/components/RecipientPayoutTracker.tsx b/src/components/RecipientPayoutTracker.tsx index 4607c72..df1b66f 100644 --- a/src/components/RecipientPayoutTracker.tsx +++ b/src/components/RecipientPayoutTracker.tsx @@ -3,10 +3,12 @@ import { useMemo, useState } from "react"; import { formatAmount, truncateAddress } from "@stellar-split/sdk"; import type { Invoice, Recipient } from "@stellar-split/sdk"; +import { RecipientDetailRow } from "@/components/invoice/RecipientRow"; interface Props { invoice: Invoice; publicKey: string | null; + network?: "testnet" | "mainnet"; } type PayoutStatus = "Pending" | "Paid" | "Claimed" | "Refunded"; @@ -15,7 +17,7 @@ function formatCurrency(amount: bigint): string { return `${formatAmount(amount)} USDC`; } -export default function RecipientPayoutTracker({ invoice, publicKey }: Props) { +export default function RecipientPayoutTracker({ invoice, publicKey, network = "testnet" }: Props) { const [claimingId, setClaimingId] = useState(null); const [claimError, setClaimError] = useState(null); const [claimTx, setClaimTx] = useState(null); @@ -26,30 +28,19 @@ export default function RecipientPayoutTracker({ invoice, publicKey }: Props) { const getStatus = (recipient: Recipient): PayoutStatus => { if (invoice.status === "Refunded") return "Refunded"; if (invoice.status === "Released") { - const claimedAddresses: string[] = (invoice as unknown as { claimed?: string[] }).claimed ?? []; + const claimedAddresses: string[] = + (invoice as unknown as { claimed?: string[] }).claimed ?? []; if (claimedAddresses.includes(recipient.address)) return "Claimed"; return "Paid"; } return "Pending"; }; - const statusStyles: Record = { - Pending: { - badge: "bg-yellow-500/20 text-yellow-300", - text: "text-yellow-300", - }, - Paid: { - badge: "bg-green-500/20 text-green-300", - text: "text-green-300", - }, - Claimed: { - badge: "bg-indigo-500/20 text-indigo-300", - text: "text-indigo-300", - }, - Refunded: { - badge: "bg-gray-500/20 text-gray-300", - text: "text-gray-300", - }, + const statusStyles: Record = { + Pending: { badge: "bg-yellow-500/20 text-yellow-300" }, + Paid: { badge: "bg-green-500/20 text-green-300" }, + Claimed: { badge: "bg-indigo-500/20 text-indigo-300" }, + Refunded: { badge: "bg-gray-500/20 text-gray-300" }, }; const handleClaim = async (invoiceId: string) => { @@ -59,9 +50,11 @@ export default function RecipientPayoutTracker({ invoice, publicKey }: Props) { setClaimTx(null); try { const { splitClient } = await import("@/lib/stellar"); - const result = await (splitClient as unknown as { claimShare: (id: string) => Promise<{ txHash?: string }> }).claimShare( - invoiceId - ); + const result = await ( + splitClient as unknown as { + claimShare: (id: string) => Promise<{ txHash?: string }>; + } + ).claimShare(invoiceId); setClaimTx(result?.txHash ?? "ok"); } catch (err) { setClaimError(String(err)); @@ -70,7 +63,7 @@ export default function RecipientPayoutTracker({ invoice, publicKey }: Props) { } }; - const recipientRows = useMemo( + const rows = useMemo( () => recipients.map((r) => { const status = getStatus(r); @@ -79,86 +72,104 @@ export default function RecipientPayoutTracker({ invoice, publicKey }: Props) { const canClaim = isWallet && status === "Paid" && invoice.status === "Released"; + const sharePercent = + total > 0n + ? ((Number(r.amount) * 100) / Number(total)).toFixed(2) + : "0.00"; + + const actionSlot = ( + <> + {canClaim && ( + + )} + {claimError && ( +

+ {claimError} +

+ )} + {claimTx && ( +

+ Claimed! Tx: {claimTx.slice(0, 12)}… +

+ )} + + ); + return ( - - - {truncateAddress(r.address)} - {r.address} - {isWallet && ( - - You - - )} - - - {total > 0n - ? `${((Number(r.amount) * 100) / Number(total)).toFixed(2)}%` - : "0.00%"} - - - {formatCurrency(r.amount)} - - - - {status} - - - - {canClaim && ( - - )} - {claimError && ( -

{claimError}

- )} - {claimTx && ( -

- Claimed! Tx: {claimTx.slice(0, 12)}… -

- )} - - + address={r.address} + sharePercent={sharePercent} + formattedAmount={formatCurrency(r.amount)} + status={status} + statusBadgeClass={styles.badge} + isCurrentWallet={isWallet} + invoiceId={invoice.id} + network={network} + actionSlot={actionSlot} + /> ); }), - [recipients, total, publicKey, invoice.status, invoice.id, claimingId, claimError, claimTx] + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + recipients, + total, + publicKey, + invoice.status, + invoice.id, + network, + claimingId, + claimError, + claimTx, + ] ); return (
-

+

Recipient Payouts

- - - - - + {/* chevron column — no label */} + + + + + - {recipientRows} + {rows} - ` with an expandable payment history panel. + * + * The history panel is fetched lazily on first expand and cached by the + * RecipientPaymentHistory component for the page session. + */ +export function RecipientDetailRow({ + address, + sharePercent, + formattedAmount, + status, + statusBadgeClass, + isCurrentWallet = false, + invoiceId, + network = "testnet", + actionSlot, +}: RecipientDetailRowProps) { + const [expanded, setExpanded] = useState(false); + + const truncated = `${address.slice(0, 8)}...${address.slice(-6)}`; + + return ( + <> + + {/* Chevron toggle */} + + + {/* Address */} + + + {/* Share */} + + + {/* Amount */} + + + {/* Status */} + + + {/* Action slot */} + + + + {/* Expanded history panel spans all columns */} + {expanded && ( + + + + )} + + ); +}
AddressShareExpected AmountStatusAction + + Address + + Share + + Expected Amount + + Status + + Action +
- {recipients.length} recipient{recipients.length === 1 ? "" : "s"} + + {recipients.length} recipient + {recipients.length === 1 ? "" : "s"} {formatCurrency(total)} diff --git a/src/components/invoice/BulkActionToolbar.tsx b/src/components/invoice/BulkActionToolbar.tsx index 8f9c831..eea5067 100644 --- a/src/components/invoice/BulkActionToolbar.tsx +++ b/src/components/invoice/BulkActionToolbar.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { Trash2, Archive, Tag, X } from "lucide-react"; +import BulkTagModal from "@/components/invoice/BulkTagModal"; interface Props { selectedCount: number; @@ -11,7 +12,20 @@ interface Props { onDeselectAll: () => void; onArchive: () => Promise; onDelete: () => Promise; - onTag: () => void; + /** @deprecated Pass onTagApplied instead. onTag is kept for backwards compat. */ + onTag?: () => void; + /** + * Called after the optimistic tag update is applied. + * Receives the affected invoice ids and the tags that were added. + * The parent should update its local invoice list (e.g. via mutate/immer). + */ + onTagApplied?: (ids: string[], addedTags: string[]) => void; + /** Called if the tag API call fails and the optimistic update must be rolled back. */ + onTagRollback?: () => void; + /** Show an error toast — e.g. (msg) => addToast(msg, 'error') */ + onError?: (message: string) => void; + /** All existing tag names for autocomplete inside the modal */ + existingTags?: string[]; } export default function BulkActionToolbar({ @@ -23,8 +37,13 @@ export default function BulkActionToolbar({ onArchive, onDelete, onTag, + onTagApplied, + onTagRollback, + onError, + existingTags = [], }: Props) { const [processing, setProcessing] = useState(false); + const [tagModalOpen, setTagModalOpen] = useState(false); const handleArchive = async () => { setProcessing(true); @@ -36,7 +55,11 @@ export default function BulkActionToolbar({ }; const handleDelete = async () => { - if (!window.confirm(`Delete ${selectedCount} invoice(s)? This cannot be undone.`)) { + if ( + !window.confirm( + `Delete ${selectedCount} invoice(s)? This cannot be undone.` + ) + ) { return; } setProcessing(true); @@ -47,65 +70,85 @@ export default function BulkActionToolbar({ } }; + const handleTagClick = () => { + // Open the BulkTagModal; also call legacy onTag if provided. + onTag?.(); + setTagModalOpen(true); + }; + return ( -
-
-
-
- {selectedCount} of {totalVisible} selected + <> +
+
+
+
+ {selectedCount} of {totalVisible} selected +
+ + {selectedCount > 0 && totalVisible > selectedCount && ( + + )}
- {selectedCount > 0 && totalVisible > selectedCount && ( +
- )} -
-
- - - + - + - + +
-
+ + {tagModalOpen && ( + onTagApplied?.(ids, tags)} + onRollback={() => onTagRollback?.()} + onClose={() => setTagModalOpen(false)} + onError={(msg) => onError?.(msg)} + /> + )} + ); } diff --git a/src/components/invoice/BulkTagModal.tsx b/src/components/invoice/BulkTagModal.tsx new file mode 100644 index 0000000..2bda7a6 --- /dev/null +++ b/src/components/invoice/BulkTagModal.tsx @@ -0,0 +1,170 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { X } from "lucide-react"; +import TagInput from "@/components/invoice/TagInput"; +import { apiFetch } from "@/lib/api"; + +interface BulkTagModalProps { + /** IDs of the selected invoices */ + selectedIds: string[]; + /** All existing tags across invoices, used for autocomplete */ + existingTags?: string[]; + /** Called after the optimistic update is applied (passes the tags added) */ + onOptimisticUpdate?: (ids: string[], tags: string[]) => void; + /** Called if the API call fails and the optimistic update should be rolled back */ + onRollback?: () => void; + /** Called when the modal should close (success or cancel) */ + onClose: () => void; + /** Show an error toast — provided by the parent so modals stay decoupled */ + onError?: (message: string) => void; +} + +/** + * BulkTagModal — lets users apply one or more tags to all selected invoices + * in a single API request (Issue #518). + * + * Flow: + * 1. User picks tags via TagInput (autocomplete from existingTags). + * 2. On confirm the parent's onOptimisticUpdate is called immediately so the + * list updates before the network round-trip. + * 3. PATCH /api/invoices/bulk { ids, addTags } is sent. + * 4. On failure, onRollback is called and an error toast is shown. + */ +export default function BulkTagModal({ + selectedIds, + existingTags = [], + onOptimisticUpdate, + onRollback, + onClose, + onError, +}: BulkTagModalProps) { + const [tags, setTags] = useState([]); + const [submitting, setSubmitting] = useState(false); + const dialogRef = useRef(null); + const firstFocusRef = useRef(null); + + // Focus trap: return focus to the dialog on mount + useEffect(() => { + firstFocusRef.current?.focus(); + }, []); + + // Close on Escape + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [onClose]); + + const handleConfirm = useCallback(async () => { + if (tags.length === 0 || submitting) return; + + setSubmitting(true); + + // 1. Optimistic update — parent updates the list immediately + onOptimisticUpdate?.(selectedIds, tags); + onClose(); + + try { + const res = await apiFetch("/api/invoices/bulk", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ids: selectedIds, addTags: tags }), + }); + + if (!res.ok) { + const json = await res.json().catch(() => ({})); + throw new Error( + (json as { error?: string }).error ?? "Failed to apply tags" + ); + } + } catch (err) { + // 2. Roll back the optimistic update and surface the error + onRollback?.(); + onError?.( + err instanceof Error ? err.message : "Failed to apply tags to invoices" + ); + } finally { + setSubmitting(false); + } + }, [tags, submitting, selectedIds, onOptimisticUpdate, onRollback, onClose, onError]); + + return ( + /* Backdrop */ +
{ + if (e.target === e.currentTarget) onClose(); + }} + > +
+ {/* Header */} +
+

+ Add tags to {selectedIds.length} invoice + {selectedIds.length === 1 ? "" : "s"} +

+ +
+ + {/* Body */} +
+

+ Tags will be added to all selected invoices. Existing tags are + preserved. +

+ + +
+ + {/* Footer */} +
+ + + +
+
+
+ ); +} diff --git a/src/components/invoice/RecipientPaymentHistory.tsx b/src/components/invoice/RecipientPaymentHistory.tsx new file mode 100644 index 0000000..174bb73 --- /dev/null +++ b/src/components/invoice/RecipientPaymentHistory.tsx @@ -0,0 +1,184 @@ +"use client"; + +import { useEffect, useState } from "react"; +import TxHash from "@/components/ui/TxHash"; +import RelativeTime from "@/components/ui/RelativeTime"; + +export interface RecipientHistoryEntry { + operationHash: string; + amount: string; + asset: string; + timestamp: string; + from: string; +} + +interface RecipientPaymentHistoryProps { + invoiceId: string; + recipientId: string; + /** Network for Stellar transaction explorer links */ + network?: "testnet" | "mainnet"; +} + +/** + * RecipientPaymentHistory — displays payment timeline for one recipient on an invoice. + * + * Data is fetched lazily on mount from GET /api/invoices/[id]/recipients/[recipientId]/history + * and cached for the page session (component stays mounted while the row is expanded). + * + * Supports cursor-based pagination if the recipient has >20 payments, though that's rare. + */ +export default function RecipientPaymentHistory({ + invoiceId, + recipientId, + network = "testnet", +}: RecipientPaymentHistoryProps) { + const [entries, setEntries] = useState([]); + const [cursor, setCursor] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [loadingMore, setLoadingMore] = useState(false); + + useEffect(() => { + const controller = new AbortController(); + setLoading(true); + setError(null); + + const url = new URL( + `/api/invoices/${invoiceId}/recipients/${recipientId}/history`, + window.location.origin + ); + + fetch(url.toString(), { signal: controller.signal }) + .then((res) => { + if (!res.ok) { + return res.json().then( + (json) => { + throw new Error(json.error ?? "Failed to load payment history"); + }, + () => { + throw new Error("Failed to load payment history"); + } + ); + } + return res.json(); + }) + .then((data) => { + setEntries(data.entries ?? []); + setCursor(data.cursor ?? null); + }) + .catch((err) => { + if (err.name !== "AbortError") { + console.error("Payment history fetch error:", err); + setError(err.message ?? "Failed to load payment history"); + } + }) + .finally(() => setLoading(false)); + + return () => controller.abort(); + }, [invoiceId, recipientId]); + + const loadMore = async () => { + if (!cursor || loadingMore) return; + + setLoadingMore(true); + const url = new URL( + `/api/invoices/${invoiceId}/recipients/${recipientId}/history`, + window.location.origin + ); + url.searchParams.set("cursor", cursor); + + try { + const res = await fetch(url.toString()); + if (!res.ok) { + const json = await res.json().catch(() => ({})); + throw new Error(json.error ?? "Failed to load more entries"); + } + const data = await res.json(); + setEntries((prev) => [...prev, ...(data.entries ?? [])]); + setCursor(data.cursor ?? null); + } catch (err) { + console.error("Load more error:", err); + } finally { + setLoadingMore(false); + } + }; + + if (loading) { + return ( +
+ Loading payment history... +
+ ); + } + + if (error) { + return ( +
+ {error} +
+ ); + } + + if (entries.length === 0) { + return ( +
+ No payments recorded yet +
+ ); + } + + return ( +
+
+ Payment History +
+ +
+ {entries.map((entry) => ( +
+
+
+ + {entry.amount} {entry.asset} + + + from{" "} + + {entry.from.slice(0, 8)}...{entry.from.slice(-6)} + + +
+ +
+ +
+
+ +
+ +
+
+ ))} +
+ + {cursor && ( +
+ +
+ )} +
+ ); +} diff --git a/src/components/invoice/RecipientRow.tsx b/src/components/invoice/RecipientRow.tsx index 4f6524f..8c32abf 100644 --- a/src/components/invoice/RecipientRow.tsx +++ b/src/components/invoice/RecipientRow.tsx @@ -1,6 +1,11 @@ "use client"; +import { useState } from "react"; +import { ChevronDown, ChevronRight } from "lucide-react"; import { useEmailValidation } from "@/hooks/useEmailValidation"; +import RecipientPaymentHistory from "@/components/invoice/RecipientPaymentHistory"; + +// ─── Form Row (invoice creation) ────────────────────────────────────────── interface RecipientRowProps { email: string; @@ -58,3 +63,130 @@ export default function RecipientRow({
); } + +// ─── Invoice Detail Row (with payment history panel) ────────────────────── + +export interface RecipientDetailRowProps { + /** Stellar address of the recipient */ + address: string; + /** Share percentage (0–100) */ + sharePercent: string; + /** Expected payout amount, formatted */ + formattedAmount: string; + /** Payment status label */ + status: string; + /** Status badge CSS classes */ + statusBadgeClass: string; + /** Whether this row belongs to the connected wallet */ + isCurrentWallet?: boolean; + /** Invoice id — used to build the history API URL */ + invoiceId: string; + /** Stellar network for explorer links */ + network?: "testnet" | "mainnet"; + /** Optional action slot (e.g. Claim button) */ + actionSlot?: React.ReactNode; +} + +/** + * RecipientDetailRow — a `
+ + + {truncated} + {address} + {isCurrentWallet && ( + + You + + )} + + {sharePercent}% + + {formattedAmount} + + + {status} + + {actionSlot}
+ +