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