Lift source/version selection to a functional StatusBar

App.jsx owns sources/sourceId/versions/versionId and persists them
across reloads. StatusBar renders the dropdowns plus a status badge —
Source-only on Setup, Source · Version · status on Baseline/Forecast.
The duplicate in-view selector bars in Forecast and Baseline are gone;
Baseline keeps its version actions (New/Close/Reopen/Delete) inline.
Setup reports source-list mutations up via refreshSources so the bar
stays in sync after register/deregister.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-04-28 20:54:55 -04:00
parent d70d813604
commit e279a510d8
5 changed files with 100 additions and 111 deletions

View File

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react' import { useState, useEffect, useCallback } from 'react'
import Sidebar from './components/Sidebar.jsx' import Sidebar from './components/Sidebar.jsx'
import StatusBar from './components/StatusBar.jsx' import StatusBar from './components/StatusBar.jsx'
import Setup from './views/Setup.jsx' import Setup from './views/Setup.jsx'
@ -9,18 +9,64 @@ export default function App() {
const [view, setView] = useState(() => localStorage.getItem('pf_view') || 'forecast') const [view, setView] = useState(() => localStorage.getItem('pf_view') || 'forecast')
const [sidebarExpanded, setSidebarExpanded] = useState(() => localStorage.getItem('pf_sidebar') !== 'collapsed') const [sidebarExpanded, setSidebarExpanded] = useState(() => localStorage.getItem('pf_sidebar') !== 'collapsed')
const [sources, setSources] = useState([])
const [sourceId, setSourceId] = useState(() => localStorage.getItem('pf_sourceId') || '')
const [versions, setVersions] = useState([])
const [versionId, setVersionId] = useState(() => localStorage.getItem('pf_versionId') || '')
useEffect(() => { localStorage.setItem('pf_view', view) }, [view]) useEffect(() => { localStorage.setItem('pf_view', view) }, [view])
useEffect(() => { localStorage.setItem('pf_sidebar', sidebarExpanded ? 'expanded' : 'collapsed') }, [sidebarExpanded]) useEffect(() => { localStorage.setItem('pf_sidebar', sidebarExpanded ? 'expanded' : 'collapsed') }, [sidebarExpanded])
useEffect(() => { localStorage.setItem('pf_sourceId', sourceId || '') }, [sourceId])
useEffect(() => { localStorage.setItem('pf_versionId', versionId || '') }, [versionId])
const refreshSources = useCallback(async () => {
const data = await fetch('/api/sources').then(r => r.json())
setSources(data)
return data
}, [])
const refreshVersions = useCallback(async (sid) => {
const id = sid ?? sourceId
if (!id) { setVersions([]); return [] }
const data = await fetch(`/api/sources/${id}/versions`).then(r => r.json())
setVersions(data)
return data
}, [sourceId])
useEffect(() => {
refreshSources().then(data => {
if (data.length === 0) { setSourceId(''); return }
if (!sourceId || !data.some(s => String(s.id) === String(sourceId))) {
setSourceId(String(data[0].id))
}
})
}, [])
useEffect(() => {
if (!sourceId) { setVersions([]); setVersionId(''); return }
refreshVersions(sourceId).then(data => {
if (data.length === 0) { setVersionId(''); return }
if (!versionId || !data.some(v => String(v.id) === String(versionId))) {
setVersionId(String(data[0].id))
}
})
}, [sourceId])
const ctx = {
sources, sourceId, setSourceId,
versions, versionId, setVersionId,
refreshSources, refreshVersions, setVersions,
}
return ( return (
<div className="flex h-screen w-full text-sm overflow-hidden"> <div className="flex h-screen w-full text-sm overflow-hidden">
<Sidebar view={view} setView={setView} expanded={sidebarExpanded} setExpanded={setSidebarExpanded} /> <Sidebar view={view} setView={setView} expanded={sidebarExpanded} setExpanded={setSidebarExpanded} />
<div className="flex flex-col flex-1 overflow-hidden min-w-0"> <div className="flex flex-col flex-1 overflow-hidden min-w-0">
<StatusBar /> <StatusBar view={view} {...ctx} />
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
{view === 'setup' && <Setup />} {view === 'setup' && <Setup {...ctx} />}
{view === 'baseline' && <Baseline />} {view === 'baseline' && <Baseline {...ctx} />}
{view === 'forecast' && <Forecast />} {view === 'forecast' && <Forecast {...ctx} />}
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,21 +1,46 @@
import useTheme from '../theme.jsx' import useTheme from '../theme.jsx'
export default function StatusBar() { export default function StatusBar({ view, sources = [], sourceId, setSourceId, versions = [], versionId, setVersionId }) {
const { dark, setDark } = useTheme() const { dark, setDark } = useTheme()
const showVersion = view === 'baseline' || view === 'forecast'
const selectedVersion = versions.find(v => String(v.id) === String(versionId))
return ( return (
<div className="bg-white border-b border-gray-200 px-4 h-8 flex items-center gap-4 shrink-0 text-xs"> <div className="bg-white border-b border-gray-200 px-3 h-9 flex items-center gap-3 shrink-0 text-xs">
<span className="text-gray-400">Source</span> <span className="text-gray-400">Source</span>
<span className="font-medium text-gray-700">sales_orders</span> <select
<span className="text-gray-200">|</span> value={sourceId || ''}
<span className="text-gray-400">Version</span> onChange={e => setSourceId(e.target.value)}
<span className="font-medium text-gray-700">FY2026 Plan</span> disabled={sources.length === 0}
<span className="text-gray-200">|</span> className="border border-gray-200 rounded px-2 py-0.5 bg-white"
<span className="text-gray-400">Baseline</span> >
<span className="font-medium text-gray-700">44,313 rows</span> {sources.length === 0
<span className="text-gray-200">|</span> ? <option value=""> no sources </option>
<span className="text-gray-400">Status</span> : sources.map(s => <option key={s.id} value={s.id}>{s.tname}</option>)}
<span className="text-green-600 font-medium">open</span> </select>
{showVersion && (
<>
<span className="text-gray-200">|</span>
<span className="text-gray-400">Version</span>
<select
value={versionId || ''}
onChange={e => setVersionId(e.target.value)}
disabled={versions.length === 0}
className="border border-gray-200 rounded px-2 py-0.5 bg-white"
>
{versions.length === 0
? <option value=""> no versions </option>
: versions.map(v => <option key={v.id} value={v.id}>{v.name}</option>)}
</select>
{selectedVersion && (
<span className={`font-medium ${selectedVersion.status === 'open' ? 'text-green-600' : 'text-gray-400'}`}>
{selectedVersion.status}
</span>
)}
</>
)}
<div className="ml-auto"> <div className="ml-auto">
<button <button
onClick={() => setDark(d => !d)} onClick={() => setDark(d => !d)}

View File

@ -55,11 +55,7 @@ function emptyFilter(cols) {
return { col: cols[0]?.cname || '', op: 'BETWEEN', values: ['', ''] } return { col: cols[0]?.cname || '', op: 'BETWEEN', values: ['', ''] }
} }
export default function Baseline() { export default function Baseline({ sources = [], sourceId, versions = [], versionId, setVersionId, refreshVersions }) {
const [sources, setSources] = useState([])
const [sourceId, setSourceId] = useState('')
const [versions, setVersions] = useState([])
const [versionId, setVersionId] = useState('')
const [filterCols, setFilterCols] = useState([]) const [filterCols, setFilterCols] = useState([])
const [log, setLog] = useState([]) const [log, setLog] = useState([])
@ -81,20 +77,8 @@ export default function Baseline() {
const [expandedId, setExpandedId] = useState(null) const [expandedId, setExpandedId] = useState(null)
const [msg, setMsg] = useState(null) const [msg, setMsg] = useState(null)
useEffect(() => {
fetch('/api/sources').then(r => r.json()).then(data => {
setSources(data)
if (data.length > 0) setSourceId(String(data[0].id))
})
}, [])
useEffect(() => { useEffect(() => {
if (!sourceId) return if (!sourceId) return
fetch(`/api/sources/${sourceId}/versions`).then(r => r.json()).then(data => {
setVersions(data)
if (data.length > 0) setVersionId(String(data[0].id))
else setVersionId('')
})
fetch(`/api/sources/${sourceId}/cols`).then(r => r.json()).then(cols => { fetch(`/api/sources/${sourceId}/cols`).then(r => r.json()).then(cols => {
const fc = cols.filter(c => c.role === 'date' || c.role === 'filter') const fc = cols.filter(c => c.role === 'date' || c.role === 'filter')
setFilterCols(fc) setFilterCols(fc)
@ -124,8 +108,7 @@ export default function Baseline() {
}) })
const data = await res.json() const data = await res.json()
if (!res.ok) { flash(data.error, 'error'); return } if (!res.ok) { flash(data.error, 'error'); return }
const updated = await fetch(`/api/sources/${sourceId}/versions`).then(r => r.json()) await refreshVersions(sourceId)
setVersions(updated)
setVersionId(String(data.id)) setVersionId(String(data.id))
setShowNewVersion(false) setShowNewVersion(false)
setNewVerName('') setNewVerName('')
@ -220,8 +203,7 @@ export default function Baseline() {
}) })
const data = await res.json() const data = await res.json()
if (!res.ok) { flash(data.error, 'error'); return } if (!res.ok) { flash(data.error, 'error'); return }
const updated = await fetch(`/api/sources/${sourceId}/versions`).then(r => r.json()) await refreshVersions(sourceId)
setVersions(updated)
flash('Version closed') flash('Version closed')
} }
@ -229,8 +211,7 @@ export default function Baseline() {
const res = await fetch(`/api/versions/${versionId}/reopen`, { method: 'POST' }) const res = await fetch(`/api/versions/${versionId}/reopen`, { method: 'POST' })
const data = await res.json() const data = await res.json()
if (!res.ok) { flash(data.error, 'error'); return } if (!res.ok) { flash(data.error, 'error'); return }
const updated = await fetch(`/api/sources/${sourceId}/versions`).then(r => r.json()) await refreshVersions(sourceId)
setVersions(updated)
flash('Version reopened') flash('Version reopened')
} }
@ -239,8 +220,7 @@ export default function Baseline() {
const res = await fetch(`/api/versions/${versionId}`, { method: 'DELETE' }) const res = await fetch(`/api/versions/${versionId}`, { method: 'DELETE' })
const data = await res.json() const data = await res.json()
if (!res.ok) { flash(data.error, 'error'); return } if (!res.ok) { flash(data.error, 'error'); return }
const updated = await fetch(`/api/sources/${sourceId}/versions`).then(r => r.json()) const updated = await refreshVersions(sourceId)
setVersions(updated)
setVersionId(updated.length > 0 ? String(updated[0].id) : '') setVersionId(updated.length > 0 ? String(updated[0].id) : '')
flash('Version deleted') flash('Version deleted')
} }
@ -264,33 +244,13 @@ export default function Baseline() {
</div> </div>
)} )}
{/* Source + Version bar */} {/* Version actions */}
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">Source</span>
<select value={sourceId} onChange={e => setSourceId(e.target.value)} className="border border-gray-200 rounded px-2 py-1 text-sm bg-white">
{sources.map(s => <option key={s.id} value={s.id}>{s.tname}</option>)}
</select>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">Version</span>
<select value={versionId} onChange={e => setVersionId(e.target.value)} className="border border-gray-200 rounded px-2 py-1 text-sm bg-white" disabled={versions.length === 0}>
{versions.length === 0
? <option value=""> no versions </option>
: versions.map(v => <option key={v.id} value={v.id}>{v.name}</option>)
}
</select>
{versionId && (
<span className={`text-xs font-medium ${selectedVersion?.status === 'open' ? 'text-green-600' : 'text-gray-400'}`}>
{selectedVersion?.status}
</span>
)}
</div>
<button onClick={() => setShowNewVersion(v => !v)} className="text-xs text-blue-600 hover:text-blue-700 border border-blue-200 px-2 py-1 rounded"> <button onClick={() => setShowNewVersion(v => !v)} className="text-xs text-blue-600 hover:text-blue-700 border border-blue-200 px-2 py-1 rounded">
+ New version + New version
</button> </button>
{versionId && ( {versionId && (
<div className="flex items-center gap-2 ml-2"> <div className="flex items-center gap-2">
{selectedVersion?.status === 'open' {selectedVersion?.status === 'open'
? <button onClick={closeVersion} className="text-xs text-gray-500 hover:text-gray-700 border border-gray-200 px-2 py-1 rounded">Close</button> ? <button onClick={closeVersion} className="text-xs text-gray-500 hover:text-gray-700 border border-gray-200 px-2 py-1 rounded">Close</button>
: <button onClick={reopenVersion} className="text-xs text-gray-500 hover:text-gray-700 border border-gray-200 px-2 py-1 rounded">Reopen</button> : <button onClick={reopenVersion} className="text-xs text-gray-500 hover:text-gray-700 border border-gray-200 px-2 py-1 rounded">Reopen</button>

View File

@ -29,12 +29,8 @@ function cleanLayout(cfg, validCols) {
return c return c
} }
export default function Forecast() { export default function Forecast({ sourceId, versionId }) {
const { dark } = useTheme() const { dark } = useTheme()
const [sources, setSources] = useState([])
const [sourceId, setSourceId] = useState('')
const [versions, setVersions] = useState([])
const [versionId, setVersionId] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [largeDataset, setLargeDataset] = useState(false) const [largeDataset, setLargeDataset] = useState(false)
const [loadProgress, setLoadProgress] = useState(null) // { received, total } const [loadProgress, setLoadProgress] = useState(null) // { received, total }
@ -86,21 +82,6 @@ export default function Forecast() {
window.addEventListener('mouseup', onUp) window.addEventListener('mouseup', onUp)
} }
useEffect(() => {
fetch('/api/sources').then(r => r.json()).then(data => {
setSources(data)
if (data.length > 0) setSourceId(String(data[0].id))
})
}, [])
useEffect(() => {
if (!sourceId) return
fetch(`/api/sources/${sourceId}/versions`).then(r => r.json()).then(data => {
setVersions(data)
setVersionId(data.length > 0 ? String(data[0].id) : '')
})
}, [sourceId])
useEffect(() => { useEffect(() => {
if (!versionId || !sourceId) return if (!versionId || !sourceId) return
loadLayouts(versionId) loadLayouts(versionId)
@ -420,36 +401,12 @@ export default function Forecast() {
} }
} }
const selectedVersion = versions.find(v => String(v.id) === versionId)
const dimCols = colMetaRef.current.filter(c => c.role === 'dimension') const dimCols = colMetaRef.current.filter(c => c.role === 'dimension')
const hasSlice = Object.keys(slice).length > 0 const hasSlice = Object.keys(slice).length > 0
return ( return (
<div className="h-full flex flex-col"> <div className="h-full flex flex-col">
{/* Source / version bar */}
<div className="px-3 py-2 border-b border-gray-200 bg-white flex items-center gap-3 shrink-0 flex-wrap">
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">Source</span>
<select value={sourceId} onChange={e => setSourceId(e.target.value)} className="border border-gray-200 rounded px-2 py-1 text-sm bg-white">
{sources.map(s => <option key={s.id} value={s.id}>{s.tname}</option>)}
</select>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">Version</span>
<select value={versionId} onChange={e => setVersionId(e.target.value)} className="border border-gray-200 rounded px-2 py-1 text-sm bg-white" disabled={!versions.length}>
{versions.length === 0
? <option value=""> no versions </option>
: versions.map(v => <option key={v.id} value={v.id}>{v.name}</option>)}
</select>
{selectedVersion && (
<span className={`text-xs font-medium ${selectedVersion.status === 'open' ? 'text-green-600' : 'text-gray-400'}`}>
{selectedVersion.status}
</span>
)}
</div>
</div>
{/* Toolbar */} {/* Toolbar */}
<div className="px-3 py-1.5 border-b border-gray-200 bg-white flex items-center gap-3 shrink-0 flex-wrap text-xs"> <div className="px-3 py-1.5 border-b border-gray-200 bg-white flex items-center gap-3 shrink-0 flex-wrap text-xs">

View File

@ -11,7 +11,7 @@ const ROLE_STYLE = {
ignore: 'bg-gray-100 text-gray-400', ignore: 'bg-gray-100 text-gray-400',
} }
export default function Setup() { export default function Setup({ refreshSources }) {
const [tables, setTables] = useState([]) const [tables, setTables] = useState([])
const [sources, setSources] = useState([]) const [sources, setSources] = useState([])
const [selectedSource, setSelectedSource] = useState(null) const [selectedSource, setSelectedSource] = useState(null)
@ -40,6 +40,7 @@ export default function Setup() {
}) })
}) })
}).catch(console.error) }).catch(console.error)
refreshSources?.()
} }
function selectSource(source) { function selectSource(source) {