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:
parent
d70d813604
commit
e279a510d8
@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import Sidebar from './components/Sidebar.jsx'
|
||||
import StatusBar from './components/StatusBar.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 [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_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 (
|
||||
<div className="flex h-screen w-full text-sm overflow-hidden">
|
||||
<Sidebar view={view} setView={setView} expanded={sidebarExpanded} setExpanded={setSidebarExpanded} />
|
||||
<div className="flex flex-col flex-1 overflow-hidden min-w-0">
|
||||
<StatusBar />
|
||||
<StatusBar view={view} {...ctx} />
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{view === 'setup' && <Setup />}
|
||||
{view === 'baseline' && <Baseline />}
|
||||
{view === 'forecast' && <Forecast />}
|
||||
{view === 'setup' && <Setup {...ctx} />}
|
||||
{view === 'baseline' && <Baseline {...ctx} />}
|
||||
{view === 'forecast' && <Forecast {...ctx} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,21 +1,46 @@
|
||||
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 showVersion = view === 'baseline' || view === 'forecast'
|
||||
const selectedVersion = versions.find(v => String(v.id) === String(versionId))
|
||||
|
||||
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="font-medium text-gray-700">sales_orders</span>
|
||||
<span className="text-gray-200">|</span>
|
||||
<span className="text-gray-400">Version</span>
|
||||
<span className="font-medium text-gray-700">FY2026 Plan</span>
|
||||
<span className="text-gray-200">|</span>
|
||||
<span className="text-gray-400">Baseline</span>
|
||||
<span className="font-medium text-gray-700">44,313 rows</span>
|
||||
<span className="text-gray-200">|</span>
|
||||
<span className="text-gray-400">Status</span>
|
||||
<span className="text-green-600 font-medium">open</span>
|
||||
<select
|
||||
value={sourceId || ''}
|
||||
onChange={e => setSourceId(e.target.value)}
|
||||
disabled={sources.length === 0}
|
||||
className="border border-gray-200 rounded px-2 py-0.5 bg-white"
|
||||
>
|
||||
{sources.length === 0
|
||||
? <option value="">— no sources —</option>
|
||||
: sources.map(s => <option key={s.id} value={s.id}>{s.tname}</option>)}
|
||||
</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">
|
||||
<button
|
||||
onClick={() => setDark(d => !d)}
|
||||
|
||||
@ -55,11 +55,7 @@ function emptyFilter(cols) {
|
||||
return { col: cols[0]?.cname || '', op: 'BETWEEN', values: ['', ''] }
|
||||
}
|
||||
|
||||
export default function Baseline() {
|
||||
const [sources, setSources] = useState([])
|
||||
const [sourceId, setSourceId] = useState('')
|
||||
const [versions, setVersions] = useState([])
|
||||
const [versionId, setVersionId] = useState('')
|
||||
export default function Baseline({ sources = [], sourceId, versions = [], versionId, setVersionId, refreshVersions }) {
|
||||
const [filterCols, setFilterCols] = useState([])
|
||||
const [log, setLog] = useState([])
|
||||
|
||||
@ -81,20 +77,8 @@ export default function Baseline() {
|
||||
const [expandedId, setExpandedId] = 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(() => {
|
||||
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 => {
|
||||
const fc = cols.filter(c => c.role === 'date' || c.role === 'filter')
|
||||
setFilterCols(fc)
|
||||
@ -124,8 +108,7 @@ export default function Baseline() {
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) { flash(data.error, 'error'); return }
|
||||
const updated = await fetch(`/api/sources/${sourceId}/versions`).then(r => r.json())
|
||||
setVersions(updated)
|
||||
await refreshVersions(sourceId)
|
||||
setVersionId(String(data.id))
|
||||
setShowNewVersion(false)
|
||||
setNewVerName('')
|
||||
@ -220,8 +203,7 @@ export default function Baseline() {
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) { flash(data.error, 'error'); return }
|
||||
const updated = await fetch(`/api/sources/${sourceId}/versions`).then(r => r.json())
|
||||
setVersions(updated)
|
||||
await refreshVersions(sourceId)
|
||||
flash('Version closed')
|
||||
}
|
||||
|
||||
@ -229,8 +211,7 @@ export default function Baseline() {
|
||||
const res = await fetch(`/api/versions/${versionId}/reopen`, { method: 'POST' })
|
||||
const data = await res.json()
|
||||
if (!res.ok) { flash(data.error, 'error'); return }
|
||||
const updated = await fetch(`/api/sources/${sourceId}/versions`).then(r => r.json())
|
||||
setVersions(updated)
|
||||
await refreshVersions(sourceId)
|
||||
flash('Version reopened')
|
||||
}
|
||||
|
||||
@ -239,8 +220,7 @@ export default function Baseline() {
|
||||
const res = await fetch(`/api/versions/${versionId}`, { method: 'DELETE' })
|
||||
const data = await res.json()
|
||||
if (!res.ok) { flash(data.error, 'error'); return }
|
||||
const updated = await fetch(`/api/sources/${sourceId}/versions`).then(r => r.json())
|
||||
setVersions(updated)
|
||||
const updated = await refreshVersions(sourceId)
|
||||
setVersionId(updated.length > 0 ? String(updated[0].id) : '')
|
||||
flash('Version deleted')
|
||||
}
|
||||
@ -264,33 +244,13 @@ export default function Baseline() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Source + Version bar */}
|
||||
{/* Version actions */}
|
||||
<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">
|
||||
+ New version
|
||||
</button>
|
||||
{versionId && (
|
||||
<div className="flex items-center gap-2 ml-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{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={reopenVersion} className="text-xs text-gray-500 hover:text-gray-700 border border-gray-200 px-2 py-1 rounded">Reopen</button>
|
||||
|
||||
@ -29,12 +29,8 @@ function cleanLayout(cfg, validCols) {
|
||||
return c
|
||||
}
|
||||
|
||||
export default function Forecast() {
|
||||
export default function Forecast({ sourceId, versionId }) {
|
||||
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 [largeDataset, setLargeDataset] = useState(false)
|
||||
const [loadProgress, setLoadProgress] = useState(null) // { received, total }
|
||||
@ -86,21 +82,6 @@ export default function Forecast() {
|
||||
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(() => {
|
||||
if (!versionId || !sourceId) return
|
||||
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 hasSlice = Object.keys(slice).length > 0
|
||||
|
||||
return (
|
||||
<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 */}
|
||||
<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">
|
||||
|
||||
|
||||
@ -11,7 +11,7 @@ const ROLE_STYLE = {
|
||||
ignore: 'bg-gray-100 text-gray-400',
|
||||
}
|
||||
|
||||
export default function Setup() {
|
||||
export default function Setup({ refreshSources }) {
|
||||
const [tables, setTables] = useState([])
|
||||
const [sources, setSources] = useState([])
|
||||
const [selectedSource, setSelectedSource] = useState(null)
|
||||
@ -40,6 +40,7 @@ export default function Setup() {
|
||||
})
|
||||
})
|
||||
}).catch(console.error)
|
||||
refreshSources?.()
|
||||
}
|
||||
|
||||
function selectSource(source) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user