Add a source list to the sidebar and lead with the Records tab

The sidebar now lists every source under the Sources item when it is
expanded, so entering a source takes one click instead of two. Bank
feeds sort first, then CSV sources, alphabetical within each group, and
a small icon marks which is which — the same config.simplefin test the
Import page uses.

Selecting a source lands on Records rather than Setup: the index route
under /sources/:name redirects there and Setup moved to an explicit
/setup path at the end of the tab strip. Setup is the rare job; Records
is the frequent one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154qBiPua1PXeet69XmmvQb
This commit is contained in:
Paul Trowbridge 2026-08-09 16:03:57 -04:00
parent 3297acf9db
commit a1e6edc74d
3 changed files with 79 additions and 24 deletions

View File

@ -141,6 +141,7 @@ export default function App() {
setExpanded={setSidebarExpanded} setExpanded={setSidebarExpanded}
loginUser={loginUser} loginUser={loginUser}
onLogout={handleLogout} onLogout={handleLogout}
sources={sources}
/> />
</div> </div>
@ -201,7 +202,8 @@ export default function App() {
<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} />} />

View File

@ -1,10 +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' import { NAV } from './navItems.jsx'
export default function Sidebar({ expanded, setExpanded, loginUser, onLogout }) { // Same distinction the Import page makes: a source is either on a bank feed or
// it gets CSVs uploaded to it.
const feedIcon = (
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
<path d="M4 10.5a4.5 4.5 0 0 1 4.5 4.5"/>
<path d="M4 6a9 9 0 0 1 9 9"/>
<circle cx="4.2" cy="14.8" r="1.2" fill="currentColor" stroke="none"/>
</svg>
)
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"
@ -32,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>

View File

@ -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 }) {