{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "activity", "type": "registry:block", "title": "Activity block", "description": "One feed over both money rails: the account's transfers interleaved with the company's withdrawals and add-money requests, a labelled Scope column, three bands (In progress / Completed / Didn't complete), live-synced detail panels, unified CSV export, and arrow-key row stepping. Mount UnifiedActivityBlock for the full feed, or ActivityBlock for a transfers-only ledger.", "dependencies": [ "@venlyfinance/react@^0.2.0", "@venlyfinance/sdk@^0.3.0", "@tanstack/react-query@^5.0.0" ], "registryDependencies": [ "@venlyfinance/venly-tokens", "@venlyfinance/money", "@venlyfinance/data-table", "@venlyfinance/status-pill", "@venlyfinance/side-panel", "@venlyfinance/timeline", "@venlyfinance/field-list", "@venlyfinance/withdraw" ], "files": [ { "path": "registry/blocks/activity.tsx", "type": "registry:component", "target": "~/components/venly/blocks/activity.tsx", "content": "import { useEffect, useMemo, useState, type CSSProperties, type ReactElement } from \"react\";\nimport type { FundflowComponents, Transfer } from \"@venlyfinance/sdk\";\nimport { describeRampStatus, useRampRequests, useTransfers } from \"@venlyfinance/react\";\nimport { Money, formatAmount } from \"../lib/money.js\";\nimport { DataTable, RowText, type DataTableColumn, type DataTableGroup } from \"../components/data-table.js\";\nimport { StatusPill, type StatusIntent } from \"../components/status-pill.js\";\nimport { SidePanel } from \"../components/side-panel.js\";\nimport { Timeline, type TimelineStep } from \"../components/timeline.js\";\nimport { FieldList } from \"../components/field-list.js\";\nimport { WITHDRAW_STATUS_PILL } from \"./withdraw.js\";\n\n/**\n * Activity block – the ledger plus its detail panel.\n *\n * Design contract encoded by this block:\n * - Clicking a row opens the side panel; it never navigates. The source\n * row stays tinted while the panel is open.\n * - Pending sits in its own section ABOVE settled; the empty pending\n * section is still drawn, collapsed – \"Pending: 0\" is a state.\n * - The summary strip is one inline stat row (~a table row tall), its\n * figures recompute on every filter, and each figure is a selectable\n * scope switch – the summary IS the primary filter.\n * - Export declares its scope in prose BEFORE offering a format, and the\n * trigger reads \"Export filtered\" while a filter is active.\n * - Amounts are signed relative to the account: money leaving is negative\n * with a true minus, but the level stays tonally neutral – debits are\n * not red; red is reserved for failure.\n * - Status renders as an inline pill after the primary label – zero column\n * width, only on rows that need it (PENDING/FAILED; COMPLETED rows stay\n * quiet because success is the default, not news).\n * - The failure explanation lives where the status is: the panel timeline's\n * terminal node carries the error message.\n * - ↑/↓ step the panel through the visible rows without closing it; Esc\n * closes. The footer chips advertise exactly the keys that work.\n */\n\nexport type ActivityScope = \"all\" | \"pending\" | \"failed\";\n\nexport function transferStatusIntent(status: Transfer[\"status\"]): {\n intent: StatusIntent;\n label: string;\n} | null {\n switch (status) {\n case \"PENDING\":\n return { intent: \"pending\", label: \"Pending\" };\n case \"FAILED\":\n return { intent: \"negative\", label: \"Failed\" };\n default:\n return null; // COMPLETED rows stay quiet: colour is a budget\n }\n}\n\nexport function transferTimeline(transfer: Transfer): TimelineStep[] {\n return [\n {\n key: \"created\",\n label: \"Created\",\n meta: transfer.createdAt,\n state: \"completed\",\n },\n {\n key: \"settled\",\n label:\n transfer.status === \"FAILED\"\n ? (transfer.errorMessage ?? \"Transfer failed\")\n : \"Settled\",\n meta: transfer.status === \"COMPLETED\" ? transfer.updatedAt : undefined,\n state:\n transfer.status === \"COMPLETED\"\n ? \"completed\"\n : transfer.status === \"FAILED\"\n ? \"failed\"\n : \"current\",\n },\n ];\n}\n\n/** Direction relative to the account viewing the ledger. */\nexport function transferDirection(transfer: Transfer, accountId: string): \"in\" | \"out\" {\n return transfer.senderAccountId === accountId ? \"out\" : \"in\";\n}\n\n/** Outgoing money is negative; the LEVEL stays tonally neutral either way. */\nexport function signedTransferAmount(\n transfer: Transfer,\n accountId?: string,\n): number | undefined {\n if (transfer.amount === undefined) return undefined;\n if (accountId && transferDirection(transfer, accountId) === \"out\") return -transfer.amount;\n return transfer.amount;\n}\n\n/** The scope switch the summary strip drives. */\nexport function scopeTransfers(transfers: Transfer[], scope: ActivityScope): Transfer[] {\n switch (scope) {\n case \"pending\":\n return transfers.filter((t) => t.status === \"PENDING\");\n case \"failed\":\n return transfers.filter((t) => t.status === \"FAILED\");\n default:\n return transfers;\n }\n}\n\n/** Recomputed on every filter change – aggregates must never go stale. */\nexport function activitySummary(transfers: Transfer[]): {\n transfers: number;\n pending: number;\n failed: number;\n} {\n return {\n transfers: transfers.length,\n pending: transfers.filter((t) => t.status === \"PENDING\").length,\n failed: transfers.filter((t) => t.status === \"FAILED\").length,\n };\n}\n\n/** Scope in prose BEFORE the format choice – the reader must know what\n * leaves the app before choosing how it leaves. */\nexport function exportScopeSentence(count: number, total: number): string {\n return count === total\n ? `Exports all ${total} transfer${total === 1 ? \"\" : \"s\"} on this account.`\n : `Exports the ${count} transfer${count === 1 ? \"\" : \"s\"} matching your current filters, out of ${total}.`;\n}\n\nfunction csvField(value: unknown): string {\n const s = value === undefined || value === null ? \"\" : String(value);\n return /[\",\\n]/.test(s) ? `\"${s.replaceAll('\"', '\"\"')}\"` : s;\n}\n\nexport function transfersToCsv(transfers: Transfer[], accountId?: string): string {\n const header = \"id,createdAt,direction,asset,chain,amount,status,description,reference\";\n const lines = transfers.map((t) =>\n [\n t.id,\n t.createdAt,\n accountId ? transferDirection(t, accountId) : \"\",\n t.asset,\n t.chain,\n signedTransferAmount(t, accountId),\n t.status,\n t.description,\n t.merchantReference,\n ]\n .map(csvField)\n .join(\",\"),\n );\n return [header, ...lines].join(\"\\n\");\n}\n\n/** The activity table's group for a transfer: pending above settled. */\nexport function transferGroupKey(transfer: Transfer): \"pending\" | \"settled\" {\n return transfer.status === \"PENDING\" ? \"pending\" : \"settled\";\n}\n\n/**\n * The ids the keyboard stepper may visit: exactly the rows the grouped\n * table renders. A collapsed group renders no rows, so its ids are\n * excluded – the stepper must never select a row with no , or the\n * \"source row stays tinted\" contract silently breaks.\n */\nexport function visibleTransferIds(\n transfers: Transfer[],\n collapsedGroups: Record,\n): string[] {\n return transfers\n .filter((t) => !collapsedGroups[transferGroupKey(t)])\n .map((t) => t.id ?? \"\");\n}\n\n/** Row-stepping: ↑/↓ move through the visible rows; never wraps. */\nexport function stepSelection(\n visibleIds: string[],\n currentId: string | null,\n delta: 1 | -1,\n): string | null {\n if (visibleIds.length === 0) return null;\n if (currentId === null) return delta === 1 ? visibleIds[0] : null;\n const index = visibleIds.indexOf(currentId);\n if (index === -1) return visibleIds[0];\n const next = index + delta;\n if (next < 0 || next >= visibleIds.length) return currentId;\n return visibleIds[next];\n}\n\nfunction transferColumns(accountId?: string): DataTableColumn[] {\n return [\n {\n key: \"what\",\n header: \"Transfer\",\n cell: (t) => {\n const status = transferStatusIntent(t.status);\n return (\n \n \n {status ? : null}\n \n );\n },\n },\n { key: \"date\", header: \"Date\", cell: (t) => t.createdAt?.slice(0, 10) },\n {\n key: \"amount\",\n header: \"Amount\",\n money: true,\n cell: (t) => {\n const amount = signedTransferAmount(t, accountId);\n return amount === undefined ? null : ;\n },\n },\n ];\n}\n\nexport function ActivityTable({\n transfers,\n selectedId,\n onSelect,\n accountId,\n}: {\n transfers: Transfer[];\n selectedId?: string;\n onSelect?: (transfer: Transfer) => void;\n /** Signs amounts relative to this account when provided. */\n accountId?: string;\n}): ReactElement {\n return (\n t.id ?? \"\"}\n selectedKey={selectedId}\n onRowClick={onSelect}\n emptyMessage=\"No transfers yet\"\n />\n );\n}\n\n/** Pending in its own section above settled; empty sections still drawn. */\nexport function GroupedActivityTable({\n transfers,\n selectedId,\n onSelect,\n accountId,\n collapsedGroups,\n onGroupToggle,\n}: {\n transfers: Transfer[];\n selectedId?: string;\n onSelect?: (transfer: Transfer) => void;\n accountId?: string;\n /** Controlled collapse state – required when a stepper reads the rows. */\n collapsedGroups?: Record;\n onGroupToggle?: (key: string, collapsed: boolean) => void;\n}): ReactElement {\n const groups: DataTableGroup[] = [\n {\n key: \"pending\",\n label: \"Pending\",\n rows: transfers.filter((t) => transferGroupKey(t) === \"pending\"),\n attention: true,\n },\n {\n key: \"settled\",\n label: \"Settled\",\n rows: transfers.filter((t) => transferGroupKey(t) === \"settled\"),\n },\n ];\n return (\n t.id ?? \"\"}\n selectedKey={selectedId}\n onRowClick={onSelect}\n />\n );\n}\n\nexport function TransferDetailPanel({\n transfer,\n onClose,\n accountId,\n}: {\n transfer: Transfer;\n onClose: () => void;\n /** Signs the hero amount relative to this account when provided. */\n accountId?: string;\n}): ReactElement {\n return (\n \n \n Status\n \n \n {transfer.transactionHash ? (\n \n Transaction {transfer.transactionHash}\n

\n ) : null}\n \n );\n}\n\nfunction StatFigure({\n label,\n value,\n selected,\n onClick,\n}: {\n label: string;\n value: number;\n selected: boolean;\n onClick: () => void;\n}): ReactElement {\n return (\n \n {label}\n \n {value}\n \n \n );\n}\n\nfunction triggerDownload(filename: string, mime: string, content: string): void {\n const url = URL.createObjectURL(new Blob([content], { type: mime }));\n const a = document.createElement(\"a\");\n a.href = url;\n a.download = filename;\n a.click();\n URL.revokeObjectURL(url);\n}\n\n// ── Unified activity (transfers + company ramps, one feed) ─────────────\n//\n// The neobank has two ledgers with different scopes: finance transfers\n// belong to the selected account; ramp requests (withdrawals, add money)\n// belong to the company - the API carries no account linkage for them.\n// One feed renders both, and a labelled Scope column says which is which\n// instead of guessing a linkage that doesn't exist.\n\ntype fundflow = FundflowComponents[\"schemas\"];\nexport type RampActivityItem = fundflow[\"RampRequestListItem\"];\n\nexport type UnifiedActivityRow =\n | { kind: \"transfer\"; key: string; createdAt?: string; transfer: Transfer }\n | { kind: \"ramp\"; key: string; createdAt?: string; ramp: RampActivityItem };\n\n/** Merge is presentation-only: keys namespace the source ledger. */\nexport function unifyActivity(\n transfers: Transfer[],\n ramps: RampActivityItem[],\n): UnifiedActivityRow[] {\n const rows: UnifiedActivityRow[] = [\n ...transfers.map((transfer) => ({\n kind: \"transfer\" as const,\n key: `transfer:${transfer.id ?? \"\"}`,\n createdAt: transfer.createdAt,\n transfer,\n })),\n ...ramps.map((ramp) => ({\n kind: \"ramp\" as const,\n key: `ramp:${ramp.id ?? \"\"}`,\n createdAt: ramp.createdAt,\n ramp,\n })),\n ];\n return rows.sort((a, b) => (b.createdAt ?? \"\").localeCompare(a.createdAt ?? \"\"));\n}\n\n/**\n * Three bands - a failed movement never sits under a success header.\n * BLOCKED is a live hold, not a terminal, so it waits with In progress.\n */\nexport type UnifiedBand = \"pending\" | \"completed\" | \"incomplete\";\n\nexport const UNIFIED_BAND_LABELS: Record = {\n pending: \"In progress\",\n completed: \"Completed\",\n incomplete: \"Didn't complete\",\n};\n\nconst RAMP_PENDING = new Set([\"AWAITING_APPROVAL\", \"AWAITING_FUNDS\", \"PROCESSING\", \"BLOCKED\"]);\n\nexport function unifiedBand(row: UnifiedActivityRow): UnifiedBand {\n if (row.kind === \"transfer\") {\n if (row.transfer.status === \"PENDING\") return \"pending\";\n return row.transfer.status === \"COMPLETED\" ? \"completed\" : \"incomplete\";\n }\n const status = row.ramp.status ?? \"\";\n if (RAMP_PENDING.has(status)) return \"pending\";\n return status === \"SUCCEEDED\" ? \"completed\" : \"incomplete\";\n}\n\n/** Type label: which rail, said in the Move-money surface's own words. */\nexport function unifiedTypeLabel(row: UnifiedActivityRow, accountId?: string): string {\n if (row.kind === \"ramp\") return row.ramp.rampType === \"OFF_RAMP\" ? \"Withdrawal\" : \"Add money\";\n if (accountId && transferDirection(row.transfer, accountId) === \"out\") return \"Transfer sent\";\n return \"Transfer received\";\n}\n\n/**\n * Failed counts rejection as refusal (NAV vocabulary maps it negative,\n * the intent-twin of declined); a cancellation stays neutral and out.\n */\nexport function unifiedSummary(rows: UnifiedActivityRow[]): {\n all: number;\n pending: number;\n failed: number;\n} {\n const failed = rows.filter((row) =>\n row.kind === \"transfer\"\n ? row.transfer.status === \"FAILED\"\n : [\"FAILED\", \"DENIED\", \"REJECTED\"].includes(row.ramp.status ?? \"\"),\n ).length;\n return {\n all: rows.length,\n pending: rows.filter((row) => unifiedBand(row) === \"pending\").length,\n failed,\n };\n}\n\nexport type UnifiedTypeFilter = \"all\" | \"transfers\" | \"withdrawals\" | \"add-money\";\n\nexport function filterUnified(\n rows: UnifiedActivityRow[],\n type: UnifiedTypeFilter,\n scope: ActivityScope,\n): UnifiedActivityRow[] {\n let out = rows;\n if (type === \"transfers\") out = out.filter((r) => r.kind === \"transfer\");\n if (type === \"withdrawals\") out = out.filter((r) => r.kind === \"ramp\" && r.ramp.rampType === \"OFF_RAMP\");\n if (type === \"add-money\") out = out.filter((r) => r.kind === \"ramp\" && r.ramp.rampType === \"ON_RAMP\");\n if (scope === \"pending\") out = out.filter((r) => unifiedBand(r) === \"pending\");\n if (scope === \"failed\") {\n out = out.filter((r) =>\n r.kind === \"transfer\"\n ? r.transfer.status === \"FAILED\"\n : [\"FAILED\", \"DENIED\", \"REJECTED\"].includes(r.ramp.status ?? \"\"),\n );\n }\n return out;\n}\n\n/**\n * Signed amount for the row's primary (crypto) figure. Direction is\n * carried by this sign + the Type label; the fiat side stays unsigned\n * because the list carries GROSS fiat, not the net the bank receives.\n * A pre-settlement Add money row stays unsigned - nothing has been\n * credited yet, and the band carries that meaning.\n */\nexport function rampSigned(ramp: RampActivityItem): { amount: number; signed: boolean } | undefined {\n if (ramp.cryptoAmount === undefined) return undefined;\n if (ramp.rampType === \"OFF_RAMP\") return { amount: -ramp.cryptoAmount, signed: true };\n if (ramp.status === \"SUCCEEDED\") return { amount: ramp.cryptoAmount, signed: true };\n return { amount: ramp.cryptoAmount, signed: false };\n}\n\nexport function unifiedToCsv(rows: UnifiedActivityRow[], accountId?: string, accountName?: string): string {\n const header = \"source,id,reference,type,date,scope,amount,currency,convertedAmount,convertedCurrency,status\";\n const lines = rows.map((row) => {\n if (row.kind === \"transfer\") {\n const t = row.transfer;\n return [\n \"transfer\",\n t.id,\n t.merchantReference,\n accountId ? `Transfer ${transferDirection(t, accountId) === \"out\" ? \"sent\" : \"received\"}` : \"Transfer\",\n t.createdAt,\n accountName ?? accountId,\n signedTransferAmount(t, accountId),\n t.asset,\n // Transfers carry no fiat leg - the columns stay honestly empty.\n \"\",\n \"\",\n t.status,\n ]\n .map(csvField)\n .join(\",\");\n }\n const r = row.ramp;\n return [\n \"ramp\",\n r.id,\n r.paymentReference,\n r.rampType === \"OFF_RAMP\" ? \"Withdrawal\" : \"Add money\",\n r.createdAt,\n \"Company-wide\",\n rampSigned(r)?.amount,\n r.cryptoCurrency,\n // Gross converted amount, as carried by the record; the net the bank\n // receives lives on the withdrawal detail.\n r.fiatAmount,\n r.fiatCurrency,\n r.status,\n ]\n .map(csvField)\n .join(\",\");\n });\n return [header, ...lines].join(\"\\n\");\n}\n\nexport function unifiedColumns(accountId?: string, accountName?: string): DataTableColumn[] {\n return [\n {\n key: \"what\",\n header: \"Activity\",\n cell: (row) => {\n const label = unifiedTypeLabel(row, accountId);\n if (row.kind === \"transfer\") {\n const status = transferStatusIntent(row.transfer.status);\n return (\n \n \n {status ? : null}\n \n );\n }\n const pill = WITHDRAW_STATUS_PILL[row.ramp.status ?? \"\"];\n // Success stays quiet in the table (colour is a budget); every\n // other state carries its word.\n const quiet = row.ramp.status === \"SUCCEEDED\";\n return (\n \n \n {pill && !quiet ? : null}\n \n );\n },\n },\n {\n key: \"scope\",\n header: \"Scope\",\n cell: (row) =>\n row.kind === \"transfer\" ? (accountName ?? \"This account\") : \"Company-wide\",\n },\n { key: \"date\", header: \"Date\", cell: (row) => row.createdAt?.slice(0, 10) },\n {\n key: \"amount\",\n header: \"Amount\",\n money: true,\n cell: (row) => {\n if (row.kind === \"transfer\") {\n const amount = signedTransferAmount(row.transfer, accountId);\n return amount === undefined ? null : ;\n }\n const signed = rampSigned(row.ramp);\n return (\n \n {signed ? (\n \n {/* Settled credits carry an explicit +, symmetric with the − debits render. */}\n {signed.signed && signed.amount > 0 ? (\n +\n ) : null}\n \n \n ) : null}\n {row.ramp.fiatAmount !== undefined ? (\n \n {formatAmount(row.ramp.fiatAmount)} {row.ramp.fiatCurrency}\n \n ) : null}\n \n );\n },\n },\n ];\n}\n\n/** Ramp side panel: list-item fields only; the entity page owns the rest. */\nexport function RampActivityPanel({\n ramp,\n onClose,\n onOpenWithdrawal,\n}: {\n ramp: RampActivityItem;\n onClose: () => void;\n /** OFF_RAMP only - Add money has no entity page yet. */\n onOpenWithdrawal?: (id: string) => void;\n}): ReactElement {\n const descriptor = describeRampStatus(ramp.status);\n const signed = rampSigned(ramp);\n return (\n 0 ? \"+\" : undefined}\n currency={ramp.cryptoCurrency}\n qualifier={ramp.paymentReference}\n onClose={onClose}\n >\n {descriptor ? (\n

\n {descriptor.explanation}\n

\n ) : null}\n \n {ramp.rampType === \"OFF_RAMP\" && onOpenWithdrawal && ramp.id ? (\n onOpenWithdrawal(ramp.id as string)}\n style={{\n marginTop: \"var(--space-md)\",\n border: \"var(--border-w-hairline) solid var(--border-strong)\",\n background: \"var(--surface-raised)\",\n color: \"var(--text-primary)\",\n borderRadius: \"var(--radius-control)\",\n padding: \"var(--space-2xs) var(--space-md)\",\n fontSize: \"var(--font-size-label)\",\n fontFamily: \"var(--font-family)\",\n cursor: \"pointer\",\n }}\n >\n View withdrawal\n \n ) : null}\n \n );\n}\n\n/** One feed over both ledgers - a withdrawal you just created is activity too. */\nexport function UnifiedActivityBlock({\n accountId,\n accountName,\n initialScope = \"all\",\n onOpenWithdrawal,\n style,\n className,\n}: {\n accountId: string;\n /** Renders in the Scope column for transfer rows. */\n accountName?: string;\n initialScope?: ActivityScope;\n /** Entity drill for OFF_RAMP panel rows, e.g. navigate to the withdrawal. */\n onOpenWithdrawal?: (id: string) => void;\n style?: CSSProperties;\n className?: string;\n}): ReactElement {\n const { data: transferData, isPending: transfersPending } = useTransfers(accountId);\n const { data: rampData, isPending: rampsPending } = useRampRequests();\n const [scope, setScope] = useState(initialScope);\n const [typeFilter, setTypeFilter] = useState(\"all\");\n const [collapsedGroups, setCollapsedGroups] = useState>({});\n const [selectedKey, setSelectedKey] = useState(null);\n const [exportOpen, setExportOpen] = useState(false);\n\n const all = useMemo(\n () => unifyActivity(transferData?.items ?? [], rampData?.items ?? []),\n [transferData, rampData],\n );\n const summary = useMemo(() => unifiedSummary(all), [all]);\n const visible = useMemo(() => filterUnified(all, typeFilter, scope), [all, typeFilter, scope]);\n const visibleOrdered = useMemo(() => {\n const order: UnifiedBand[] = [\"pending\", \"completed\", \"incomplete\"];\n return order.flatMap((band) => visible.filter((row) => unifiedBand(row) === band));\n }, [visible]);\n const steppableKeys = useMemo(\n () => visibleOrdered.filter((row) => !collapsedGroups[unifiedBand(row)]).map((row) => row.key),\n [visibleOrdered, collapsedGroups],\n );\n const selected =\n selectedKey && steppableKeys.includes(selectedKey)\n ? (visibleOrdered.find((row) => row.key === selectedKey) ?? null)\n : null;\n const filtered = typeFilter !== \"all\" || scope !== \"all\";\n\n useEffect(() => {\n if (!selected) return;\n const onKey = (event: KeyboardEvent) => {\n const target = event.target as HTMLElement | null;\n if (target && /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName)) return;\n if (event.key === \"Escape\") {\n setSelectedKey(null);\n } else if (event.key === \"ArrowDown\" || event.key === \"ArrowUp\") {\n event.preventDefault();\n setSelectedKey((current) =>\n stepSelection(steppableKeys, current, event.key === \"ArrowDown\" ? 1 : -1),\n );\n }\n };\n document.addEventListener(\"keydown\", onKey);\n return () => document.removeEventListener(\"keydown\", onKey);\n }, [selected, steppableKeys]);\n\n const groups: DataTableGroup[] = (\n [\"pending\", \"completed\", \"incomplete\"] as UnifiedBand[]\n ).map((band) => ({\n key: band,\n label: UNIFIED_BAND_LABELS[band],\n rows: visible.filter((row) => unifiedBand(row) === band),\n attention: band === \"pending\" && visible.some((row) => unifiedBand(row) === band),\n }));\n\n const toggleScope = (next: ActivityScope) => {\n setScope((current) => (current === next ? \"all\" : next));\n };\n\n if (transfersPending || rampsPending) {\n return (\n
\n

Loading activity…

\n
\n );\n }\n\n return (\n
\n
\n setScope(\"all\")} />\n toggleScope(\"pending\")} />\n toggleScope(\"failed\")} />\n\n
\n setTypeFilter(e.target.value as UnifiedTypeFilter)}\n style={{\n fontFamily: \"var(--font-family)\",\n fontSize: \"var(--font-size-label)\",\n color: typeFilter !== \"all\" ? \"var(--text-primary)\" : \"var(--text-secondary)\",\n background: typeFilter !== \"all\" ? \"var(--selected-tint)\" : \"var(--surface-raised)\",\n border: \"var(--border-w-hairline) solid var(--border-hairline)\",\n borderRadius: \"var(--radius-control)\",\n padding: \"var(--space-2xs) var(--space-sm)\",\n }}\n >\n \n \n \n \n \n
\n setExportOpen((open) => !open)}\n style={{\n fontFamily: \"var(--font-family)\",\n fontSize: \"var(--font-size-label)\",\n color: \"var(--text-primary)\",\n background: \"var(--surface-raised)\",\n border: \"var(--border-w-hairline) solid var(--border-hairline)\",\n borderRadius: \"var(--radius-control)\",\n padding: \"var(--space-2xs) var(--space-sm)\",\n cursor: \"pointer\",\n }}\n >\n {filtered ? \"Export filtered\" : \"Export\"}\n \n {exportOpen ? (\n \n

\n Exports the {visible.length} rows matching your current filters – this\n account’s transfers plus the company’s withdrawals and Add money\n rows – out of {all.length}.\n

\n
\n {\n triggerDownload(\"activity.csv\", \"text/csv\", unifiedToCsv(visible, accountId, accountName));\n setExportOpen(false);\n }}\n style={{\n fontFamily: \"var(--font-family)\",\n fontSize: \"var(--font-size-label)\",\n fontWeight: 500,\n color: \"var(--accent-fg)\",\n background: \"var(--accent)\",\n border: \"none\",\n borderRadius: \"var(--radius-control)\",\n padding: \"var(--space-xs) var(--space-md)\",\n cursor: \"pointer\",\n }}\n >\n CSV\n \n
\n
\n ) : null}\n
\n
\n \n\n {all.length === 0 ? (\n

\n No activity yet. Transfers and withdrawals appear here as soon as they’re created.\n

\n ) : visible.length === 0 ? (\n

\n Nothing matches these filters.{\" \"}\n {\n setTypeFilter(\"all\");\n setScope(\"all\");\n }}\n style={{\n border: \"none\",\n background: \"none\",\n cursor: \"pointer\",\n color: \"var(--text-primary)\",\n fontSize: \"var(--font-size-body)\",\n fontFamily: \"var(--font-family)\",\n textDecoration: \"underline\",\n padding: 0,\n }}\n >\n Clear filters\n \n

\n ) : (\n setCollapsedGroups((c) => ({ ...c, [key]: isCollapsed }))}\n rowKey={(row) => row.key}\n selectedKey={selected?.key}\n onRowClick={(row) => setSelectedKey(row.key)}\n />\n )}\n\n {selected?.kind === \"transfer\" ? (\n setSelectedKey(null)}\n />\n ) : null}\n {selected?.kind === \"ramp\" ? (\n setSelectedKey(null)}\n onOpenWithdrawal={onOpenWithdrawal}\n />\n ) : null}\n
\n );\n}\n\n/** Ledger + panel, bound to the account's transfers. */\nexport function ActivityBlock({\n accountId,\n initialScope = \"all\",\n style,\n className,\n}: {\n accountId: string;\n /** Landing scope, e.g. \"pending\" when arriving from a reserved drill. */\n initialScope?: ActivityScope;\n style?: CSSProperties;\n className?: string;\n}): ReactElement {\n const { data, isPending } = useTransfers(accountId);\n const [scope, setScope] = useState(initialScope);\n const [assetFilter, setAssetFilter] = useState(null);\n const [exportOpen, setExportOpen] = useState(false);\n // Collapse state is lifted out of the table so the keyboard stepper\n // knows exactly which rows are rendered.\n const [collapsedGroups, setCollapsedGroups] = useState>({});\n // Selection is held by id and re-derived from the live list on every\n // render: when a refetch moves a transfer from PENDING to COMPLETED or\n // FAILED while its panel is open, the panel shows the new state, not a\n // snapshot from click time.\n const [selectedId, setSelectedId] = useState(null);\n\n const all = useMemo(() => data?.items ?? [], [data]);\n const assets = useMemo(\n () => [...new Set(all.map((t) => t.asset).filter((a): a is string => Boolean(a)))],\n [all],\n );\n\n // Filter order: asset first, then scope. The summary recomputes after\n // the asset filter so its figures always describe what the strip can\n // switch between, never a stale superset.\n const assetFiltered = useMemo(\n () => (assetFilter ? all.filter((t) => t.asset === assetFilter) : all),\n [all, assetFilter],\n );\n const summary = useMemo(() => activitySummary(assetFiltered), [assetFiltered]);\n const visible = useMemo(() => scopeTransfers(assetFiltered, scope), [assetFiltered, scope]);\n // Display order mirrors the grouped table: pending above settled.\n const visibleOrdered = useMemo(\n () => [\n ...visible.filter((t) => t.status === \"PENDING\"),\n ...visible.filter((t) => t.status !== \"PENDING\"),\n ],\n [visible],\n );\n\n // The stepper may only visit rows the table actually renders: a\n // selection inside a collapsed group would tint no row at all.\n const steppableIds = useMemo(\n () => visibleTransferIds(visibleOrdered, collapsedGroups),\n [visibleOrdered, collapsedGroups],\n );\n const selected =\n selectedId && steppableIds.includes(selectedId)\n ? (visibleOrdered.find((t) => t.id === selectedId) ?? null)\n : null;\n const filtered = assetFilter !== null || scope !== \"all\";\n\n // ↑/↓ step the open panel through the visible rows; Esc closes. The\n // listener exists only while the panel is open, and never fights a\n // focused form control.\n useEffect(() => {\n if (!selected) return;\n const onKey = (event: KeyboardEvent) => {\n const target = event.target as HTMLElement | null;\n if (target && /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName)) return;\n if (event.key === \"Escape\") {\n setSelectedId(null);\n } else if (event.key === \"ArrowDown\" || event.key === \"ArrowUp\") {\n event.preventDefault();\n setSelectedId((current) =>\n stepSelection(steppableIds, current, event.key === \"ArrowDown\" ? 1 : -1),\n );\n }\n };\n document.addEventListener(\"keydown\", onKey);\n return () => document.removeEventListener(\"keydown\", onKey);\n }, [selected, steppableIds]);\n\n const toggleScope = (next: ActivityScope) => {\n setScope((current) => (current === next ? \"all\" : next));\n };\n\n return (\n
\n {isPending ? (\n

Loading activity…

\n ) : (\n <>\n {/* Summary strip: one inline stat row, no containers, under a\n table row tall. Each figure is the scope switch. */}\n \n setScope(\"all\")}\n />\n toggleScope(\"pending\")}\n />\n toggleScope(\"failed\")}\n />\n\n
\n {assets.length > 1 ? (\n setAssetFilter(e.target.value === \"\" ? null : e.target.value)}\n style={{\n fontFamily: \"var(--font-family)\",\n fontSize: \"var(--font-size-label)\",\n color: assetFilter ? \"var(--text-primary)\" : \"var(--text-secondary)\",\n background: assetFilter ? \"var(--selected-tint)\" : \"var(--surface-raised)\",\n border: \"var(--border-w-hairline) solid var(--border-hairline)\",\n borderRadius: \"var(--radius-control)\",\n padding: \"var(--space-2xs) var(--space-sm)\",\n }}\n >\n \n {assets.map((asset) => (\n \n ))}\n \n ) : null}\n {assetFilter ? (\n setAssetFilter(null)}\n style={{\n border: \"none\",\n background: \"none\",\n cursor: \"pointer\",\n color: \"var(--text-secondary)\",\n fontSize: \"var(--font-size-label)\",\n padding: 0,\n }}\n >\n ✕\n \n ) : null}\n
\n setExportOpen((open) => !open)}\n style={{\n fontFamily: \"var(--font-family)\",\n fontSize: \"var(--font-size-label)\",\n color: \"var(--text-primary)\",\n background: \"var(--surface-raised)\",\n border: \"var(--border-w-hairline) solid var(--border-hairline)\",\n borderRadius: \"var(--radius-control)\",\n padding: \"var(--space-2xs) var(--space-sm)\",\n cursor: \"pointer\",\n }}\n >\n {filtered ? \"Export filtered\" : \"Export\"}\n \n {exportOpen ? (\n \n {/* Scope first, format second: the reader must know what\n leaves the app before choosing how. */}\n \n {exportScopeSentence(visible.length, all.length)}\n

\n
\n {(\n [\n [\"CSV\", \"text/csv\", () => transfersToCsv(visible, accountId), \"transfers.csv\"],\n [\"JSON\", \"application/json\", () => JSON.stringify(visible, null, 2), \"transfers.json\"],\n ] as const\n ).map(([label, mime, make, filename]) => (\n {\n triggerDownload(filename, mime, make());\n setExportOpen(false);\n }}\n style={{\n fontFamily: \"var(--font-family)\",\n fontSize: \"var(--font-size-label)\",\n fontWeight: 500,\n color: \"var(--accent-fg)\",\n background: \"var(--accent)\",\n border: \"none\",\n borderRadius: \"var(--radius-control)\",\n padding: \"var(--space-xs) var(--space-md)\",\n cursor: \"pointer\",\n }}\n >\n {label}\n \n ))}\n
\n
\n ) : null}\n
\n \n \n\n setSelectedId(t.id ?? null)}\n collapsedGroups={collapsedGroups}\n onGroupToggle={(key, isCollapsed) =>\n setCollapsedGroups((c) => ({ ...c, [key]: isCollapsed }))\n }\n />\n \n )}\n {selected ? (\n setSelectedId(null)}\n />\n ) : null}\n
\n );\n}\n" } ] }