diff --git a/ui/index.html b/ui/index.html index 830671e..1ae87a4 100644 --- a/ui/index.html +++ b/ui/index.html @@ -4,7 +4,7 @@ - ui + Dataflow
diff --git a/ui/src/App.jsx b/ui/src/App.jsx index 2740847..c513404 100644 --- a/ui/src/App.jsx +++ b/ui/src/App.jsx @@ -1,26 +1,41 @@ -import { useState, useEffect } from 'react' -import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' +import { useState, useEffect, createElement, lazy, Suspense } 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 BottomNav from './components/BottomNav.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' import Records from './pages/Records' import Log from './pages/Log' -import Pivot from './pages/Pivot' +const Pivot = lazy(() => import('./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 +52,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 +61,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 @@ -127,22 +135,19 @@ export default function App() {
+
+
{/* Main */}
- - {(staleSources.size > 0 || staleStacks.size > 0) && ( -
+
View out of sync: {[...staleSources].map(name => ( @@ -150,20 +155,20 @@ export default function App() { ))} - {staleSources.size > 0 && staleStacks.size > 0 && |} + {staleSources.size > 0 && staleStacks.size > 0 && |} {[...staleStacks].map(name => ( stack: {name} @@ -172,7 +177,7 @@ export default function App() {
)} {reprocessSources.size > 0 && ( -
+
Mappings updated: {[...reprocessSources].map(name => ( @@ -189,22 +194,33 @@ export default function App() {
)} -
+
+ Loading…
}> } /> - } /> - } /> - } /> - } /> + + } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + + + } /> + } /> + } /> + } /> } /> - } /> - } /> - } /> } /> +
+ ) } diff --git a/ui/src/components/BottomNav.jsx b/ui/src/components/BottomNav.jsx new file mode 100644 index 0000000..4a7869c --- /dev/null +++ b/ui/src/components/BottomNav.jsx @@ -0,0 +1,40 @@ +import { NavLink } from 'react-router-dom' +import useTheme from '../theme.jsx' +import { NAV } from './navItems.jsx' + +// Phone-sized replacement for the sidebar: same destinations, thumb-reachable. +// Hidden from md: upward, where the sidebar takes over. +export default function BottomNav() { + const { dark, setDark } = useTheme() + + return ( + + ) +} diff --git a/ui/src/components/SampleTable.jsx b/ui/src/components/SampleTable.jsx new file mode 100644 index 0000000..6d51e5d --- /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..cf01532 --- /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..6741839 100644 --- a/ui/src/components/Sidebar.jsx +++ b/ui/src/components/Sidebar.jsx @@ -1,120 +1,20 @@ import { NavLink } from 'react-router-dom' - -const NAV = [ - { - to: '/sources', - label: 'Sources', - icon: ( - - - - - - ), - }, - { - to: '/import', - label: 'Import', - icon: ( - - - - - - ), - }, - { - to: '/rules', - label: 'Rules', - icon: ( - - - - - - ), - }, - { - to: '/mappings', - label: 'Mappings', - icon: ( - - - - - - - ), - }, - { - to: '/remap', - label: 'Remap', - icon: ( - - - - - - - ), - }, - { - to: '/records', - label: 'Records', - icon: ( - - - - - - ), - }, - { - to: '/pivot', - label: 'Pivot', - icon: ( - - - - - - - ), - }, - { - to: '/stacks', - label: 'Stacks', - icon: ( - - - - - - ), - }, - { - to: '/log', - label: 'Log', - icon: ( - - - - - ), - }, -] +import useTheme from '../theme.jsx' +import { NAV } from './navItems.jsx' export default function Sidebar({ expanded, setExpanded, loginUser, onLogout }) { + const { dark, setDark } = useTheme() + return (
{/* Header */} -
+
Dataflow @@ -141,8 +41,8 @@ export default function Sidebar({ expanded, setExpanded, loginUser, onLogout }) className={({ isActive }) => `flex items-center gap-3 px-2 py-2 rounded w-full transition-colors ${ isActive - ? 'bg-blue-50 text-blue-700' - : 'text-gray-500 hover:bg-gray-100 hover:text-gray-800' + ? 'bg-accent-soft text-accent' + : 'text-muted hover:bg-raised hover:text-ink' }` } > @@ -157,10 +57,41 @@ export default function Sidebar({ expanded, setExpanded, loginUser, onLogout }) ))} + {/* Theme */} +
+ +
+ {/* User / logout */} -
+
{loginUser ? loginUser[0].toUpperCase() : '?'} @@ -169,10 +100,10 @@ export default function Sidebar({ expanded, setExpanded, loginUser, onLogout }) className="flex-1 flex items-center justify-between min-w-0 transition-opacity duration-100" style={{ opacity: expanded ? 1 : 0, pointerEvents: expanded ? 'auto' : 'none', width: expanded ? 'auto' : 0, overflow: 'hidden' }} > - {loginUser} + {loginUser} diff --git a/ui/src/components/SourceTabs.jsx b/ui/src/components/SourceTabs.jsx new file mode 100644 index 0000000..5924c74 --- /dev/null +++ b/ui/src/components/SourceTabs.jsx @@ -0,0 +1,58 @@ +import { NavLink, Outlet, useParams, Link } from 'react-router-dom' + +// Everything scoped to one source lives under /sources/:name, so the source is +// in the URL rather than in a global selector. +const TABS = [ + { to: '', label: 'Setup', end: true }, + { to: 'import', label: 'Import' }, + { to: 'rules', label: 'Rules' }, + { to: 'mappings', label: 'Mappings' }, + { to: 'records', label: 'Records' }, + { to: 'pivot', label: 'Pivot' }, +] + +export default function SourceTabs({ sources }) { + const { name } = useParams() + const sourceObj = sources.find(s => 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/components/navItems.jsx b/ui/src/components/navItems.jsx new file mode 100644 index 0000000..a94ef0e --- /dev/null +++ b/ui/src/components/navItems.jsx @@ -0,0 +1,70 @@ +// Top-level destinations, shared by the desktop sidebar and the mobile bar +export const NAV = [ + { + to: '/sources', + label: 'Sources', + icon: ( + + + + + + ), + }, + { + to: '/import', + label: 'Import', + icon: ( + + + + + + ), + }, + { + to: '/bridge', + label: 'Bridge', + icon: ( + + + + + + + ), + }, + { + to: '/remap', + label: 'Remap', + icon: ( + + + + + + + ), + }, + { + to: '/stacks', + label: 'Stacks', + icon: ( + + + + + + ), + }, + { + to: '/log', + label: 'Log', + icon: ( + + + + + ), + }, +] diff --git a/ui/src/index.css b/ui/src/index.css index d8895b6..0af3036 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -1,5 +1,42 @@ @import "tailwindcss"; +/* + Semantic colour tokens. + + Components name the *role* of a colour (bg-surface, text-muted, border-line) + rather than a literal shade, so light and dark are two sets of variable values + instead of two sets of rules. Adding a component no longer means adding a + matching `.dark` override. + + The raw palette below is the only place actual colours appear. +*/ + +@theme { + --color-canvas: var(--bg-primary); + --color-surface: var(--bg-secondary); + --color-raised: var(--bg-tertiary); + + --color-ink: var(--text-primary); + --color-ink-soft: var(--text-secondary); + --color-muted: var(--text-muted); + + --color-line: var(--border-color); + --color-line-soft: var(--border-light); + + --color-accent: var(--accent-text); + --color-accent-soft: var(--accent-bg); + --color-accent-line: var(--accent-line); + + --color-ok: var(--ok-text); + --color-ok-soft: var(--ok-bg); + --color-warn: var(--warn-text); + --color-warn-soft: var(--warn-bg); + --color-warn-line: var(--warn-line); + --color-danger: var(--danger-text); + --color-danger-soft: var(--danger-bg); + --color-danger-line: var(--danger-line); +} + :root, .light { --bg-primary: #f3f4f6; --bg-secondary: #ffffff; @@ -11,6 +48,16 @@ --border-light: #f3f4f6; --accent-bg: #eff6ff; --accent-text: #1d4ed8; + --accent-line: #bfdbfe; + + --ok-text: #059669; + --ok-bg: #ecfdf5; + --warn-text: #b45309; + --warn-bg: #fffbeb; + --warn-line: #fde68a; + --danger-text: #ef4444; + --danger-bg: #fef2f2; + --danger-line: #fecaca; } /* Dark palette tuned to Perspective's "Pro Dark" theme: @@ -27,6 +74,17 @@ --border-light: #3b3f46; --accent-bg: rgba(39, 113, 170, 0.32); --accent-text: #4778c2; + --accent-line: #2770a9; + + /* Status accents desaturated to sit on Pro Dark's neutral background */ + --ok-text: #6ee7b7; + --ok-bg: #1a3d2c; + --warn-text: #f5c66f; + --warn-bg: #3a2e14; + --warn-line: #5a4a26; + --danger-text: #ff9485; + --danger-bg: #3d1f1f; + --danger-line: #6b3030; } body { @@ -36,62 +94,14 @@ body { color: var(--text-primary); } -.dark .bg-white { background-color: var(--bg-secondary); } -.dark .bg-gray-50 { background-color: var(--bg-tertiary); } -.dark .bg-gray-100 { background-color: var(--bg-tertiary); } -.dark .bg-gray-200 { background-color: var(--bg-tertiary); } -.dark .bg-gray-300 { background-color: var(--bg-tertiary); } -.dark .text-gray-300 { color: var(--text-muted); } -.dark .text-gray-400 { color: var(--text-muted); } -.dark .text-gray-500 { color: var(--text-muted); } -.dark .text-gray-600 { color: var(--text-secondary); } -.dark .text-gray-700 { color: var(--text-secondary); } -.dark .text-gray-800 { color: var(--text-primary); } -.dark .text-gray-900 { color: var(--text-primary); } -.dark .bg-blue-50 { background-color: var(--accent-bg); } -.dark .bg-blue-100 { background-color: var(--accent-bg); } -.dark .text-blue-400 { color: var(--accent-text); } -.dark .text-blue-600 { color: var(--accent-text); } -.dark .text-blue-700 { color: var(--accent-text); } -.dark .text-blue-800 { color: var(--accent-text); } -.dark .border-blue-200 { border-color: var(--accent-text); } -.dark .border-blue-300 { border-color: var(--accent-text); } -.dark .hover\:bg-blue-50:hover { background-color: var(--accent-bg); } +/* Bare border utilities have no colour of their own */ +.border, .border-t, .border-b, .border-l, .border-r { border-color: var(--border-color); } -/* Status accents — desaturated to sit on Pro Dark's neutral background */ -.dark .bg-green-50 { background-color: #1a3d2c; } -.dark .text-green-600 { color: #6ee7b7; } -.dark .text-green-700 { color: #6ee7b7; } -.dark .text-green-400 { color: #6ee7b7; } -.dark .bg-amber-50 { background-color: #3a2e14; } -.dark .text-amber-800 { color: #f5c66f; } -.dark .border-amber-200 { border-color: #5a4a26; } -.dark .bg-amber-200 { background-color: #5a4a26; } -.dark .hover\:bg-amber-300:hover { background-color: #6b5830; } -.dark .bg-red-50 { background-color: #3d1f1f; } -.dark .text-red-500 { color: #ff9485; } -.dark .text-red-700 { color: #ff9485; } -.dark .border-gray-100 { border-color: var(--border-light); } -.dark .border-gray-200 { border-color: var(--border-color); } -.dark .border-gray-300 { border-color: var(--border-color); } -.dark .border-blue-100 { border-color: var(--border-color); } -.dark .border-b { border-color: var(--border-color); } -.dark .border-t { border-color: var(--border-color); } -.dark .border-r { border-color: var(--border-color); } -.dark .border-l { border-color: var(--border-color); } -.dark .hover\:bg-gray-50:hover { background-color: var(--bg-tertiary); } -.dark .hover\:bg-gray-100:hover { background-color: var(--bg-tertiary); } -.dark .hover\:bg-gray-200:hover { background-color: var(--bg-tertiary); } -.dark .hover\:text-gray-500:hover { color: var(--text-secondary); } -.dark .hover\:text-gray-600:hover { color: var(--text-secondary); } -.dark .hover\:text-gray-700:hover { color: var(--text-primary); } -.dark .hover\:text-gray-800:hover { color: var(--text-primary); } -.dark .hover\:border-gray-300:hover { border-color: var(--border-color); } -.dark .hover\:border-gray-400:hover { border-color: var(--border-color); } -.dark .focus\:border-gray-300:focus { border-color: var(--border-color); } -.dark .focus\:border-blue-400:focus { border-color: var(--accent-text); } -.dark ::selection { background-color: var(--accent-bg); color: var(--text-primary); } -.dark input { background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border-color); } -.dark select { background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border-color); } -.dark textarea { background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border-color); } -.dark .bg-transparent { background-color: transparent; } +/* Form controls don't inherit the surface token on their own */ +input, select, textarea { + background-color: var(--bg-secondary); + color: var(--text-primary); + border-color: var(--border-color); +} + +::selection { background-color: var(--accent-bg); color: var(--text-primary); } diff --git a/ui/src/pages/Bridge.jsx b/ui/src/pages/Bridge.jsx new file mode 100644 index 0000000..1030d46 --- /dev/null +++ b/ui/src/pages/Bridge.jsx @@ -0,0 +1,176 @@ +import { useState } from 'react' +import { Link } from 'react-router-dom' +import { api } from '../api' +import Section from '../components/Section.jsx' + +// Accounting style: aligned to 2 decimals, negatives in parentheses +function money(value) { + const n = parseFloat(value) + if (!isFinite(n)) return '—' + const abs = Math.abs(n).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + return n < 0 ? `(${abs})` : abs +} + +// SimpleFIN doesn't report an account type, so retirement accounts are +// recognised from the institution and account names +const RETIREMENT_RE = /401\(?k\)?|403\(?b\)?|\bira\b|retirement|pension|profit sharing/i +const isRetirement = (a) => RETIREMENT_RE.test(`${a.name} ${a.organization || ''}`) + +// 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 sum = (list) => list.reduce((t, a) => t + (parseFloat(a.balance) || 0), 0) + const banking = (accounts || []).filter(a => !isRetirement(a)) + const retirement = (accounts || []).filter(isRetirement) + // Banking first, then retirement, so each subtotal sits under its own rows + const ordered = [...banking, ...retirement] + + return ( +
+
+

Bridge

+ +
+ + {error && ( +
+

{error}

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

Click Refresh to load balances from SimpleFIN.

+ )} + + {accounts && ( +
+ + + + + + + + + + + + {ordered.map(a => { + const src = sourceFor(a.id) + return ( + + + + + + + + ) + })} + + + {banking.length > 0 && ( + + + + + + )} + {retirement.length > 0 && ( + + + + + + )} + + + + + + +
AccountInstitutionBalanceAs ofSource
{a.name}{a.organization}{money(a.balance)}{a.balance_date} + {src + ? {src.name} + : not linked} +
Banking and cards{money(sum(banking))}
Retirement{money(sum(retirement))}
Total{money(sum(accounts))}
+ {/* Stacked cards for narrow screens */} +
+ {ordered.map(a => { + const src = sourceFor(a.id) + return ( +
+
+ {a.name} + {money(a.balance)} +
+
+ {a.organization} + {a.balance_date} +
+
+ {src + ? {src.name} + : not linked} +
+
+ ) + })} +
+ Banking and cards + {money(sum(banking))} +
+
+ Retirement + {money(sum(retirement))} +
+
+ Total + {money(sum(accounts))} +
+
+ + {accounts.length === 0 &&

No accounts returned.

} +
+ )} +
+ ) +} diff --git a/ui/src/pages/Import.jsx b/ui/src/pages/Import.jsx index 651ff32..8bfd263 100644 --- a/ui/src/pages/Import.jsx +++ b/ui/src/pages/Import.jsx @@ -6,7 +6,7 @@ function KeyList({ keys, label, color }) { return (
{label} ({keys.length})
-
+
{keys.map((k, i) => (
{typeof k === 'object' && k !== null @@ -28,19 +28,19 @@ function LogRow({ entry, selected, onToggle }) { return ( <> - + - {entry.id} - {new Date(entry.imported_at).toLocaleString()} - {entry.records_imported} - {entry.records_duplicate} + {entry.id} + {new Date(entry.imported_at).toLocaleString()} + {entry.records_imported} + {entry.records_duplicate} {hasKeys && ( @@ -48,10 +48,10 @@ function LogRow({ entry, selected, onToggle }) { {expanded && ( - + - - + + )} @@ -168,11 +168,11 @@ export default function Import({ source }) { } } - if (!source) return
Select a source first.
+ if (!source) return
Select a source first.
return ( -
-

Import — {source}

+
+

Import — {source}

{/* Stats */} {stats && ( @@ -182,9 +182,9 @@ export default function Import({ source }) { { label: 'Transformed', value: stats.transformed_records }, { label: 'Pending', value: stats.pending_records }, ].map(({ label, value }) => ( -
-
{value}
-
{label}
+
+
{value}
+
{label}
))}
@@ -192,15 +192,15 @@ export default function Import({ source }) { {/* SimpleFIN sync — only for sources with a bridge account in their config */} {simplefin?.account_id && ( -
+
-
SimpleFIN
-
{simplefin.account_id}
+
SimpleFIN
+
{simplefin.account_id}
setUser(e.target.value)} - className="w-full border border-gray-200 rounded px-3 py-2 text-sm focus:outline-none focus:border-blue-400" + className="w-full border border-line rounded px-3 py-2 text-sm focus:outline-none focus:border-accent" required />
- + setPass(e.target.value)} - className="w-full border border-gray-200 rounded px-3 py-2 text-sm focus:outline-none focus:border-blue-400" + className="w-full border border-line rounded px-3 py-2 text-sm focus:outline-none focus:border-accent" required />
- {error &&

{error}

} + {error &&

{error}

} @@ -393,15 +393,15 @@ export default function Mappings({ source, onNeedsReprocess }) { {selectedRule && (
setRowFilter(e.target.value)} /> {rowFilter && !rowFilterError && ( - + {filteredRows.length} )} @@ -435,12 +435,12 @@ export default function Mappings({ source, onNeedsReprocess }) { alert(err.message) } }} - className="text-sm px-3 py-1.5 border border-gray-200 rounded hover:bg-gray-50 text-gray-600" + className="text-sm px-3 py-1.5 border border-line rounded hover:bg-raised text-ink-soft" > Export TSV )} -