diff --git a/app/governance/page.tsx b/app/governance/page.tsx index a810b13..18c21d6 100644 --- a/app/governance/page.tsx +++ b/app/governance/page.tsx @@ -31,6 +31,7 @@ function StatusBadge({ status }: { status: ProposalStatus }) { Failed: { color: "bg-red-500/15 text-red-500 border-red-500/30", icon: "cancel" }, Executed: { color: "bg-purple-500/15 text-purple-500 border-purple-500/30", icon: "rocket_launch" }, Pending: { color: "bg-amber-500/15 text-amber-500 border-amber-500/30", icon: "schedule" }, + Vetoed: { color: "bg-red-500/15 text-red-500 border-red-500/30", icon: "block" }, }; const { color, icon } = config[status]; return ( diff --git a/src/components/LPEarningsTable.tsx b/src/components/LPEarningsTable.tsx new file mode 100644 index 0000000..d643364 --- /dev/null +++ b/src/components/LPEarningsTable.tsx @@ -0,0 +1,144 @@ +"use client"; + +import { useMemo, useState } from "react"; +import type { ApprovedToken } from "@/hooks/useApprovedTokens"; +import type { Invoice } from "@/utils/soroban"; +import { + buildLPEarningsCsv, + buildLPEarningsRows, + formatEarningsPayer, + getLPEarningsExportFilename, +} from "@/utils/lpEarnings"; +import { downloadFile } from "@/utils/exportData"; + +interface LPEarningsTableProps { + invoices: Invoice[]; + tokenMap?: Map; + defaultToken?: ApprovedToken | null; +} + +const PAGE_SIZE = 20; + +export default function LPEarningsTable({ + invoices, + tokenMap = new Map(), + defaultToken = null, +}: LPEarningsTableProps) { + const [page, setPage] = useState(1); + const rows = useMemo( + () => buildLPEarningsRows(invoices, tokenMap, defaultToken), + [defaultToken, invoices, tokenMap], + ); + const pageCount = Math.max(1, Math.ceil(rows.length / PAGE_SIZE)); + const currentPage = Math.min(page, pageCount); + const visibleRows = rows.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE); + + const exportCsv = () => { + downloadFile( + buildLPEarningsCsv(rows), + getLPEarningsExportFilename(), + "text/csv;charset=utf-8;", + ); + }; + + return ( +
+
+
+

+ Earnings History +

+

+ Settled LP positions for accounting and tax export. +

+
+ +
+ +
+ + + + {[ + "Invoice ID", + "Payer", + "Settlement Date", + "Amount Funded", + "Payout Received", + "Earned", + "Token", + "Yield %", + ].map((heading) => ( + + ))} + + + + {visibleRows.length === 0 ? ( + + + + ) : ( + visibleRows.map((row) => ( + + + + + + + + + + + )) + )} + +
+ {heading} +
+ No settled LP earnings yet. +
#{row.invoiceId}{formatEarningsPayer(row.payer)}{row.settlementDate}{row.amountFunded}{row.payoutReceived}{row.earned}{row.token}{row.yieldPercent}
+
+ +
+ + Showing {visibleRows.length} of {rows.length} settled positions + +
+ + + Page {currentPage} / {pageCount} + + +
+
+
+ ); +} diff --git a/src/components/LPPortfolio.tsx b/src/components/LPPortfolio.tsx index e6f9dc9..07c908a 100644 --- a/src/components/LPPortfolio.tsx +++ b/src/components/LPPortfolio.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useMemo } from "react"; +import { useEffect, useState, useMemo } from "react"; import { formatAddress, formatDate, formatUSDC, calculateYield } from "@/utils/format"; import type { Invoice } from "@/utils/soroban"; import type { ApprovedToken } from "@/hooks/useApprovedTokens"; @@ -10,6 +10,7 @@ import LPTokenMetricsCards from "./LPTokenMetricsCards"; import LPPortfolioAllocationChart from "./LPPortfolioAllocationChart"; import WeeklyYieldChart from "./WeeklyYieldChart"; import { calculatePerTokenMetrics } from "@/utils/per-token-yield"; +import LPEarningsTable from "./LPEarningsTable"; interface LPPortfolioProps { invoices: Invoice[]; @@ -31,7 +32,12 @@ export default function LPPortfolio({ onTransfer, }: LPPortfolioProps) { const [showUSDEquivalent, setShowUSDEquivalent] = useState(false); - const now = Date.now(); + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + const interval = window.setInterval(() => setNow(Date.now()), 60 * 1000); + return () => window.clearInterval(interval); + }, []); // Calculate per-token metrics const perTokenMetrics = useMemo( @@ -170,6 +176,12 @@ export default function LPPortfolio({ /> )} + + {/* Portfolio Table */} ): Invoice { + return { + id: 1n, + status: "Paid", + freelancer: "freelancer", + payer: "payer", + amount: 1000_0000000n, + due_date: 1_800_000_000n, + discount_rate: 300, + funder: "lp", + funded_at: 1_801_000_000n, + token: "usdc-token", + ...overrides, + }; +} + +const tokenMap = new Map([ + ["usdc-token", { contractId: "usdc-token", symbol: "USDC", decimals: 7 }], + ["eurc-token", { contractId: "eurc-token", symbol: "EURC", decimals: 7 }], +]); + +describe("LP earnings history", () => { + it("builds newest-first rows for paid invoices only", () => { + const rows = buildLPEarningsRows( + [ + invoice({ id: 1n, funded_at: 1_701_000_000n }), + invoice({ id: 2n, status: "Funded", funded_at: 1_901_000_000n }), + invoice({ id: 3n, funded_at: 1_801_000_000n, token: "eurc-token" }), + ], + tokenMap, + null, + ); + + expect(rows.map((row) => row.invoiceId)).toEqual(["3", "1"]); + expect(rows[0]).toMatchObject({ + token: "EURC", + amountFunded: "970 EURC", + payoutReceived: "1,000 EURC", + earned: "30 EURC", + yieldPercent: "3.00%", + }); + }); + + it("exports all rows to CSV with the required headers", () => { + const rows = buildLPEarningsRows([invoice({ id: 7n })], tokenMap, null); + const csv = buildLPEarningsCsv(rows); + + expect(csv.split("\n")[0]).toBe( + "Invoice ID,Payer,Settlement Date,Amount Funded,Payout Received,Earned,Token,Yield %", + ); + expect(csv).toContain('"7","payer"'); + expect(csv).toContain('"30 USDC"'); + }); + + it("uses the requested ILN-LP-Earnings date filename", () => { + expect(getLPEarningsExportFilename(new Date("2026-05-26T09:00:00Z"))).toBe( + "ILN-LP-Earnings-2026-05-26.csv", + ); + }); +}); diff --git a/src/utils/lpEarnings.ts b/src/utils/lpEarnings.ts new file mode 100644 index 0000000..6a13ea0 --- /dev/null +++ b/src/utils/lpEarnings.ts @@ -0,0 +1,104 @@ +import type { ApprovedToken } from "@/hooks/useApprovedTokens"; +import type { Invoice } from "@/utils/soroban"; +import { + calculateYield, + formatAddress, + formatTokenAmount, + type TokenDisplayMeta, +} from "@/utils/format"; + +export interface LPEarningsRow { + invoiceId: string; + payer: string; + settlementTimestamp: bigint; + settlementDate: string; + amountFunded: string; + payoutReceived: string; + earned: string; + token: string; + yieldPercent: string; +} + +function getTokenForInvoice( + invoice: Invoice, + tokenMap: Map, + defaultToken: ApprovedToken | null, +): TokenDisplayMeta { + return ( + tokenMap.get(invoice.token ?? defaultToken?.contractId ?? "") ?? + defaultToken ?? { + symbol: "USDC", + decimals: 7, + } + ); +} + +function formatIsoDate(timestamp: bigint): string { + return new Date(Number(timestamp) * 1000).toISOString().slice(0, 10); +} + +export function buildLPEarningsRows( + invoices: Invoice[], + tokenMap = new Map(), + defaultToken: ApprovedToken | null = null, +): LPEarningsRow[] { + return invoices + .filter((invoice) => invoice.status === "Paid") + .map((invoice) => { + const token = getTokenForInvoice(invoice, tokenMap, defaultToken); + const earned = calculateYield(invoice.amount, invoice.discount_rate); + const payoutReceived = invoice.amount; + const amountFunded = invoice.amount - earned; + const settlementTimestamp = invoice.funded_at ?? invoice.due_date; + + return { + invoiceId: invoice.id.toString(), + payer: invoice.payer, + settlementTimestamp, + settlementDate: formatIsoDate(settlementTimestamp), + amountFunded: formatTokenAmount(amountFunded, token), + payoutReceived: formatTokenAmount(payoutReceived, token), + earned: formatTokenAmount(earned, token), + token: token.symbol, + yieldPercent: `${(invoice.discount_rate / 100).toFixed(2)}%`, + }; + }) + .sort((a, b) => Number(b.settlementTimestamp - a.settlementTimestamp)); +} + +export function buildLPEarningsCsv(rows: LPEarningsRow[]): string { + const headers = [ + "Invoice ID", + "Payer", + "Settlement Date", + "Amount Funded", + "Payout Received", + "Earned", + "Token", + "Yield %", + ]; + + const escapeCell = (value: string) => `"${value.replace(/"/g, '""')}"`; + const body = rows.map((row) => + [ + row.invoiceId, + row.payer, + row.settlementDate, + row.amountFunded, + row.payoutReceived, + row.earned, + row.token, + row.yieldPercent, + ].map(escapeCell).join(","), + ); + + return [headers.join(","), ...body].join("\n"); +} + +export function getLPEarningsExportFilename(date = new Date()): string { + return `ILN-LP-Earnings-${date.toISOString().slice(0, 10)}.csv`; +} + +export function formatEarningsPayer(address: string): string { + return formatAddress(address); +}