diff --git a/src/app/analytics/page.tsx b/src/app/analytics/page.tsx index baaac115..4d72e9b6 100644 --- a/src/app/analytics/page.tsx +++ b/src/app/analytics/page.tsx @@ -119,6 +119,7 @@ export default function AnalyticsPage() { const [error, setError] = useState(null); const [dateFrom, setDateFrom] = useState(""); const [dateTo, setDateTo] = useState(""); + const [drilldownStatus, setDrilldownStatus] = useState(null); useEffect(() => { getFreighterPublicKey() @@ -165,6 +166,23 @@ export default function AnalyticsPage() { }); }, [invoices, dateFrom, dateTo]); + const drilldownInvoices = useMemo(() => { + if (!drilldownStatus) return []; + const now = Date.now() / 1000; + switch (drilldownStatus) { + case "Released": + return filtered.filter((i) => i.status === "Released"); + case "Refunded": + return filtered.filter((i) => i.status === "Refunded"); + case "Active": + return filtered.filter((i) => i.status === "Pending" && i.deadline >= now); + case "Expired": + return filtered.filter((i) => i.status === "Pending" && i.deadline < now); + default: + return []; + } + }, [filtered, drilldownStatus]); + const stats = useMemo(() => { const total = filtered.length; const released = filtered.filter((i) => i.status === "Released"); @@ -435,11 +453,51 @@ export default function AnalyticsPage() { Invoice Status Breakdown }> - + setDrilldownStatus(name)} + /> +

+ Click a slice to drill into those invoices. +

+ {drilldownStatus && ( +
+
+

+ {drilldownStatus} invoices ({drilldownInvoices.length}) +

+ +
+ {drilldownInvoices.length === 0 ? ( +

No invoices in this category.

+ ) : ( +
    + {drilldownInvoices.slice(0, 50).map((inv) => ( +
  • + Invoice #{inv.id} + + {stroopsToUsdc(inv.funded).toFixed(2)} USDC + +
  • + ))} +
+ )} +
+ )} +

diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index 20519c79..9feea130 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -80,6 +80,15 @@ function hasActiveFilters(c: FilterCriteria): boolean { }); } +/** Counts how many individual filter dimensions are currently active (for a filter-count badge). */ +function countActiveFilters(c: FilterCriteria): number { + return Object.keys(c).reduce((count, k) => { + const v = (c as unknown as Record)[k]; + const active = Array.isArray(v) ? v.length > 0 : v !== undefined; + return active ? count + 1 : count; + }, 0); +} + // ── Status badge styles ─────────────────────────────────────────────────────── const STATUS_STYLES: Record = { @@ -88,6 +97,60 @@ const STATUS_STYLES: Record = { Refunded: 'bg-gray-500/20 text-gray-300', }; +// ── Highlight ─────────────────────────────────────────────────────────────── + +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** Wraps occurrences of `query` inside `text` with a highlight . */ +function highlightMatch(text: string, query: string): React.ReactNode { + const trimmed = query.trim(); + if (!trimmed) return text; + const escaped = escapeRegExp(trimmed); + const parts = text.split(new RegExp(`(${escaped})`, 'ig')); + if (parts.length === 1) return text; + return parts.map((part, i) => + part.toLowerCase() === trimmed.toLowerCase() ? ( + + {part} + + ) : ( + {part} + ), + ); +} + +// ── Smart suggestions ───────────────────────────────────────────────────────── + +/** Builds lightweight "did you mean" style suggestions from history + common terms. */ +function buildSmartSuggestions( + query: string, + recentSearches: string[], + savedSearches: SavedSearch[], +): string[] { + const trimmed = query.trim().toLowerCase(); + if (!trimmed) return []; + const pool = [ + ...recentSearches, + ...savedSearches.map((s) => s.query), + ...STATUSES, + ...DEFAULT_TOKENS, + ]; + const seen = new Set(); + const suggestions: string[] = []; + for (const candidate of pool) { + const c = candidate.trim(); + if (!c || c.toLowerCase() === trimmed) continue; + if (c.toLowerCase().includes(trimmed) && !seen.has(c.toLowerCase())) { + seen.add(c.toLowerCase()); + suggestions.push(c); + } + if (suggestions.length >= 5) break; + } + return suggestions; +} + // ── FullTextSearchBar ───────────────────────────────────────────────────────── interface FullTextSearchBarProps { @@ -356,9 +419,11 @@ function ColumnPicker({ function InvoiceRow({ invoice, visibleColumns, + highlightQuery, }: { invoice: Invoice; visibleColumns: SearchColumn[]; + highlightQuery?: string; }) { const total = invoice.recipients.reduce((s, r) => s + r.amount, 0n); const fundedPct = total > 0n ? Number((invoice.funded * 100n) / total) : 0; @@ -367,7 +432,7 @@ function InvoiceRow({
- Invoice #{invoice.id} + Invoice #{highlightQuery ? highlightMatch(String(invoice.id), highlightQuery) : invoice.id} {visibleColumns.includes('amount') && ( @@ -380,7 +445,7 @@ function InvoiceRow({ - {invoice.status} + {highlightQuery ? highlightMatch(invoice.status, highlightQuery) : invoice.status} )} @@ -392,7 +457,9 @@ function InvoiceRow({ {visibleColumns.includes('creator') && ( - {truncateAddress(invoice.creator)} + {highlightQuery + ? highlightMatch(truncateAddress(invoice.creator), highlightQuery) + : truncateAddress(invoice.creator)} )} @@ -417,9 +484,10 @@ function InvoiceRow({ interface VirtualListProps { items: Invoice[]; visibleColumns: SearchColumn[]; + highlightQuery?: string; } -function VirtualList({ items, visibleColumns }: VirtualListProps) { +function VirtualList({ items, visibleColumns, highlightQuery }: VirtualListProps) { const [scrollTop, setScrollTop] = useState(0); const startIdx = Math.floor(scrollTop / ITEM_HEIGHT_PX); @@ -444,7 +512,7 @@ function VirtualList({ items, visibleColumns }: VirtualListProps) { className="mb-4" > - +
))} @@ -829,6 +897,15 @@ function SearchPageInner() { {shareStatus || 'Share search'} + {countActiveFilters(criteria) > 0 && ( + + {countActiveFilters(criteria)} filter{countActiveFilters(criteria) !== 1 ? 's' : ''} + + )} {filtersActive && ( + ))} +
+ ); + })() + )} {showHistory && (
0} hasFilters={filtersActive} /> ) : ( - + )} ); diff --git a/src/components/ExportModal.tsx b/src/components/ExportModal.tsx index 0db0aea6..63958b61 100644 --- a/src/components/ExportModal.tsx +++ b/src/components/ExportModal.tsx @@ -149,19 +149,19 @@ export default function ExportModal({ return (
-
+
{/* Header */} -
+
-

Export Invoices

-

+

Export Invoices

+

{invoices.length} invoice{invoices.length !== 1 ? 's' : ''} available

diff --git a/src/components/analytics/StatusPieChart.tsx b/src/components/analytics/StatusPieChart.tsx index 9d7dfa5a..0936d2af 100644 --- a/src/components/analytics/StatusPieChart.tsx +++ b/src/components/analytics/StatusPieChart.tsx @@ -11,11 +11,13 @@ import { export interface StatusPieChartProps { data: { name: string; value: number }[]; + /** Called with the status name when a slice (or its legend entry) is clicked, for drill-down. */ + onSliceClick?: (name: string) => void; } const COLORS = ["#10b981", "#ef4444", "#3b82f6", "#f59e0b"]; -export default function StatusPieChart({ data }: StatusPieChartProps) { +export default function StatusPieChart({ data, onSliceClick }: StatusPieChartProps) { const total = data.reduce((s, d) => s + d.value, 0); if (total === 0) { @@ -45,6 +47,8 @@ export default function StatusPieChart({ data }: StatusPieChartProps) { label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%` } + onClick={(entry) => onSliceClick?.(entry.name)} + cursor={onSliceClick ? "pointer" : undefined} > {data.map((_, index) => ( diff --git a/src/components/ui/Modal.tsx b/src/components/ui/Modal.tsx index a5bda499..511348d3 100644 --- a/src/components/ui/Modal.tsx +++ b/src/components/ui/Modal.tsx @@ -61,7 +61,7 @@ export default function Modal({ >
e.stopPropagation()} > {/* Title row */}