From aa9315fdd261b94a931f9fdee3988d057bc27e09 Mon Sep 17 00:00:00 2001 From: Paul Trowbridge Date: Sun, 2 Aug 2026 12:25:13 -0400 Subject: [PATCH] Put the source in the URL and drop the global status bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selecting a source from a bar at the top meant every page was implicitly scoped to a hidden bit of state. The source is now a route parameter: /sources lists them, /sources/:name owns the source, and Import, Rules, Mappings, Records, and Pivot are tabs beneath it. Sources.jsx split into SourceList and SourceDetail, with Section and SampleTable extracted so the create dialog and the detail page share them. The detail page is grouped into titled panels — Connection, Fields and view, Sample rows, Maintenance, Delete — rather than one flat form. Two new top-level pages, following how Monarch separates these: - Import, because importing is frequent and configuring a source is not. Every source in one list, with a sync button for bank feeds. - Bridge, because one SimpleFIN credential covers every account, so connection state belongs in one place rather than per source. Loads only when asked, since it queries SimpleFIN, and totals the balances. The dark mode toggle lived in the status bar and moved to the sidebar. The out-of-sync and reprocess banners are unchanged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G2HFeU5neCKagTnmA6o9Tu --- ui/src/App.jsx | 60 ++- ui/src/components/SampleTable.jsx | 28 ++ ui/src/components/Section.jsx | 13 + ui/src/components/Sidebar.jsx | 80 +-- ui/src/components/SourceTabs.jsx | 58 +++ ui/src/components/StatusBar.jsx | 73 --- ui/src/pages/Bridge.jsx | 111 +++++ ui/src/pages/ImportHub.jsx | 129 +++++ ui/src/pages/SourceDetail.jsx | 424 ++++++++++++++++ ui/src/pages/SourceList.jsx | 388 +++++++++++++++ ui/src/pages/Sources.jsx | 780 ------------------------------ ui/src/pages/Stacks.jsx | 4 + 12 files changed, 1230 insertions(+), 918 deletions(-) create mode 100644 ui/src/components/SampleTable.jsx create mode 100644 ui/src/components/Section.jsx create mode 100644 ui/src/components/SourceTabs.jsx delete mode 100644 ui/src/components/StatusBar.jsx create mode 100644 ui/src/pages/Bridge.jsx create mode 100644 ui/src/pages/ImportHub.jsx create mode 100644 ui/src/pages/SourceDetail.jsx create mode 100644 ui/src/pages/SourceList.jsx delete mode 100644 ui/src/pages/Sources.jsx diff --git a/ui/src/App.jsx b/ui/src/App.jsx index 2740847..b2b46fb 100644 --- a/ui/src/App.jsx +++ b/ui/src/App.jsx @@ -1,10 +1,13 @@ -import { useState, useEffect } from 'react' -import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' +import { useState, useEffect, createElement } from 'react' +import { BrowserRouter, Routes, Route, Navigate, useParams } from 'react-router-dom' import { api, setCredentials, clearCredentials } from './api' -import StatusBar from './components/StatusBar.jsx' import Sidebar from './components/Sidebar.jsx' +import SourceTabs from './components/SourceTabs.jsx' import Login from './pages/Login' -import Sources from './pages/Sources' +import SourceList from './pages/SourceList' +import SourceDetail from './pages/SourceDetail' +import Bridge from './pages/Bridge' +import ImportHub from './pages/ImportHub' import Import from './pages/Import' import Rules from './pages/Rules' import Mappings from './pages/Mappings' @@ -14,13 +17,24 @@ import Pivot from './pages/Pivot' import Remap from './pages/Remap' import Stacks from './pages/Stacks' +// Source-scoped pages still take a `source` prop; this reads it off the URL so +// they didn't all need rewriting when selection moved out of the status bar. +function ScopedToSource({ component, ...props }) { + const { name } = useParams() + return createElement(component, { source: name, ...props }) +} + +// Pivot doubles as the stack viewer; a stack in the URL takes precedence there +function StackPivot() { + const { name } = useParams() + return {}} /> +} + export default function App() { const [authed, setAuthed] = useState(false) const [loginUser, setLoginUser] = useState('') const [sources, setSources] = useState([]) - const [stacks, setStacks] = useState([]) const [source, setSource] = useState(() => localStorage.getItem('selectedSource') || '') - const [selectedStack, setSelectedStack] = useState(null) const [sidebarExpanded, setSidebarExpanded] = useState(() => localStorage.getItem('df_sidebar') !== 'collapsed') // Sets of names whose dfv view is out of sync with current definitions const [staleSources, setStaleSources] = useState(new Set()) @@ -37,7 +51,6 @@ export default function App() { if (!source && s.length > 0) setSource(s[0].name) setAuthed(true) setLoginUser(user) - api.getStacks().then(setStacks).catch(() => {}) } function handleLogout() { @@ -47,17 +60,11 @@ export default function App() { setAuthed(false) setLoginUser('') setSources([]) - setStacks([]) - setSelectedStack(null) setStaleSources(new Set()) setStaleStacks(new Set()) setReprocessSources(new Set()) } - function refreshStacks() { - api.getStacks().then(setStacks).catch(() => {}) - } - // Load initial stale state from DB once on login useEffect(() => { if (!authed) return @@ -136,11 +143,6 @@ export default function App() { {/* Main */}
- - {(staleSources.size > 0 || staleStacks.size > 0) && (
View out of sync: @@ -192,14 +194,22 @@ export default function App() {
} /> - } /> - } /> - } /> - } /> + + } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + + + } /> + } /> + } /> + } /> } /> - } /> - } /> - } /> } />
diff --git a/ui/src/components/SampleTable.jsx b/ui/src/components/SampleTable.jsx new file mode 100644 index 0000000..4992630 --- /dev/null +++ b/ui/src/components/SampleTable.jsx @@ -0,0 +1,28 @@ +// Compact preview of raw record values — used by the source detail page and by +// the create dialog when a CSV or bank feed is sampled +export default function SampleTable({ rows }) { + if (!rows || rows.length === 0) return null + const cols = Object.keys(rows[0]) + return ( +
+ + + + {cols.map(c => )} + + + + {rows.map((row, i) => ( + + {cols.map(c => ( + + ))} + + ))} + +
{c}
+ {row[c] == null ? : String(row[c])} +
+
+ ) +} diff --git a/ui/src/components/Section.jsx b/ui/src/components/Section.jsx new file mode 100644 index 0000000..b52762d --- /dev/null +++ b/ui/src/components/Section.jsx @@ -0,0 +1,13 @@ +// One titled panel per job, so setup, schema, and destructive actions read as +// separate things rather than one flat form +export default function Section({ title, description, children }) { + return ( +
+

{title}

+ {description + ?

{description}

+ :
} + {children} +
+ ) +} diff --git a/ui/src/components/Sidebar.jsx b/ui/src/components/Sidebar.jsx index 1c5683f..d4abbd0 100644 --- a/ui/src/components/Sidebar.jsx +++ b/ui/src/components/Sidebar.jsx @@ -1,4 +1,5 @@ import { NavLink } from 'react-router-dom' +import useTheme from '../theme.jsx' const NAV = [ { @@ -24,25 +25,14 @@ const NAV = [ ), }, { - to: '/rules', - label: 'Rules', + to: '/bridge', + label: 'Bridge', icon: ( - - - - - ), - }, - { - to: '/mappings', - label: 'Mappings', - icon: ( - - - - - + + + + ), }, @@ -58,29 +48,6 @@ const NAV = [ ), }, - { - to: '/records', - label: 'Records', - icon: ( - - - - - - ), - }, - { - to: '/pivot', - label: 'Pivot', - icon: ( - - - - - - - ), - }, { to: '/stacks', label: 'Stacks', @@ -105,6 +72,8 @@ const NAV = [ ] export default function Sidebar({ expanded, setExpanded, loginUser, onLogout }) { + const { dark, setDark } = useTheme() + return (
+ {/* Theme */} +
+ +
+ {/* User / logout */}
s.name === name) + const base = `/sources/${encodeURIComponent(name)}` + + return ( +
+
+
+ Sources + / +

{name}

+ {sourceObj?.config?.simplefin?.account_id && ( + + bank feed + + )} +
+ + +
+ +
+ +
+
+ ) +} diff --git a/ui/src/components/StatusBar.jsx b/ui/src/components/StatusBar.jsx deleted file mode 100644 index 2042699..0000000 --- a/ui/src/components/StatusBar.jsx +++ /dev/null @@ -1,73 +0,0 @@ -import { NavLink } from 'react-router-dom' -import useTheme from '../theme.jsx' - -export default function StatusBar({ sources = [], source, setSource, stacks = [], selectedStack, setSelectedStack }) { - const { dark, setDark } = useTheme() - - return ( -
- Source - - + - - {stacks.length > 0 && ( - <> - | - Stacks - {stacks.map(s => ( - - ))} - - )} - -
- -
-
- ) -} diff --git a/ui/src/pages/Bridge.jsx b/ui/src/pages/Bridge.jsx new file mode 100644 index 0000000..7ad9a6a --- /dev/null +++ b/ui/src/pages/Bridge.jsx @@ -0,0 +1,111 @@ +import { useState } from 'react' +import { Link } from 'react-router-dom' +import { api } from '../api' +import Section from '../components/Section.jsx' + +// One SimpleFIN bridge covers every linked bank account, so connection state is +// a bridge-level concern rather than something to hunt for source by source. +export default function Bridge({ sources }) { + const [accounts, setAccounts] = useState(null) + const [errors, setErrors] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + + async function load() { + setLoading(true) + setError('') + try { + const res = await api.getSimpleFinAccounts() + setAccounts(res.accounts || []) + setErrors(res.errors || []) + } catch (err) { + setError(err.message) + } finally { + setLoading(false) + } + } + + // Which source, if any, pulls from each account + const sourceFor = (accountId) => + sources.find(s => s.config?.simplefin?.account_id === accountId) + + const total = (accounts || []).reduce((sum, a) => sum + (parseFloat(a.balance) || 0), 0) + + return ( +
+
+

Bridge

+ +
+ + {error && ( +
+

{error}

+
+ )} + + {errors.length > 0 && ( +
+ {errors.map((e, i) =>
{e}
)} +
+ )} + + {!accounts && !loading && !error && ( +

Click Refresh to load balances from SimpleFIN.

+ )} + + {accounts && ( +
+ + + + + + + + + + + + {accounts.map(a => { + const src = sourceFor(a.id) + return ( + + + + + + + + ) + })} + + + + + + + + +
AccountInstitutionBalanceAs ofSource
{a.name}{a.organization}{a.balance}{a.balance_date} + {src + ? {src.name} + : not linked} +
Total + {total.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} +
+ {accounts.length === 0 &&

No accounts returned.

} +
+ )} +
+ ) +} diff --git a/ui/src/pages/ImportHub.jsx b/ui/src/pages/ImportHub.jsx new file mode 100644 index 0000000..fed85d2 --- /dev/null +++ b/ui/src/pages/ImportHub.jsx @@ -0,0 +1,129 @@ +import { useState, useEffect } from 'react' +import { Link } from 'react-router-dom' +import { api } from '../api' + +// Importing is the frequent job; configuring a source is the rare one. This is +// the top-level entry point for the frequent one — every source in one place, +// with a sync button for anything on a bank feed. +export default function ImportHub({ sources }) { + const [stats, setStats] = useState({}) // name -> stats + const [lastImport, setLastImport] = useState({}) // name -> ISO timestamp + const [busy, setBusy] = useState('') + const [results, setResults] = useState({}) // name -> message + const [errors, setErrors] = useState({}) // name -> message + + useEffect(() => { + let cancelled = false + Promise.all(sources.map(s => + api.getStats(s.name).then(st => [s.name, st]).catch(() => [s.name, null]) + )).then(pairs => { + if (!cancelled) setStats(Object.fromEntries(pairs)) + }) + api.getAllImportLog().then(log => { + if (cancelled) return + const latest = {} + for (const entry of log) { + if (!latest[entry.source_name] || entry.imported_at > latest[entry.source_name]) { + latest[entry.source_name] = entry.imported_at + } + } + setLastImport(latest) + }).catch(() => {}) + return () => { cancelled = true } + }, [sources]) + + async function sync(name) { + setBusy(name) + setErrors(e => ({ ...e, [name]: '' })) + setResults(r => ({ ...r, [name]: '' })) + try { + const res = await api.syncSimpleFin(name, { days: 10 }) + setResults(r => ({ + ...r, + [name]: `${res.imported} imported, ${res.duplicates} already had` + + (res.errors?.length ? ` — ${res.errors.join('; ')}` : ''), + })) + api.getStats(name).then(st => setStats(s => ({ ...s, [name]: st }))).catch(() => {}) + api.getAllImportLog().then(log => { + const entry = log.find(l => l.source_name === name) + if (entry) setLastImport(l => ({ ...l, [name]: entry.imported_at })) + }).catch(() => {}) + } catch (err) { + setErrors(e => ({ ...e, [name]: err.message })) + } finally { + setBusy('') + } + } + + const feeds = sources.filter(s => s.config?.simplefin?.account_id) + const manual = sources.filter(s => !s.config?.simplefin?.account_id) + + function Row({ s, isFeed }) { + const st = stats[s.name] + const when = lastImport[s.name] + return ( +
+
+ + {s.name} + +
+ {st ? `${st.total_records} records` : '—'} + {st && Number(st.pending_records) > 0 && ` · ${st.pending_records} untransformed`} + {when && ` · last import ${new Date(when).toLocaleDateString()}`} +
+
+ + {results[s.name] && {results[s.name]}} + {errors[s.name] && {errors[s.name]}} + + {isFeed ? ( + + ) : ( + + Upload CSV + + )} +
+ ) + } + + return ( +
+

Import

+ + {feeds.length > 0 && ( +
+

Bank feeds

+
+ {feeds.map(s => )} +
+

Syncs pull the last 10 days; use a source’s Import tab to backfill further.

+
+ )} + + {manual.length > 0 && ( +
+

CSV sources

+
+ {manual.map(s => )} +
+
+ )} + + {sources.length === 0 &&

No sources yet.

} + + + Full import history → + +
+ ) +} diff --git a/ui/src/pages/SourceDetail.jsx b/ui/src/pages/SourceDetail.jsx new file mode 100644 index 0000000..404baae --- /dev/null +++ b/ui/src/pages/SourceDetail.jsx @@ -0,0 +1,424 @@ +import { useState, useEffect } from 'react' +import { useParams, useNavigate } from 'react-router-dom' +import { api } from '../api' +import Section from '../components/Section.jsx' +import SampleTable from '../components/SampleTable.jsx' + +const FIELD_TYPES = ['text', 'numeric', 'date'] + +export default function SourceDetail({ sources, setSources }) { + const { name: source } = useParams() + const navigate = useNavigate() + const [constraintFields, setConstraintFields] = useState('') + const [globalPicklist, setGlobalPicklist] = useState(true) + const [schemaFields, setSchemaFields] = useState([]) + const [stats, setStats] = useState(null) + const [sampleRows, setSampleRows] = useState([]) + const [saving, setSaving] = useState(false) + const [reprocessing, setReprocessing] = useState(false) + const [generating, setGenerating] = useState(false) + const [result, setResult] = useState('') + const [error, setError] = useState('') + const [viewName, setViewName] = useState('') + const [availableFields, setAvailableFields] = useState([]) + const [fieldSort, setFieldSort] = useState({ col: 'key', dir: 'asc' }) + const [bridgeAccounts, setBridgeAccounts] = useState(null) + const [bridgeLoading, setBridgeLoading] = useState(false) + const [bridgeError, setBridgeError] = useState('') + + const sourceObj = sources.find(s => s.name === source) + + useEffect(() => { + if (!sourceObj) return + setConstraintFields(sourceObj.constraint_fields?.join(', ') || '') + setGlobalPicklist(sourceObj.global_picklist !== false) + setSchemaFields((sourceObj.config?.fields || []).map((f, i) => ({ seq: i + 1, ...f }))) + setViewName(sourceObj.config?.fields?.length ? `dfv.${sourceObj.name}` : '') + setResult('') + setError('') + setStats(null) + setAvailableFields([]) + setSampleRows([]) + setBridgeAccounts(null) + setBridgeError('') + api.getStats(sourceObj.name).then(setStats).catch(() => {}) + api.getFields(sourceObj.name).then(setAvailableFields).catch(() => {}) + api.getRecords(sourceObj.name, 50).then(rows => setSampleRows(rows.map(r => r.data).filter(Boolean))).catch(() => {}) + }, [source, sourceObj?.name]) + + + async function handleSave(e) { + e.preventDefault() + setSaving(true) + setError('') + try { + const constraint_fields = constraintFields.split(',').map(s => s.trim()).filter(Boolean) + const fields = [...schemaFields.filter(f => f.name)].sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0)) + const config = { ...(sourceObj.config || {}), fields } + await api.updateSource(sourceObj.name, { constraint_fields, config, global_picklist: globalPicklist }) + if (fields.length > 0) { + const res = await api.generateView(sourceObj.name) + if (res.success) setViewName(res.view) + } + const updated = await api.getSources() + setSources(updated) + setResult('Saved.') + } catch (err) { + setError(err.message) + } finally { + setSaving(false) + } + } + + async function handleGenerateView() { + setGenerating(true) + setResult('') + setError('') + try { + const constraint_fields = constraintFields.split(',').map(s => s.trim()).filter(Boolean) + const fields = [...schemaFields.filter(f => f.name)].sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0)) + const config = { ...(sourceObj.config || {}), fields } + await api.updateSource(sourceObj.name, { constraint_fields, config, global_picklist: globalPicklist }) + const res = await api.generateView(sourceObj.name) + if (res.success) { + setViewName(res.view) + setResult(`View created: ${res.view}`) + } else { + setError(res.error) + } + } catch (err) { + setError(err.message) + } finally { + setGenerating(false) + } + } + + async function handleReprocess() { + if (!confirm(`Reprocess all records for "${sourceObj.name}"? This will clear and reapply all transformations.`)) return + setReprocessing(true) + setResult('') + setError('') + try { + const res = await api.reprocess(sourceObj.name) + setResult(`Reprocessed ${res.transformed} records.`) + api.getStats(sourceObj.name).then(setStats).catch(() => {}) + } catch (err) { + setError(err.message) + } finally { + setReprocessing(false) + } + } + + async function handleDelete() { + if (!confirm(`Delete source "${sourceObj.name}" and all its data?`)) return + try { + await api.deleteSource(sourceObj.name) + setSources(await api.getSources()) + navigate('/sources') + } catch (err) { + alert(err.message) + } + } + + // The bridge is an external call, so accounts are fetched on demand rather + // than on every visit to this page. + async function loadBridgeAccounts() { + setBridgeLoading(true) + setBridgeError('') + try { + const res = await api.getSimpleFinAccounts() + setBridgeAccounts(res.accounts || []) + if (res.errors?.length) setBridgeError(res.errors.join('; ')) + } catch (err) { + setBridgeError(err.message) + } finally { + setBridgeLoading(false) + } + } + + // Writes only config — constraint_fields and global_picklist are left NULL so + // update_source keeps whatever is already stored. + async function handleLinkAccount(accountId) { + setError('') + setResult('') + try { + const config = { ...(sourceObj.config || {}) } + if (accountId) { + config.simplefin = { ...(config.simplefin || {}), account_id: accountId } + } else { + delete config.simplefin + } + await api.updateSource(sourceObj.name, { config }) + setSources(await api.getSources()) + setResult(accountId ? 'Account linked.' : 'Account unlinked.') + } catch (err) { + setError(err.message) + } + } + + if (!sourceObj) return
Source not found.
+ + return ( +
+ {stats && ( +
+ {stats.total_records} total + {stats.transformed_records} transformed + {stats.pending_records} pending +
+ )} + + {/* Bank feed — link this source to a SimpleFIN account */} +
+
+ {bridgeAccounts === null ? ( + <> + + {sourceObj.config?.simplefin?.account_id || 'not linked'} + + + + ) : ( + + )} +
+ + {bridgeError &&

{bridgeError}

} + + {/* Dedupe depends on the transaction id being the constraint key */} + {sourceObj.config?.simplefin?.account_id + && sourceObj.constraint_fields?.join(',') !== 'id' && ( +

+ Constraint fields are “{sourceObj.constraint_fields?.join(', ') || 'none'}” — a + bank feed should use “id” so re-syncs don’t duplicate rows. +

+ )} +
+ + {/* Unified field table */} + {availableFields.length > 0 && ( +
+ + + + {[ + { col: 'key', label: 'Key' }, + { col: 'origin', label: 'Origin' }, + { col: 'type', label: 'Type' }, + { col: 'constraint', label: 'Constraint', center: true }, + { col: 'inview', label: 'In view', center: true }, + { col: 'seq', label: 'Seq', center: true }, + ].map(({ col, label, center }) => ( + + ))} + + + + {[...availableFields].sort((a, b) => { + const constraintList = constraintFields.split(',').map(s => s.trim()) + const aSchema = schemaFields.find(sf => sf.name === a.key) + const bSchema = schemaFields.find(sf => sf.name === b.key) + let av, bv + if (fieldSort.col === 'key') { av = a.key; bv = b.key } + else if (fieldSort.col === 'origin') { av = a.origins.join(','); bv = b.origins.join(',') } + else if (fieldSort.col === 'type') { av = aSchema?.type || ''; bv = bSchema?.type || '' } + else if (fieldSort.col === 'constraint') { av = constraintList.includes(a.key) ? 0 : 1; bv = constraintList.includes(b.key) ? 0 : 1 } + else if (fieldSort.col === 'inview') { av = aSchema ? 0 : 1; bv = bSchema ? 0 : 1 } + else if (fieldSort.col === 'seq') { av = aSchema?.seq ?? 999; bv = bSchema?.seq ?? 999 } + if (av < bv) return fieldSort.dir === 'asc' ? -1 : 1 + if (av > bv) return fieldSort.dir === 'asc' ? 1 : -1 + return 0 + }).map(f => { + const isRaw = f.origins.includes('raw') + const constraintChecked = constraintFields.split(',').map(s => s.trim()).includes(f.key) + const schemaEntry = schemaFields.find(sf => sf.name === f.key) + const inView = !!schemaEntry + return ( + + + + + + + + + ) + })} + +
setFieldSort(s => ({ col, dir: s.col === col && s.dir === 'asc' ? 'desc' : 'asc' }))} + className={`pb-1 font-medium cursor-pointer select-none hover:text-gray-600 ${center ? 'text-center' : ''}`} + > + {label} + + {fieldSort.col === col ? (fieldSort.dir === 'asc' ? '▲' : '▼') : '⇅'} + +
{f.key}{f.origins.join(', ')} + {inView && ( +
+ + setSchemaFields(sf => + sf.map(s => s.name === f.key ? { ...s, expression: e.target.value || undefined } : s) + )} + /> +
+ )} +
+ {isRaw && ( + { + const current = constraintFields.split(',').map(s => s.trim()).filter(Boolean) + const next = e.target.checked + ? [...current, f.key] + : current.filter(k => k !== f.key) + setConstraintFields(next.join(', ')) + }} + /> + )} + + { + if (e.target.checked) { + const nextSeq = schemaFields.length > 0 + ? Math.max(...schemaFields.map(s => s.seq ?? 0)) + 1 + : 1 + setSchemaFields(sf => [...sf, { name: f.key, type: 'text', seq: nextSeq }]) + } else { + setSchemaFields(sf => sf.filter(s => s.name !== f.key)) + } + }} + /> + + {inView && ( + setSchemaFields(sf => + sf.map(s => s.name === f.key ? { ...s, seq: parseInt(e.target.value) || 0 } : s) + )} + /> + )} +
+ +
+ +
+ +
+ {schemaFields.length > 0 && ( + <> + + {viewName && ( + {viewName} + )} + + )} +
+
+ )} + + {/* Save button when no fields loaded yet */} + {availableFields.length === 0 && ( +
+
+ +
+ +
+
+
+ )} + + {sampleRows.length > 0 && ( +
+ +
+ )} + +
+
+ + Clears and reruns all transformation rules +
+
+ + {result &&

{result}

} + {error &&

{error}

} + +
+ +
+
+ ) +} diff --git a/ui/src/pages/SourceList.jsx b/ui/src/pages/SourceList.jsx new file mode 100644 index 0000000..2daba4a --- /dev/null +++ b/ui/src/pages/SourceList.jsx @@ -0,0 +1,388 @@ +import { useState, useRef } from 'react' +import { useNavigate } from 'react-router-dom' +import { api } from '../api' +import SampleTable from '../components/SampleTable.jsx' + +const FIELD_TYPES = ['text', 'numeric', 'date'] + +// Ticked into the view by default when a bank feed sample contains them +const FEED_DEFAULT_VIEW = ['date', 'description', 'payee', 'amount'] + +export default function SourceList({ sources, setSources, setSource }) { + const navigate = useNavigate() + const [creating, setCreating] = useState(false) + const [form, setForm] = useState({ name: '', constraint_fields: '', fields: [], schema: [], importSample: true }) + const [createError, setCreateError] = useState('') + const [createLoading, setCreateLoading] = useState(false) + const [csvFileName, setCsvFileName] = useState('') + const [bridgeAccounts, setBridgeAccounts] = useState(null) + const [bridgeLoading, setBridgeLoading] = useState(false) + const [bridgeError, setBridgeError] = useState('') + const [sampleInfo, setSampleInfo] = useState(null) + const fileRef = useRef() + + async function loadBridgeAccounts() { + setBridgeLoading(true) + setBridgeError('') + try { + const res = await api.getSimpleFinAccounts() + setBridgeAccounts(res.accounts || []) + if (res.errors?.length) setBridgeError(res.errors.join('; ')) + } catch (err) { + setBridgeError(err.message) + } finally { + setBridgeLoading(false) + } + } + + // Writes only config — constraint_fields and global_picklist are left NULL so + // update_source keeps whatever is already stored. + async function handleSelectFeedAccount(accountId) { + if (!accountId) { + // Clearing the feed only resets fields we populated, not a loaded CSV + setForm(f => csvFileName ? { ...f, simplefin_account_id: '' } : { + ...f, simplefin_account_id: '', fields: [], schema: [], constraint_fields: '', sampleRows: [], + }) + setSampleInfo(null) + return + } + + setForm(f => ({ ...f, simplefin_account_id: accountId })) + setBridgeLoading(true) + setBridgeError('') + try { + const res = await api.getSimpleFinSample(accountId) + const names = res.fields.map(f => f.name) + setSampleInfo({ fetched: res.fetched, fields: res.fields.length }) + if (res.errors?.length) setBridgeError(res.errors.join('; ')) + setForm(f => ({ + ...f, + fields: res.fields, + sampleRows: res.sampleRows || [], + schema: FEED_DEFAULT_VIEW.filter(n => names.includes(n)).map((name, i) => ({ + name, type: res.fields.find(sf => sf.name === name).type, seq: i + 1, + })), + // Only default the constraint if the sample actually has an id + constraint_fields: f.constraint_fields || (names.includes('id') ? 'id' : ''), + })) + } catch (err) { + setBridgeError(err.message) + } finally { + setBridgeLoading(false) + } + } + + async function handleSuggest(e) { + const file = e.target.files[0] + if (!file) return + setCsvFileName(file.name) + try { + const suggestion = await api.suggestSource(file) + setForm(f => ({ + ...f, + fields: suggestion.fields, + constraint_fields: '', + schema: suggestion.fields.map(f => ({ name: f.name, type: f.type, seq: suggestion.fields.indexOf(f) + 1 })), + sampleRows: suggestion.sampleRows || [] + })) + } catch (err) { + setCreateError(err.message) + } + } + + async function handleCreate(e) { + e.preventDefault() + setCreateError('') + const constraintArr = form.constraint_fields.split(',').map(s => s.trim()).filter(Boolean) + if (!form.name || constraintArr.length === 0) { + setCreateError('Name and at least one constraint field required') + return + } + setCreateLoading(true) + try { + const config = form.schema.length > 0 ? { fields: form.schema } : {} + if (form.simplefin_account_id) { + config.simplefin = { account_id: form.simplefin_account_id } + } + await api.createSource({ name: form.name, constraint_fields: constraintArr, config, global_picklist: form.global_picklist !== false }) + if (form.schema.length > 0) { + await api.generateView(form.name) + } + if (form.importSample && fileRef.current?.files[0]) { + await api.importCSV(form.name, fileRef.current.files[0]) + } + const updated = await api.getSources() + setSources(updated) + setSource(form.name) + setForm({ name: '', constraint_fields: '', fields: [], schema: [], importSample: true, simplefin_account_id: '' }) + setCreating(false) + } catch (err) { + setCreateError(err.message) + } finally { + setCreateLoading(false) + } + } + + return ( +
+
+

Sources

+ {!creating && ( + + )} +
+ + {!creating && sources.length === 0 && ( +

No sources yet. Create one to get started.

+ )} + + {!creating && sources.length > 0 && ( +
+ {sources.map(s => ( + + ))} +
+ )} + + {creating && ( +
+

New source

+ +
+ + +
+ +
+
+ + setForm(f => ({ ...f, name: e.target.value }))} + placeholder="e.g. chase, dcard" + /> +
+ + {/* Bank feed — optional; picking an account defaults the constraint + field to the transaction id, which is what dedupe needs */} +
+ +
+ {bridgeAccounts === null ? ( + + ) : ( + + )} +
+ {bridgeError &&

{bridgeError}

} + + {form.simplefin_account_id && sampleInfo && ( +
+

+ Read {sampleInfo.fetched} transaction{sampleInfo.fetched === 1 ? '' : 's'} from this + account and found {sampleInfo.fields} field{sampleInfo.fields === 1 ? '' : 's'}. + The table below lists what this account actually returns — fields it never sends + won’t appear. +

+ {form.constraint_fields === 'id' && ( +

+ id is checked as the constraint + field because it is SimpleFIN’s own transaction identifier. Syncs pull an + overlapping window of days, so the same transaction arrives more than once — + matching on id skips the repeats + while still keeping genuinely separate charges that share a date, amount, and + description. +

+ )} + {sampleInfo.fetched === 0 && ( +

+ No transactions came back, so there was nothing to infer fields from. Sync first, + then set the fields up here. +

+ )} +
+ )} +
+ + {form.fields.length > 0 && ( +
+ + + + + + + + + + + + {form.fields.map(f => { + const schemaEntry = form.schema.find(s => s.name === f.name) + const inView = !!schemaEntry + const currentType = schemaEntry?.type || f.type + return ( + + + + + + + + ) + })} + +
KeyTypeConstraintIn viewSeq
{f.name} + {inView && ( + + )} + + s.trim()).includes(f.name)} + onChange={e => { + const current = form.constraint_fields.split(',').map(s => s.trim()).filter(Boolean) + const next = e.target.checked + ? [...current, f.name] + : current.filter(n => n !== f.name) + setForm(ff => ({ ...ff, constraint_fields: next.join(', ') })) + }} + /> + + { + if (e.target.checked) { + const nextSeq = form.schema.length > 0 + ? Math.max(...form.schema.map(s => s.seq ?? 0)) + 1 + : 1 + setForm(ff => ({ ...ff, schema: [...ff.schema, { name: f.name, type: f.type, seq: nextSeq }] })) + } else { + setForm(ff => ({ ...ff, schema: ff.schema.filter(s => s.name !== f.name) })) + } + }} + /> + + {inView && ( + setForm(ff => ({ + ...ff, + schema: ff.schema.map(s => s.name === f.name ? { ...s, seq: parseInt(e.target.value) || 0 } : s) + }))} + /> + )} +
+ +
+ )} + + {form.fields.length === 0 && ( +
+ + setForm(f => ({ ...f, constraint_fields: e.target.value }))} + placeholder="e.g. date, amount, description" + /> +
+ )} + +
+ + {form.fields.length > 0 && ( + + )} +
+ + {createError &&

{createError}

} + +
+ + +
+
+
+ )} +
+ ) +} diff --git a/ui/src/pages/Sources.jsx b/ui/src/pages/Sources.jsx deleted file mode 100644 index ba5a912..0000000 --- a/ui/src/pages/Sources.jsx +++ /dev/null @@ -1,780 +0,0 @@ -import { useState, useEffect, useRef } from 'react' -import { useSearchParams } from 'react-router-dom' -import { api } from '../api' - -const FIELD_TYPES = ['text', 'numeric', 'date'] - -// Ticked into the view by default when a bank feed sample contains them -const FEED_DEFAULT_VIEW = ['date', 'description', 'payee', 'amount'] - -function SampleTable({ rows }) { - if (!rows || rows.length === 0) return null - const cols = Object.keys(rows[0]) - return ( -
- - - - {cols.map(c => )} - - - - {rows.map((row, i) => ( - - {cols.map(c => ( - - ))} - - ))} - -
{c}
- {row[c] == null ? : String(row[c])} -
-
- ) -} - -export default function Sources({ source, sources, setSources, setSource }) { - const [constraintFields, setConstraintFields] = useState('') - const [globalPicklist, setGlobalPicklist] = useState(true) - const [schemaFields, setSchemaFields] = useState([]) - const [stats, setStats] = useState(null) - const [sampleRows, setSampleRows] = useState([]) - const [saving, setSaving] = useState(false) - const [reprocessing, setReprocessing] = useState(false) - const [generating, setGenerating] = useState(false) - const [result, setResult] = useState('') - const [error, setError] = useState('') - const [viewName, setViewName] = useState('') - const [availableFields, setAvailableFields] = useState([]) - const [fieldSort, setFieldSort] = useState({ col: 'key', dir: 'asc' }) - const [creating, setCreating] = useState(false) - const [form, setForm] = useState({ name: '', constraint_fields: '', fields: [], schema: [], importSample: true }) - const [createError, setCreateError] = useState('') - const [createLoading, setCreateLoading] = useState(false) - const [csvFileName, setCsvFileName] = useState('') - const [bridgeAccounts, setBridgeAccounts] = useState(null) - const [bridgeLoading, setBridgeLoading] = useState(false) - const [bridgeError, setBridgeError] = useState('') - const [sampleInfo, setSampleInfo] = useState(null) - const fileRef = useRef() - - const [searchParams, setSearchParams] = useSearchParams() - const sourceObj = sources.find(s => s.name === source) - - useEffect(() => { - if (searchParams.get('new') === '1') { - setCreating(true) - setSearchParams({}) - } - }, [searchParams]) - - useEffect(() => { - if (!sourceObj) return - setConstraintFields(sourceObj.constraint_fields?.join(', ') || '') - setGlobalPicklist(sourceObj.global_picklist !== false) - setSchemaFields((sourceObj.config?.fields || []).map((f, i) => ({ seq: i + 1, ...f }))) - setViewName(sourceObj.config?.fields?.length ? `dfv.${sourceObj.name}` : '') - setResult('') - setError('') - setStats(null) - setAvailableFields([]) - setSampleRows([]) - setBridgeAccounts(null) - setBridgeError('') - api.getStats(sourceObj.name).then(setStats).catch(() => {}) - api.getFields(sourceObj.name).then(setAvailableFields).catch(() => {}) - api.getRecords(sourceObj.name, 50).then(rows => setSampleRows(rows.map(r => r.data).filter(Boolean))).catch(() => {}) - }, [source, sourceObj?.name]) - - async function handleSave(e) { - e.preventDefault() - setSaving(true) - setError('') - try { - const constraint_fields = constraintFields.split(',').map(s => s.trim()).filter(Boolean) - const fields = [...schemaFields.filter(f => f.name)].sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0)) - const config = { ...(sourceObj.config || {}), fields } - await api.updateSource(sourceObj.name, { constraint_fields, config, global_picklist: globalPicklist }) - if (fields.length > 0) { - const res = await api.generateView(sourceObj.name) - if (res.success) setViewName(res.view) - } - const updated = await api.getSources() - setSources(updated) - setResult('Saved.') - } catch (err) { - setError(err.message) - } finally { - setSaving(false) - } - } - - async function handleGenerateView() { - setGenerating(true) - setResult('') - setError('') - try { - const constraint_fields = constraintFields.split(',').map(s => s.trim()).filter(Boolean) - const fields = [...schemaFields.filter(f => f.name)].sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0)) - const config = { ...(sourceObj.config || {}), fields } - await api.updateSource(sourceObj.name, { constraint_fields, config, global_picklist: globalPicklist }) - const res = await api.generateView(sourceObj.name) - if (res.success) { - setViewName(res.view) - setResult(`View created: ${res.view}`) - } else { - setError(res.error) - } - } catch (err) { - setError(err.message) - } finally { - setGenerating(false) - } - } - - async function handleReprocess() { - if (!confirm(`Reprocess all records for "${sourceObj.name}"? This will clear and reapply all transformations.`)) return - setReprocessing(true) - setResult('') - setError('') - try { - const res = await api.reprocess(sourceObj.name) - setResult(`Reprocessed ${res.transformed} records.`) - api.getStats(sourceObj.name).then(setStats).catch(() => {}) - } catch (err) { - setError(err.message) - } finally { - setReprocessing(false) - } - } - - async function handleDelete() { - if (!confirm(`Delete source "${sourceObj.name}" and all its data?`)) return - try { - await api.deleteSource(sourceObj.name) - const updated = await api.getSources() - setSources(updated) - if (updated.length > 0) setSource(updated[0].name) - else setSource('') - } catch (err) { - alert(err.message) - } - } - - // The bridge is an external call, so accounts are fetched on demand rather - // than on every visit to this page. - async function loadBridgeAccounts() { - setBridgeLoading(true) - setBridgeError('') - try { - const res = await api.getSimpleFinAccounts() - setBridgeAccounts(res.accounts || []) - if (res.errors?.length) setBridgeError(res.errors.join('; ')) - } catch (err) { - setBridgeError(err.message) - } finally { - setBridgeLoading(false) - } - } - - // Writes only config — constraint_fields and global_picklist are left NULL so - // update_source keeps whatever is already stored. - async function handleLinkAccount(accountId) { - setError('') - setResult('') - try { - const config = { ...(sourceObj.config || {}) } - if (accountId) { - config.simplefin = { ...(config.simplefin || {}), account_id: accountId } - } else { - delete config.simplefin - } - await api.updateSource(sourceObj.name, { config }) - setSources(await api.getSources()) - setResult(accountId ? 'Account linked.' : 'Account unlinked.') - } catch (err) { - setError(err.message) - } - } - - // Picking a feed account samples its real transactions and fills the field - // table from them — the same path a CSV takes through /suggest. - async function handleSelectFeedAccount(accountId) { - if (!accountId) { - // Clearing the feed only resets fields we populated, not a loaded CSV - setForm(f => csvFileName ? { ...f, simplefin_account_id: '' } : { - ...f, simplefin_account_id: '', fields: [], schema: [], constraint_fields: '', sampleRows: [], - }) - setSampleInfo(null) - return - } - - setForm(f => ({ ...f, simplefin_account_id: accountId })) - setBridgeLoading(true) - setBridgeError('') - try { - const res = await api.getSimpleFinSample(accountId) - const names = res.fields.map(f => f.name) - setSampleInfo({ fetched: res.fetched, fields: res.fields.length }) - if (res.errors?.length) setBridgeError(res.errors.join('; ')) - setForm(f => ({ - ...f, - fields: res.fields, - sampleRows: res.sampleRows || [], - schema: FEED_DEFAULT_VIEW.filter(n => names.includes(n)).map((name, i) => ({ - name, type: res.fields.find(sf => sf.name === name).type, seq: i + 1, - })), - // Only default the constraint if the sample actually has an id - constraint_fields: f.constraint_fields || (names.includes('id') ? 'id' : ''), - })) - } catch (err) { - setBridgeError(err.message) - } finally { - setBridgeLoading(false) - } - } - - async function handleSuggest(e) { - const file = e.target.files[0] - if (!file) return - setCsvFileName(file.name) - try { - const suggestion = await api.suggestSource(file) - setForm(f => ({ - ...f, - fields: suggestion.fields, - constraint_fields: '', - schema: suggestion.fields.map(f => ({ name: f.name, type: f.type, seq: suggestion.fields.indexOf(f) + 1 })), - sampleRows: suggestion.sampleRows || [] - })) - } catch (err) { - setCreateError(err.message) - } - } - - async function handleCreate(e) { - e.preventDefault() - setCreateError('') - const constraintArr = form.constraint_fields.split(',').map(s => s.trim()).filter(Boolean) - if (!form.name || constraintArr.length === 0) { - setCreateError('Name and at least one constraint field required') - return - } - setCreateLoading(true) - try { - const config = form.schema.length > 0 ? { fields: form.schema } : {} - if (form.simplefin_account_id) { - config.simplefin = { account_id: form.simplefin_account_id } - } - await api.createSource({ name: form.name, constraint_fields: constraintArr, config, global_picklist: form.global_picklist !== false }) - if (form.schema.length > 0) { - await api.generateView(form.name) - } - if (form.importSample && fileRef.current?.files[0]) { - await api.importCSV(form.name, fileRef.current.files[0]) - } - const updated = await api.getSources() - setSources(updated) - setSource(form.name) - setForm({ name: '', constraint_fields: '', fields: [], schema: [], importSample: true, simplefin_account_id: '' }) - setCreating(false) - } catch (err) { - setCreateError(err.message) - } finally { - setCreateLoading(false) - } - } - - return ( -
-
-

- {sourceObj ? sourceObj.name : 'Sources'} -

- -
- - {/* No source selected */} - {!sourceObj && !creating && ( -

No sources yet. Create one to get started.

- )} - - {/* Source detail */} - {sourceObj && !creating && ( -
- {/* Stats */} - {stats && ( -
- {stats.total_records} total - {stats.transformed_records} transformed - {stats.pending_records} pending -
- )} - - {/* Bank feed — link this source to a SimpleFIN account */} -
-
-
Bank feed
- - {bridgeAccounts === null ? ( - <> - - {sourceObj.config?.simplefin?.account_id || 'not linked'} - - - - ) : ( - - )} -
- - {bridgeError &&

{bridgeError}

} - - {/* Dedupe depends on the transaction id being the constraint key */} - {sourceObj.config?.simplefin?.account_id - && sourceObj.constraint_fields?.join(',') !== 'id' && ( -

- Constraint fields are “{sourceObj.constraint_fields?.join(', ') || 'none'}” — a - bank feed should use “id” so re-syncs don’t duplicate rows. -

- )} -
- - {/* Unified field table */} - {availableFields.length > 0 && ( -
- - - - {[ - { col: 'key', label: 'Key' }, - { col: 'origin', label: 'Origin' }, - { col: 'type', label: 'Type' }, - { col: 'constraint', label: 'Constraint', center: true }, - { col: 'inview', label: 'In view', center: true }, - { col: 'seq', label: 'Seq', center: true }, - ].map(({ col, label, center }) => ( - - ))} - - - - {[...availableFields].sort((a, b) => { - const constraintList = constraintFields.split(',').map(s => s.trim()) - const aSchema = schemaFields.find(sf => sf.name === a.key) - const bSchema = schemaFields.find(sf => sf.name === b.key) - let av, bv - if (fieldSort.col === 'key') { av = a.key; bv = b.key } - else if (fieldSort.col === 'origin') { av = a.origins.join(','); bv = b.origins.join(',') } - else if (fieldSort.col === 'type') { av = aSchema?.type || ''; bv = bSchema?.type || '' } - else if (fieldSort.col === 'constraint') { av = constraintList.includes(a.key) ? 0 : 1; bv = constraintList.includes(b.key) ? 0 : 1 } - else if (fieldSort.col === 'inview') { av = aSchema ? 0 : 1; bv = bSchema ? 0 : 1 } - else if (fieldSort.col === 'seq') { av = aSchema?.seq ?? 999; bv = bSchema?.seq ?? 999 } - if (av < bv) return fieldSort.dir === 'asc' ? -1 : 1 - if (av > bv) return fieldSort.dir === 'asc' ? 1 : -1 - return 0 - }).map(f => { - const isRaw = f.origins.includes('raw') - const constraintChecked = constraintFields.split(',').map(s => s.trim()).includes(f.key) - const schemaEntry = schemaFields.find(sf => sf.name === f.key) - const inView = !!schemaEntry - return ( - - - - - - - - - ) - })} - -
setFieldSort(s => ({ col, dir: s.col === col && s.dir === 'asc' ? 'desc' : 'asc' }))} - className={`pb-1 font-medium cursor-pointer select-none hover:text-gray-600 ${center ? 'text-center' : ''}`} - > - {label} - - {fieldSort.col === col ? (fieldSort.dir === 'asc' ? '▲' : '▼') : '⇅'} - -
{f.key}{f.origins.join(', ')} - {inView && ( -
- - setSchemaFields(sf => - sf.map(s => s.name === f.key ? { ...s, expression: e.target.value || undefined } : s) - )} - /> -
- )} -
- {isRaw && ( - { - const current = constraintFields.split(',').map(s => s.trim()).filter(Boolean) - const next = e.target.checked - ? [...current, f.key] - : current.filter(k => k !== f.key) - setConstraintFields(next.join(', ')) - }} - /> - )} - - { - if (e.target.checked) { - const nextSeq = schemaFields.length > 0 - ? Math.max(...schemaFields.map(s => s.seq ?? 0)) + 1 - : 1 - setSchemaFields(sf => [...sf, { name: f.key, type: 'text', seq: nextSeq }]) - } else { - setSchemaFields(sf => sf.filter(s => s.name !== f.key)) - } - }} - /> - - {inView && ( - setSchemaFields(sf => - sf.map(s => s.name === f.key ? { ...s, seq: parseInt(e.target.value) || 0 } : s) - )} - /> - )} -
- -
- -
- -
- {schemaFields.length > 0 && ( - <> - - {viewName && ( - {viewName} - )} - - )} -
- -
- )} - - {/* Save button when no fields loaded yet */} - {availableFields.length === 0 && ( -
- -
- -
-
- )} - - {/* Reprocess */} -
- - Clears and reruns all transformation rules -
- - {result &&

{result}

} - {error &&

{error}

} - -
- -
-
- )} - - {/* Create form */} - {creating && ( -
-

New source

- -
- - -
- -
-
- - setForm(f => ({ ...f, name: e.target.value }))} - placeholder="e.g. chase, dcard" - /> -
- - {/* Bank feed — optional; picking an account defaults the constraint - field to the transaction id, which is what dedupe needs */} -
- -
- {bridgeAccounts === null ? ( - - ) : ( - - )} -
- {bridgeError &&

{bridgeError}

} - - {form.simplefin_account_id && sampleInfo && ( -
-

- Read {sampleInfo.fetched} transaction{sampleInfo.fetched === 1 ? '' : 's'} from this - account and found {sampleInfo.fields} field{sampleInfo.fields === 1 ? '' : 's'}. - The table below lists what this account actually returns — fields it never sends - won’t appear. -

- {form.constraint_fields === 'id' && ( -

- id is checked as the constraint - field because it is SimpleFIN’s own transaction identifier. Syncs pull an - overlapping window of days, so the same transaction arrives more than once — - matching on id skips the repeats - while still keeping genuinely separate charges that share a date, amount, and - description. -

- )} - {sampleInfo.fetched === 0 && ( -

- No transactions came back, so there was nothing to infer fields from. Sync first, - then set the fields up here. -

- )} -
- )} -
- - {form.fields.length > 0 && ( -
- - - - - - - - - - - - {form.fields.map(f => { - const schemaEntry = form.schema.find(s => s.name === f.name) - const inView = !!schemaEntry - const currentType = schemaEntry?.type || f.type - return ( - - - - - - - - ) - })} - -
KeyTypeConstraintIn viewSeq
{f.name} - {inView && ( - - )} - - s.trim()).includes(f.name)} - onChange={e => { - const current = form.constraint_fields.split(',').map(s => s.trim()).filter(Boolean) - const next = e.target.checked - ? [...current, f.name] - : current.filter(n => n !== f.name) - setForm(ff => ({ ...ff, constraint_fields: next.join(', ') })) - }} - /> - - { - if (e.target.checked) { - const nextSeq = form.schema.length > 0 - ? Math.max(...form.schema.map(s => s.seq ?? 0)) + 1 - : 1 - setForm(ff => ({ ...ff, schema: [...ff.schema, { name: f.name, type: f.type, seq: nextSeq }] })) - } else { - setForm(ff => ({ ...ff, schema: ff.schema.filter(s => s.name !== f.name) })) - } - }} - /> - - {inView && ( - setForm(ff => ({ - ...ff, - schema: ff.schema.map(s => s.name === f.name ? { ...s, seq: parseInt(e.target.value) || 0 } : s) - }))} - /> - )} -
- -
- )} - - {form.fields.length === 0 && ( -
- - setForm(f => ({ ...f, constraint_fields: e.target.value }))} - placeholder="e.g. date, amount, description" - /> -
- )} - -
- - {form.fields.length > 0 && ( - - )} -
- - {createError &&

{createError}

} - -
- - -
-
-
- )} -
- ) -} diff --git a/ui/src/pages/Stacks.jsx b/ui/src/pages/Stacks.jsx index 1ce8d00..1fb4ca4 100644 --- a/ui/src/pages/Stacks.jsx +++ b/ui/src/pages/Stacks.jsx @@ -1,3 +1,4 @@ +import { Link } from 'react-router-dom' import { useState, useEffect, useRef } from 'react' import { api } from '../api' import { format as formatSql } from 'sql-formatter' @@ -754,6 +755,9 @@ export default function Stacks({ sources, onStackStale, onStackViewGenerated, on className={`flex items-center gap-2 px-3 py-1.5 rounded border cursor-pointer text-xs group transition-colors ${selected === s.name ? 'border-blue-300 bg-blue-50 text-blue-700' : 'border-gray-200 bg-white text-gray-600 hover:border-gray-300 hover:bg-gray-50'}`}> {s.label || s.name} {s.source_count}s + e.stopPropagation()} + className="opacity-0 group-hover:opacity-100 text-blue-400 hover:text-blue-600">pivot