Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a1e6edc74d | |||
| 3297acf9db | |||
| 1edfbfac43 | |||
| 8753719db8 | |||
| 356e61d043 |
@ -68,8 +68,8 @@ Theme state lives in `ui/src/theme.jsx` — a React context (`ThemeContext`) wit
|
|||||||
`ThemeProvider` that wraps the app in `main.jsx`.
|
`ThemeProvider` that wraps the app in `main.jsx`.
|
||||||
|
|
||||||
- **Storage key:** `df_dark` in `localStorage`; falls back to `window.matchMedia('(prefers-color-scheme: dark)')` on first visit
|
- **Storage key:** `df_dark` in `localStorage`; falls back to `window.matchMedia('(prefers-color-scheme: dark)')` on first visit
|
||||||
- **Toggle:** button in the sidebar header in `App.jsx`; effect writes `localStorage` and toggles the `.dark` class on `<html>`
|
- **Toggle:** button at the foot of the sidebar (`Sidebar.jsx`), and in `BottomNav.jsx` on mobile; the effect writes `localStorage` and toggles the `.dark` class on `<html>`
|
||||||
- **CSS:** `ui/src/index.css` defines CSS custom properties under `:root` (light) and `.dark`. All Tailwind color overrides are written as `.dark .bg-white { ... }` etc.
|
- **CSS:** `ui/src/index.css` declares semantic tokens under `@theme` (`bg-surface`, `text-ink`, `text-muted`, `border-line`, `text-danger`, …) that resolve to CSS custom properties redefined by `.dark`. **Write components against the tokens, never against literal shades like `bg-white` or `text-gray-400`** — the old per-utility `.dark .bg-white { … }` overrides are gone and must not come back
|
||||||
- **Palette:** dark mode uses Perspective's "Pro Dark" colours (`--bg-primary: #242526`, panels `#2a2c2f`, gridlines `#3b3f46`, text `#c5c9d0`)
|
- **Palette:** dark mode uses Perspective's "Pro Dark" colours (`--bg-primary: #242526`, panels `#2a2c2f`, gridlines `#3b3f46`, text `#c5c9d0`)
|
||||||
- **Perspective viewer:** `Pivot.jsx` calls `viewer.setAttribute('theme', dark ? 'Pro Dark' : 'Pro Light')` on initial load and in a `useEffect([dark])` so the viewer stays in sync when the toggle fires
|
- **Perspective viewer:** `Pivot.jsx` calls `viewer.setAttribute('theme', dark ? 'Pro Dark' : 'Pro Light')` on initial load and in a `useEffect([dark])` so the viewer stays in sync when the toggle fires
|
||||||
- **Consuming the theme:** `import useTheme from '../theme.jsx'` then `const { dark, setDark } = useTheme()`
|
- **Consuming the theme:** `import useTheme from '../theme.jsx'` then `const { dark, setDark } = useTheme()`
|
||||||
|
|||||||
49
docs/spec.md
49
docs/spec.md
@ -59,10 +59,14 @@ api/
|
|||||||
ui/
|
ui/
|
||||||
src/
|
src/
|
||||||
api.js — fetch wrapper, credential management
|
api.js — fetch wrapper, credential management
|
||||||
App.jsx — root: login gate, sidebar, source selector, routing
|
App.jsx — root: login gate, routing, stale/reprocess banners
|
||||||
|
index.css — semantic colour tokens for light and dark
|
||||||
pages/
|
pages/
|
||||||
Login.jsx — username/password form
|
Login.jsx — username/password form
|
||||||
Sources.jsx — source CRUD, field config, view generation
|
SourceList.jsx — source list and the create dialog
|
||||||
|
SourceDetail.jsx — one source: connection, fields, view, maintenance
|
||||||
|
Bridge.jsx — SimpleFIN accounts, balances, and subtotals
|
||||||
|
ImportHub.jsx — all sources with sync / upload actions
|
||||||
Import.jsx — CSV upload, SimpleFIN sync, and import log
|
Import.jsx — CSV upload, SimpleFIN sync, and import log
|
||||||
Rules.jsx — rule CRUD with live pattern preview
|
Rules.jsx — rule CRUD with live pattern preview
|
||||||
Mappings.jsx — mapping table with TSV import/export
|
Mappings.jsx — mapping table with TSV import/export
|
||||||
@ -71,7 +75,7 @@ ui/
|
|||||||
Stacks.jsx — multi-source union views with running balance
|
Stacks.jsx — multi-source union views with running balance
|
||||||
Remap.jsx — bulk remap of an output field value across mappings
|
Remap.jsx — bulk remap of an output field value across mappings
|
||||||
Log.jsx — global import log across all sources
|
Log.jsx — global import log across all sources
|
||||||
components/ — Sidebar, StatusBar
|
components/ — Sidebar, BottomNav, navItems, SourceTabs, Section, SampleTable
|
||||||
theme.jsx — light/dark context provider
|
theme.jsx — light/dark context provider
|
||||||
public/ — compiled UI (output of npm run build in ui/)
|
public/ — compiled UI (output of npm run build in ui/)
|
||||||
docs/ — this file, tutorial, UI and Perspective references
|
docs/ — this file, tutorial, UI and Perspective references
|
||||||
@ -327,11 +331,43 @@ Built with React + Vite + Tailwind CSS. Compiled output goes to `public/`. The s
|
|||||||
3. On 401 response, credentials are cleared and the login screen is shown.
|
3. On 401 response, credentials are cleared and the login screen is shown.
|
||||||
4. `localStorage` persists the selected source name across sessions.
|
4. `localStorage` persists the selected source name across sessions.
|
||||||
|
|
||||||
|
**Navigation.** The selected source is a route parameter, not global state: `/sources`
|
||||||
|
lists sources, `/sources/:name` owns one, and Import, Rules, Mappings, Records, and Pivot
|
||||||
|
are tabs beneath it (`components/SourceTabs.jsx`). The sidebar holds only top-level
|
||||||
|
destinations — Sources, Import, Bridge, Remap, Stacks, Log — defined once in
|
||||||
|
`components/navItems.jsx` and rendered by `Sidebar.jsx` on desktop and `BottomNav.jsx`
|
||||||
|
below the `md:` breakpoint. Out-of-sync and reprocess banners render above every page from
|
||||||
|
`App.jsx`.
|
||||||
|
|
||||||
|
**Colour.** Components use semantic tokens (`bg-surface`, `text-ink`, `text-muted`,
|
||||||
|
`border-line`, `text-danger`) declared in `index.css` under `@theme`, which resolve to CSS
|
||||||
|
variables redefined by `.dark`. There are no per-utility `.dark` override rules; a new
|
||||||
|
component gets both themes for free.
|
||||||
|
|
||||||
|
**Bundle.** `Pivot` is loaded with `React.lazy`, keeping Perspective (~4.7 MB gzipped) out
|
||||||
|
of the initial download and in a chunk fetched only when a pivot is opened.
|
||||||
|
|
||||||
**Pages:**
|
**Pages:**
|
||||||
|
|
||||||
- **Sources** — View and edit source configuration. Shows all known field names and their origins (raw data, schema, rules, mappings). Checkboxes control which fields are constraint fields and which appear in the output view. Supports CSV upload to auto-detect fields.
|
- **Sources** (`SourceList.jsx`) — Lists every source with its constraint fields and a
|
||||||
|
badge for bank feeds; clicking opens it. "New source" opens the create dialog, which can
|
||||||
|
seed fields from a CSV sample or from a linked SimpleFIN account.
|
||||||
|
|
||||||
- **Import** — Upload a CSV to import records into the selected source. Transformations run automatically on new records. Shows import log with inserted/duplicate counts, expandable key detail, checkbox selection, and delete with confirmation. Sources with `config.simplefin.account_id` also get a Sync panel — a window selector (10/30/90 days or everything) and a "Sync now" button that pulls from the bank API through the same import path.
|
- **Source detail** (`SourceDetail.jsx`) — The Setup tab, grouped into titled panels:
|
||||||
|
Connection (link/unlink a SimpleFIN account, warns when constraint fields aren't `id`),
|
||||||
|
Fields and view (all known field names and their origins, with checkboxes for constraint
|
||||||
|
fields and view columns), Sample rows, Maintenance (reprocess), and Delete source.
|
||||||
|
|
||||||
|
- **Bridge** (`Bridge.jsx`) — Every account behind the SimpleFIN credential with balances,
|
||||||
|
the source each maps to, and subtotals split into banking versus retirement (recognised by
|
||||||
|
keyword on the account and institution names). Queries SimpleFIN only when Refresh is
|
||||||
|
pressed. Balances render in accounting style with negatives in parentheses.
|
||||||
|
|
||||||
|
- **Import** (`ImportHub.jsx`) — Top-level entry point for the frequent job. Lists every
|
||||||
|
source with record counts and last import date, a Sync button for bank feeds, and an
|
||||||
|
upload link for CSV sources.
|
||||||
|
|
||||||
|
- **Source › Import tab** — Upload a CSV to import records into the selected source. Transformations run automatically on new records. Shows import log with inserted/duplicate counts, expandable key detail, checkbox selection, and delete with confirmation. Sources with `config.simplefin.account_id` also get a Sync panel — a window selector (10/30/45 days or a full backfill) and a "Sync now" button that pulls from the bank API through the same import path.
|
||||||
|
|
||||||
- **Rules** — Create and manage regex rules. Live preview fires automatically (debounced 500ms) as pattern/field/flags are edited, showing match results against real records. Rules can be enabled/disabled by toggle.
|
- **Rules** — Create and manage regex rules. Live preview fires automatically (debounced 500ms) as pattern/field/flags are edited, showing match results against real records. Rules can be enabled/disabled by toggle.
|
||||||
|
|
||||||
@ -358,7 +394,8 @@ Built with React + Vite + Tailwind CSS. Compiled output goes to `public/`. The s
|
|||||||
|
|
||||||
See `docs/perspective.md` for the full technical reference on controlling Perspective programmatically.
|
See `docs/perspective.md` for the full technical reference on controlling Perspective programmatically.
|
||||||
|
|
||||||
- **Stacks** — Named unions of multiple sources. Each stack defines a field mapping (how source fields map to common output columns), an amount field, a date field, and an optional balance offset. The view-data endpoint unions the underlying source views and computes a running balance sorted by date. The Pivot page supports stacks as well as individual sources, with layouts stored in the same `pivot_layouts` table.
|
- **Stacks** — Named unions of multiple sources, each chip linking to its pivot at
|
||||||
|
`/stacks/:name/pivot`. Each stack defines a field mapping (how source fields map to common output columns), an amount field, a date field, and an optional balance offset. The view-data endpoint unions the underlying source views and computes a running balance sorted by date. The Pivot page supports stacks as well as individual sources, with layouts stored in the same `pivot_layouts` table.
|
||||||
|
|
||||||
- **Log** — Global import log across all sources. Same expandable key detail and delete capability as the Import page, plus a source name column.
|
- **Log** — Global import log across all sources. Same expandable key detail and delete capability as the Import page, plus a source name column.
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>ui</title>
|
<title>Dataflow</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import { useState, useEffect, createElement } from 'react'
|
import { useState, useEffect, createElement, lazy, Suspense } from 'react'
|
||||||
import { BrowserRouter, Routes, Route, Navigate, useParams } from 'react-router-dom'
|
import { BrowserRouter, Routes, Route, Navigate, useParams } from 'react-router-dom'
|
||||||
import { api, setCredentials, clearCredentials } from './api'
|
import { api, setCredentials, clearCredentials } from './api'
|
||||||
import Sidebar from './components/Sidebar.jsx'
|
import Sidebar from './components/Sidebar.jsx'
|
||||||
|
import BottomNav from './components/BottomNav.jsx'
|
||||||
import SourceTabs from './components/SourceTabs.jsx'
|
import SourceTabs from './components/SourceTabs.jsx'
|
||||||
import Login from './pages/Login'
|
import Login from './pages/Login'
|
||||||
import SourceList from './pages/SourceList'
|
import SourceList from './pages/SourceList'
|
||||||
@ -13,7 +14,7 @@ import Rules from './pages/Rules'
|
|||||||
import Mappings from './pages/Mappings'
|
import Mappings from './pages/Mappings'
|
||||||
import Records from './pages/Records'
|
import Records from './pages/Records'
|
||||||
import Log from './pages/Log'
|
import Log from './pages/Log'
|
||||||
import Pivot from './pages/Pivot'
|
const Pivot = lazy(() => import('./pages/Pivot'))
|
||||||
import Remap from './pages/Remap'
|
import Remap from './pages/Remap'
|
||||||
import Stacks from './pages/Stacks'
|
import Stacks from './pages/Stacks'
|
||||||
|
|
||||||
@ -134,12 +135,15 @@ export default function App() {
|
|||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<div className="flex h-screen">
|
<div className="flex h-screen">
|
||||||
|
|
||||||
|
<div className="hidden md:flex">
|
||||||
<Sidebar
|
<Sidebar
|
||||||
expanded={sidebarExpanded}
|
expanded={sidebarExpanded}
|
||||||
setExpanded={setSidebarExpanded}
|
setExpanded={setSidebarExpanded}
|
||||||
loginUser={loginUser}
|
loginUser={loginUser}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
|
sources={sources}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Main */}
|
{/* Main */}
|
||||||
<div className="flex-1 overflow-hidden flex flex-col min-w-0">
|
<div className="flex-1 overflow-hidden flex flex-col min-w-0">
|
||||||
@ -191,13 +195,15 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto pb-14 md:pb-0">
|
||||||
|
<Suspense fallback={<div className="p-6 text-sm text-muted">Loading…</div>}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Navigate to="/sources" replace />} />
|
<Route path="/" element={<Navigate to="/sources" replace />} />
|
||||||
|
|
||||||
<Route path="/sources" element={<SourceList sources={sources} setSources={setSources} setSource={setSource} />} />
|
<Route path="/sources" element={<SourceList sources={sources} setSources={setSources} setSource={setSource} />} />
|
||||||
<Route path="/sources/:name" element={<SourceTabs sources={sources} />}>
|
<Route path="/sources/:name" element={<SourceTabs sources={sources} />}>
|
||||||
<Route index element={<SourceDetail sources={sources} setSources={setSources} />} />
|
<Route index element={<Navigate to="records" replace />} />
|
||||||
|
<Route path="setup" element={<SourceDetail sources={sources} setSources={setSources} />} />
|
||||||
<Route path="import" element={<ScopedToSource component={Import} />} />
|
<Route path="import" element={<ScopedToSource component={Import} />} />
|
||||||
<Route path="rules" element={<ScopedToSource component={Rules} onStale={markSourceStale} />} />
|
<Route path="rules" element={<ScopedToSource component={Rules} onStale={markSourceStale} />} />
|
||||||
<Route path="mappings" element={<ScopedToSource component={Mappings} onNeedsReprocess={markNeedsReprocess} />} />
|
<Route path="mappings" element={<ScopedToSource component={Mappings} onNeedsReprocess={markNeedsReprocess} />} />
|
||||||
@ -212,9 +218,11 @@ export default function App() {
|
|||||||
<Route path="/remap" element={<Remap />} />
|
<Route path="/remap" element={<Remap />} />
|
||||||
<Route path="/log" element={<Log />} />
|
<Route path="/log" element={<Log />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<BottomNav />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
40
ui/src/components/BottomNav.jsx
Normal file
40
ui/src/components/BottomNav.jsx
Normal file
@ -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 (
|
||||||
|
<nav className="md:hidden fixed bottom-0 inset-x-0 z-20 bg-surface border-t border-line flex justify-around items-stretch h-14 pb-[env(safe-area-inset-bottom)]">
|
||||||
|
{NAV.map(({ to, label, icon }) => (
|
||||||
|
<NavLink
|
||||||
|
key={to}
|
||||||
|
to={to}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex-1 flex flex-col items-center justify-center gap-0.5 text-[10px] ${
|
||||||
|
isActive ? 'text-accent' : 'text-muted'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span>{icon}</span>
|
||||||
|
{label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={() => setDark(d => !d)}
|
||||||
|
className="flex-1 flex flex-col items-center justify-center gap-0.5 text-[10px] text-muted"
|
||||||
|
title={dark ? 'Light mode' : 'Dark mode'}
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
{dark
|
||||||
|
? <><circle cx="12" cy="12" r="4"/><line x1="12" y1="2" x2="12" y2="5"/><line x1="12" y1="19" x2="12" y2="22"/><line x1="2" y1="12" x2="5" y2="12"/><line x1="19" y1="12" x2="22" y2="12"/></>
|
||||||
|
: <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>}
|
||||||
|
</svg>
|
||||||
|
Theme
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,79 +1,37 @@
|
|||||||
|
import { Fragment, useMemo } from 'react'
|
||||||
import { NavLink } from 'react-router-dom'
|
import { NavLink } from 'react-router-dom'
|
||||||
import useTheme from '../theme.jsx'
|
import useTheme from '../theme.jsx'
|
||||||
|
import { NAV } from './navItems.jsx'
|
||||||
|
|
||||||
const NAV = [
|
// Same distinction the Import page makes: a source is either on a bank feed or
|
||||||
{
|
// it gets CSVs uploaded to it.
|
||||||
to: '/sources',
|
const feedIcon = (
|
||||||
label: 'Sources',
|
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
|
||||||
icon: (
|
<path d="M4 10.5a4.5 4.5 0 0 1 4.5 4.5"/>
|
||||||
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
<path d="M4 6a9 9 0 0 1 9 9"/>
|
||||||
<ellipse cx="10" cy="5.5" rx="7" ry="2.5"/>
|
<circle cx="4.2" cy="14.8" r="1.2" fill="currentColor" stroke="none"/>
|
||||||
<path d="M3 5.5v9c0 1.4 3.1 2.5 7 2.5s7-1.1 7-2.5v-9"/>
|
</svg>
|
||||||
<path d="M3 10.5c0 1.4 3.1 2.5 7 2.5s7-1.1 7-2.5"/>
|
)
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
to: '/import',
|
|
||||||
label: 'Import',
|
|
||||||
icon: (
|
|
||||||
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<line x1="10" y1="3" x2="10" y2="14"/>
|
|
||||||
<polyline points="6,10 10,14 14,10"/>
|
|
||||||
<line x1="3" y1="18" x2="17" y2="18"/>
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
to: '/bridge',
|
|
||||||
label: 'Bridge',
|
|
||||||
icon: (
|
|
||||||
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<path d="M2 13a8 8 0 0 1 16 0"/>
|
|
||||||
<line x1="2" y1="13" x2="18" y2="13"/>
|
|
||||||
<line x1="7" y1="13" x2="7" y2="9"/>
|
|
||||||
<line x1="13" y1="13" x2="13" y2="9"/>
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
to: '/remap',
|
|
||||||
label: 'Remap',
|
|
||||||
icon: (
|
|
||||||
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<polyline points="2,8 2,4 6,4"/>
|
|
||||||
<path d="M2 4a8 8 0 0 1 14 2"/>
|
|
||||||
<polyline points="18,12 18,16 14,16"/>
|
|
||||||
<path d="M18 16a8 8 0 0 1-14-2"/>
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
to: '/stacks',
|
|
||||||
label: 'Stacks',
|
|
||||||
icon: (
|
|
||||||
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<polygon points="10,2 18,6 10,10 2,6"/>
|
|
||||||
<polyline points="2,10 10,14 18,10"/>
|
|
||||||
<polyline points="2,14 10,18 18,14"/>
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
to: '/log',
|
|
||||||
label: 'Log',
|
|
||||||
icon: (
|
|
||||||
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<circle cx="10" cy="10" r="8"/>
|
|
||||||
<polyline points="10,5 10,10 14,12"/>
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export default function Sidebar({ expanded, setExpanded, loginUser, onLogout }) {
|
const csvIcon = (
|
||||||
|
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M4 1.5h5l3 3v10H4z"/>
|
||||||
|
<polyline points="9,1.5 9,4.5 12,4.5"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
export default function Sidebar({ expanded, setExpanded, loginUser, onLogout, sources = [] }) {
|
||||||
const { dark, setDark } = useTheme()
|
const { dark, setDark } = useTheme()
|
||||||
|
|
||||||
|
// Bank feeds first, then CSV sources, alphabetical within each group
|
||||||
|
const navSources = useMemo(() => (
|
||||||
|
sources
|
||||||
|
.map(s => ({ ...s, isFeed: !!s.config?.simplefin?.account_id }))
|
||||||
|
.sort((a, b) =>
|
||||||
|
(b.isFeed - a.isFeed) || a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })
|
||||||
|
)
|
||||||
|
), [sources])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="bg-surface border-r border-line flex flex-col shrink-0 overflow-hidden transition-all duration-150"
|
className="bg-surface border-r border-line flex flex-col shrink-0 overflow-hidden transition-all duration-150"
|
||||||
@ -101,28 +59,52 @@ export default function Sidebar({ expanded, setExpanded, loginUser, onLogout })
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Nav */}
|
{/* Nav */}
|
||||||
<nav className="flex flex-col gap-0.5 p-2 flex-1">
|
<nav className="flex flex-col gap-0.5 p-2 flex-1 overflow-y-auto">
|
||||||
{NAV.map(({ to, label, icon }) => (
|
{NAV.map(({ to, label, icon }) => (
|
||||||
<NavLink
|
<Fragment key={to}>
|
||||||
key={to}
|
<NavLink
|
||||||
to={to}
|
to={to}
|
||||||
title={!expanded ? label : undefined}
|
end={to === '/sources'}
|
||||||
className={({ isActive }) =>
|
title={!expanded ? label : undefined}
|
||||||
`flex items-center gap-3 px-2 py-2 rounded w-full transition-colors ${
|
className={({ isActive }) =>
|
||||||
isActive
|
`flex items-center gap-3 px-2 py-2 rounded w-full transition-colors ${
|
||||||
? 'bg-accent-soft text-accent'
|
isActive
|
||||||
: 'text-muted hover:bg-raised hover:text-ink'
|
? 'bg-accent-soft text-accent'
|
||||||
}`
|
: 'text-muted hover:bg-raised hover:text-ink'
|
||||||
}
|
}`
|
||||||
>
|
}
|
||||||
<span className="shrink-0">{icon}</span>
|
|
||||||
<span
|
|
||||||
className="text-sm whitespace-nowrap transition-opacity duration-100"
|
|
||||||
style={{ opacity: expanded ? 1 : 0, pointerEvents: expanded ? 'auto' : 'none', width: expanded ? 'auto' : 0, overflow: 'hidden' }}
|
|
||||||
>
|
>
|
||||||
{label}
|
<span className="shrink-0">{icon}</span>
|
||||||
</span>
|
<span
|
||||||
</NavLink>
|
className="text-sm whitespace-nowrap transition-opacity duration-100"
|
||||||
|
style={{ opacity: expanded ? 1 : 0, pointerEvents: expanded ? 'auto' : 'none', width: expanded ? 'auto' : 0, overflow: 'hidden' }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
|
{/* Jump straight to a source. Collapsed there is no room for names,
|
||||||
|
so the shortcut list only exists when the sidebar is open. */}
|
||||||
|
{to === '/sources' && expanded && navSources.map(({ isFeed, ...s }) => {
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
key={s.name}
|
||||||
|
to={`/sources/${encodeURIComponent(s.name)}`}
|
||||||
|
title={`${s.name} — ${isFeed ? 'bank feed' : 'CSV'}`}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex items-center gap-2 ml-4 pl-2 pr-2 py-1 rounded border-l border-line-soft transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'bg-accent-soft text-accent'
|
||||||
|
: 'text-muted hover:bg-raised hover:text-ink'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="shrink-0 opacity-70">{isFeed ? feedIcon : csvIcon}</span>
|
||||||
|
<span className="text-xs truncate">{s.name}</span>
|
||||||
|
</NavLink>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|||||||
@ -2,13 +2,15 @@ import { NavLink, Outlet, useParams, Link } from 'react-router-dom'
|
|||||||
|
|
||||||
// Everything scoped to one source lives under /sources/:name, so the source is
|
// Everything scoped to one source lives under /sources/:name, so the source is
|
||||||
// in the URL rather than in a global selector.
|
// in the URL rather than in a global selector.
|
||||||
|
// Records is the tab you want nine times out of ten, so it leads and is what
|
||||||
|
// /sources/:name redirects to; Setup is the rare one and sits at the end.
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ to: '', label: 'Setup', end: true },
|
{ to: 'records', label: 'Records' },
|
||||||
{ to: 'import', label: 'Import' },
|
{ to: 'import', label: 'Import' },
|
||||||
{ to: 'rules', label: 'Rules' },
|
{ to: 'rules', label: 'Rules' },
|
||||||
{ to: 'mappings', label: 'Mappings' },
|
{ to: 'mappings', label: 'Mappings' },
|
||||||
{ to: 'records', label: 'Records' },
|
|
||||||
{ to: 'pivot', label: 'Pivot' },
|
{ to: 'pivot', label: 'Pivot' },
|
||||||
|
{ to: 'setup', label: 'Setup' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function SourceTabs({ sources }) {
|
export default function SourceTabs({ sources }) {
|
||||||
@ -18,7 +20,7 @@ export default function SourceTabs({ sources }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full min-h-0">
|
<div className="flex flex-col h-full min-h-0">
|
||||||
<div className="px-6 pt-5 shrink-0">
|
<div className="px-4 sm:px-6 pt-4 sm:pt-5 shrink-0">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Link to="/sources" className="text-xs text-muted hover:text-ink-soft">Sources</Link>
|
<Link to="/sources" className="text-xs text-muted hover:text-ink-soft">Sources</Link>
|
||||||
<span className="text-muted text-xs">/</span>
|
<span className="text-muted text-xs">/</span>
|
||||||
@ -30,7 +32,7 @@ export default function SourceTabs({ sources }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex gap-1 mt-3 border-b border-line">
|
<nav className="flex gap-1 mt-3 border-b border-line overflow-x-auto whitespace-nowrap">
|
||||||
{TABS.map(({ to, label, end }) => (
|
{TABS.map(({ to, label, end }) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={label}
|
key={label}
|
||||||
|
|||||||
70
ui/src/components/navItems.jsx
Normal file
70
ui/src/components/navItems.jsx
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
// Top-level destinations, shared by the desktop sidebar and the mobile bar
|
||||||
|
export const NAV = [
|
||||||
|
{
|
||||||
|
to: '/sources',
|
||||||
|
label: 'Sources',
|
||||||
|
icon: (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<ellipse cx="10" cy="5.5" rx="7" ry="2.5"/>
|
||||||
|
<path d="M3 5.5v9c0 1.4 3.1 2.5 7 2.5s7-1.1 7-2.5v-9"/>
|
||||||
|
<path d="M3 10.5c0 1.4 3.1 2.5 7 2.5s7-1.1 7-2.5"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: '/import',
|
||||||
|
label: 'Import',
|
||||||
|
icon: (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<line x1="10" y1="3" x2="10" y2="14"/>
|
||||||
|
<polyline points="6,10 10,14 14,10"/>
|
||||||
|
<line x1="3" y1="18" x2="17" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: '/bridge',
|
||||||
|
label: 'Bridge',
|
||||||
|
icon: (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M2 13a8 8 0 0 1 16 0"/>
|
||||||
|
<line x1="2" y1="13" x2="18" y2="13"/>
|
||||||
|
<line x1="7" y1="13" x2="7" y2="9"/>
|
||||||
|
<line x1="13" y1="13" x2="13" y2="9"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: '/remap',
|
||||||
|
label: 'Remap',
|
||||||
|
icon: (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polyline points="2,8 2,4 6,4"/>
|
||||||
|
<path d="M2 4a8 8 0 0 1 14 2"/>
|
||||||
|
<polyline points="18,12 18,16 14,16"/>
|
||||||
|
<path d="M18 16a8 8 0 0 1-14-2"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: '/stacks',
|
||||||
|
label: 'Stacks',
|
||||||
|
icon: (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polygon points="10,2 18,6 10,10 2,6"/>
|
||||||
|
<polyline points="2,10 10,14 18,10"/>
|
||||||
|
<polyline points="2,14 10,18 18,14"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: '/log',
|
||||||
|
label: 'Log',
|
||||||
|
icon: (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="10" cy="10" r="8"/>
|
||||||
|
<polyline points="10,5 10,10 14,12"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
@ -49,7 +49,7 @@ export default function Bridge({ sources }) {
|
|||||||
const ordered = [...banking, ...retirement]
|
const ordered = [...banking, ...retirement]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-5xl space-y-4">
|
<div className="p-4 sm:p-6 max-w-5xl space-y-4">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<h1 className="text-xl font-semibold text-ink">Bridge</h1>
|
<h1 className="text-xl font-semibold text-ink">Bridge</h1>
|
||||||
<button
|
<button
|
||||||
@ -82,7 +82,7 @@ export default function Bridge({ sources }) {
|
|||||||
title="SimpleFIN accounts"
|
title="SimpleFIN accounts"
|
||||||
description="Every account behind the bridge credential, and which source pulls from it."
|
description="Every account behind the bridge credential, and which source pulls from it."
|
||||||
>
|
>
|
||||||
<table className="w-full text-xs">
|
<table className="w-full text-xs hidden sm:table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-left text-muted border-b border-line-soft">
|
<tr className="text-left text-muted border-b border-line-soft">
|
||||||
<th className="pb-1 font-medium">Account</th>
|
<th className="pb-1 font-medium">Account</th>
|
||||||
@ -132,6 +132,42 @@ export default function Bridge({ sources }) {
|
|||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
|
{/* Stacked cards for narrow screens */}
|
||||||
|
<div className="sm:hidden divide-y divide-line-soft">
|
||||||
|
{ordered.map(a => {
|
||||||
|
const src = sourceFor(a.id)
|
||||||
|
return (
|
||||||
|
<div key={a.id} className="py-2">
|
||||||
|
<div className="flex justify-between gap-2">
|
||||||
|
<span className="text-xs text-ink-soft">{a.name}</span>
|
||||||
|
<span className="text-xs font-mono text-ink">{money(a.balance)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between gap-2 text-xs text-muted">
|
||||||
|
<span>{a.organization}</span>
|
||||||
|
<span>{a.balance_date}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs mt-0.5">
|
||||||
|
{src
|
||||||
|
? <Link to={`/sources/${encodeURIComponent(src.name)}`} className="text-accent">{src.name}</Link>
|
||||||
|
: <span className="text-muted">not linked</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<div className="pt-2 flex justify-between text-xs">
|
||||||
|
<span className="text-muted">Banking and cards</span>
|
||||||
|
<span className="font-mono text-ink-soft">{money(sum(banking))}</span>
|
||||||
|
</div>
|
||||||
|
<div className="pt-1 flex justify-between text-xs border-0">
|
||||||
|
<span className="text-muted">Retirement</span>
|
||||||
|
<span className="font-mono text-ink-soft">{money(sum(retirement))}</span>
|
||||||
|
</div>
|
||||||
|
<div className="pt-2 flex justify-between text-xs font-medium">
|
||||||
|
<span className="text-ink-soft">Total</span>
|
||||||
|
<span className="font-mono text-ink">{money(sum(accounts))}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{accounts.length === 0 && <p className="text-xs text-muted">No accounts returned.</p>}
|
{accounts.length === 0 && <p className="text-xs text-muted">No accounts returned.</p>}
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -168,10 +168,10 @@ export default function Import({ source }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!source) return <div className="p-6 text-sm text-muted">Select a source first.</div>
|
if (!source) return <div className="p-4 sm:p-6 text-sm text-muted">Select a source first.</div>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-2xl">
|
<div className="p-4 sm:p-6 max-w-2xl">
|
||||||
<h1 className="text-xl font-semibold text-ink mb-6">Import — {source}</h1>
|
<h1 className="text-xl font-semibold text-ink mb-6">Import — {source}</h1>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
|
|||||||
@ -97,7 +97,7 @@ export default function ImportHub({ sources }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-4xl space-y-6">
|
<div className="p-4 sm:p-6 max-w-4xl space-y-6">
|
||||||
<h1 className="text-xl font-semibold text-ink">Import</h1>
|
<h1 className="text-xl font-semibold text-ink">Import</h1>
|
||||||
|
|
||||||
{feeds.length > 0 && (
|
{feeds.length > 0 && (
|
||||||
|
|||||||
@ -354,7 +354,7 @@ export default function Mappings({ source, onNeedsReprocess }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!source) return <div className="p-6 text-sm text-muted">Select a source first.</div>
|
if (!source) return <div className="p-4 sm:p-6 text-sm text-muted">Select a source first.</div>
|
||||||
|
|
||||||
const displayRows = sortedRows(filteredRows)
|
const displayRows = sortedRows(filteredRows)
|
||||||
|
|
||||||
|
|||||||
@ -370,7 +370,7 @@ export default function Pivot({ source, selectedStack, setSelectedStack }) {
|
|||||||
viewer.restore({ table: selectedView, settings: true, plugin_config: DEFAULT_PLUGIN_CONFIG })
|
viewer.restore({ table: selectedView, settings: true, plugin_config: DEFAULT_PLUGIN_CONFIG })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!source) return <div className="p-6 text-sm text-muted">Select a source first.</div>
|
if (!source) return <div className="p-4 sm:p-6 text-sm text-muted">Select a source first.</div>
|
||||||
|
|
||||||
const cols = inspectedRows?.length ? Object.keys(inspectedRows[0]) : []
|
const cols = inspectedRows?.length ? Object.keys(inspectedRows[0]) : []
|
||||||
|
|
||||||
|
|||||||
@ -283,7 +283,7 @@ export default function Records({ source }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!source) return <div className="p-6 text-sm text-muted">Select a source first.</div>
|
if (!source) return <div className="p-4 sm:p-6 text-sm text-muted">Select a source first.</div>
|
||||||
|
|
||||||
const displayCols = gridCols(rows.length > 0 ? Object.keys(rows[0]) : cols)
|
const displayCols = gridCols(rows.length > 0 ? Object.keys(rows[0]) : cols)
|
||||||
const visCols = gridCols(cols)
|
const visCols = gridCols(cols)
|
||||||
|
|||||||
@ -73,7 +73,7 @@ export default function Remap() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-4xl">
|
<div className="p-4 sm:p-6 max-w-4xl">
|
||||||
<h1 className="text-base font-semibold text-ink mb-4">Remap Output Values</h1>
|
<h1 className="text-base font-semibold text-ink mb-4">Remap Output Values</h1>
|
||||||
|
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
|
|||||||
@ -310,10 +310,10 @@ export default function Rules({ source, onStale }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!source) return <div className="p-6 text-sm text-muted">Select a source first.</div>
|
if (!source) return <div className="p-4 sm:p-6 text-sm text-muted">Select a source first.</div>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-3xl">
|
<div className="p-4 sm:p-6 max-w-3xl">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h1 className="text-xl font-semibold text-ink">Rules — {source}</h1>
|
<h1 className="text-xl font-semibold text-ink">Rules — {source}</h1>
|
||||||
<button onClick={startCreate}
|
<button onClick={startCreate}
|
||||||
|
|||||||
@ -156,10 +156,10 @@ export default function SourceDetail({ sources, setSources }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!sourceObj) return <div className="p-6 text-sm text-muted">Source not found.</div>
|
if (!sourceObj) return <div className="p-4 sm:p-6 text-sm text-muted">Source not found.</div>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-5xl space-y-4">
|
<div className="p-4 sm:p-6 max-w-5xl space-y-4">
|
||||||
{stats && (
|
{stats && (
|
||||||
<div className="flex gap-4 text-xs">
|
<div className="flex gap-4 text-xs">
|
||||||
<span className="text-muted"><span className="font-medium text-ink">{stats.total_records}</span> total</span>
|
<span className="text-muted"><span className="font-medium text-ink">{stats.total_records}</span> total</span>
|
||||||
|
|||||||
@ -124,7 +124,7 @@ export default function SourceList({ sources, setSources, setSource }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-5xl">
|
<div className="p-4 sm:p-6 max-w-5xl">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h1 className="text-xl font-semibold text-ink">Sources</h1>
|
<h1 className="text-xl font-semibold text-ink">Sources</h1>
|
||||||
{!creating && (
|
{!creating && (
|
||||||
|
|||||||
@ -757,9 +757,9 @@ export default function Stacks({ sources, onStackStale, onStackViewGenerated, on
|
|||||||
<span className="text-muted">{s.source_count}s</span>
|
<span className="text-muted">{s.source_count}s</span>
|
||||||
<Link to={`/stacks/${encodeURIComponent(s.name)}/pivot`}
|
<Link to={`/stacks/${encodeURIComponent(s.name)}/pivot`}
|
||||||
onClick={e => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
className="opacity-0 group-hover:opacity-100 text-accent hover:text-accent">pivot</Link>
|
className="text-accent underline decoration-transparent hover:decoration-inherit">pivot</Link>
|
||||||
<button onClick={e => { e.stopPropagation(); deleteStack(s.name) }}
|
<button onClick={e => { e.stopPropagation(); deleteStack(s.name) }}
|
||||||
className="opacity-0 group-hover:opacity-100 text-danger hover:text-danger leading-none">✕</button>
|
className="opacity-0 group-hover:opacity-100 text-danger hover:text-danger leading-none ml-2 pl-2 border-l border-line">✕</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{creating ? (
|
{creating ? (
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user