Nav. The source and version dropdowns sat in a status bar that is otherwise read-only, so they did not read as the controls they were. The forecasts are now listed in the sidebar above the three steps, each row carrying its status and a star for the default. Not nested under the Forecast button, which is where this started: Baseline works on the same version, so hanging the list off one step would either misrepresent the scope or duplicate it under two. The source is gone from the nav entirely -- gs.osm_skinny means nothing to someone editing a forecast. The sidebar lists every source's versions and sets the source from the one picked, since a version carries its source_id; `versions` stays scoped to the selected source, because Baseline and Forecast are, so the sidebar gets its own list rather than widening what versions means everywhere. The picker only appears on Setup now, which is the one place the raw table name is the right label. The status bar keeps what follows FROM the selection -- the forecast's name and its row count. The table it writes to moved into the hover panel, still click-to-copy, since pasting it into psql is why it was ever on screen. Permissions. requireAdmin existed in lib/auth.js and was applied to nothing, so all 26 non-admin accounts could call every Setup and Baseline endpoint directly. It is now on source register/deregister, col_meta writes, Generate SQL, dim refresh, version create/delete/ close/reopen, PUT /versions/:id, all of plans.js, and the baseline and reference loads. Deliberately not on any read, nor on the forecast operations, which carry their own owner and territory rules. Both pages stay visible and go read-only, via one <fieldset disabled> each rather than a flag threaded through forty-five controls, with a banner saying why. The Tags panel on Baseline is excluded: its permission is ownership, not admin, and a rep may rename the initiatives they named themselves. The table browser reads are open to everyone, because gating what a page needs in order to render turned Setup into a 403 -- which it then rendered as a crash, since the error body was passed to .map during render and unmounting React blanks the app. Setup's list fetches now coerce to an array, so an unexpected response costs an empty panel and not the session. That fragility predated the gating. Toolbar. The zone labels were the same visual weight as the buttons they name, and the rules between groups were the same weight as the zone borders, so nothing separated at the level it belonged to. Labels are heavier and darker, group rules are stronger and taller, and the forecast zone has a container of its own instead of floating. Fit widths is removed, button and function. It measured the leaf header over viewport rows only, so anything below the fold never counted; it passed an option draw() does not have and raced draw()'s throttle, leaving columns cleared but unmeasured at their floor widths; and the persist that followed saved nothing, because saveColumnSizes returns only overrides, which resetAutoSize had just emptied. Recoverable from git if it is ever worth fixing rather than dropping. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
177 lines
7.7 KiB
JavaScript
177 lines
7.7 KiB
JavaScript
const NAV = [
|
|
{
|
|
id: 'setup',
|
|
label: 'Setup',
|
|
icon: (
|
|
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
|
|
<line x1="3" y1="5" x2="17" y2="5"/><circle cx="7" cy="5" r="1.8" fill="white" stroke="currentColor" strokeWidth="1.6"/>
|
|
<line x1="3" y1="10" x2="17" y2="10"/><circle cx="13" cy="10" r="1.8" fill="white" stroke="currentColor" strokeWidth="1.6"/>
|
|
<line x1="3" y1="15" x2="17" y2="15"/><circle cx="8" cy="15" r="1.8" fill="white" stroke="currentColor" strokeWidth="1.6"/>
|
|
</svg>
|
|
)
|
|
},
|
|
{
|
|
id: 'baseline',
|
|
label: 'Baseline',
|
|
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,7 10,12 2,7"/>
|
|
<polyline points="2,12 10,17 18,12"/>
|
|
</svg>
|
|
)
|
|
},
|
|
{
|
|
id: 'forecast',
|
|
label: 'Forecast',
|
|
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,15 7,9 11,12 18,4"/>
|
|
<polyline points="14,4 18,4 18,8"/>
|
|
</svg>
|
|
)
|
|
},
|
|
]
|
|
|
|
// The forecasts are listed here, above the steps, rather than offered as a
|
|
// dropdown in the status bar. Two reasons: a scenario is a thing you open, not
|
|
// a setting you change, and the status bar is otherwise read-only, so a control
|
|
// sitting in it did not read as one.
|
|
//
|
|
// Not nested under the Forecast button, which is where this started: Baseline
|
|
// works on the same version, so hanging it off one step would either
|
|
// misrepresent the scope or duplicate the list under two. It reads
|
|
// "what am I working on" and then "what step am I on".
|
|
//
|
|
// The source is deliberately absent. `gs.osm_skinny` means nothing to someone
|
|
// editing a forecast, and a version already carries its source, so choosing a
|
|
// forecast settles it. Picking the source is a Setup concern and the status bar
|
|
// only offers it there.
|
|
export default function Sidebar({
|
|
view, setView, expanded, setExpanded,
|
|
versions = [], versionId, setVersionId,
|
|
sourceId, setSourceId,
|
|
}) {
|
|
const selected = versions.find(v => String(v.id) === String(versionId))
|
|
|
|
function pick(v) {
|
|
// The source comes with the forecast. This is the whole reason the source
|
|
// needs no control of its own outside Setup: every version carries its
|
|
// source_id, so choosing one settles both. Set first, so the version is not
|
|
// briefly validated against the outgoing source's list.
|
|
if (v.source_id != null && String(v.source_id) !== String(sourceId)) {
|
|
setSourceId(String(v.source_id))
|
|
}
|
|
setVersionId(String(v.id))
|
|
// Setup is the one step a version means nothing to, so choosing one there
|
|
// is a statement of intent to go and work on it.
|
|
if (view === 'setup') setView('forecast')
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="bg-white border-r border-gray-200 flex flex-col shrink-0 overflow-hidden transition-all duration-150"
|
|
style={{ width: expanded ? 200 : 48 }}
|
|
>
|
|
<div className="h-12 flex items-center px-3 border-b border-gray-100 gap-2 shrink-0">
|
|
<button
|
|
onClick={() => setExpanded(e => !e)}
|
|
className="w-8 h-8 flex items-center justify-center rounded hover:bg-gray-100 text-gray-400 shrink-0"
|
|
title="Toggle sidebar"
|
|
>
|
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
|
<rect x="1" y="3" width="14" height="1.5" rx="0.75" fill="currentColor"/>
|
|
<rect x="1" y="7.25" width="14" height="1.5" rx="0.75" fill="currentColor"/>
|
|
<rect x="1" y="11.5" width="14" height="1.5" rx="0.75" fill="currentColor"/>
|
|
</svg>
|
|
</button>
|
|
<span
|
|
className="text-xs font-semibold text-gray-600 tracking-wide uppercase whitespace-nowrap transition-opacity duration-100"
|
|
style={{ opacity: expanded ? 1 : 0, pointerEvents: expanded ? 'auto' : 'none' }}
|
|
>
|
|
Pivot Forecast
|
|
</span>
|
|
</div>
|
|
|
|
{expanded ? (
|
|
<div className="px-2 pt-2 pb-1.5 border-b border-gray-100 shrink-0">
|
|
<div className="px-2 pb-1 text-gray-400 uppercase tracking-wide" style={{ fontSize: '10px' }}>
|
|
Forecasts
|
|
</div>
|
|
{/* Capped and scrollable: the list grows with the work, and the steps
|
|
below it must never be pushed off the bottom. */}
|
|
<div className="max-h-52 overflow-y-auto">
|
|
{versions.length === 0 && (
|
|
<div className="px-2 py-1 text-xs text-gray-400">none yet</div>
|
|
)}
|
|
{versions.map(v => {
|
|
const active = String(v.id) === String(versionId)
|
|
return (
|
|
<button
|
|
key={v.id}
|
|
onClick={() => pick(v)}
|
|
title={`${v.name}${v.is_default ? ' · default' : ''} · ${v.status}`}
|
|
className={`w-full flex items-center gap-1.5 rounded px-2 py-1 text-left transition-colors ${
|
|
active ? 'bg-blue-50 text-blue-700' : 'text-gray-600 hover:bg-gray-100'
|
|
}`}
|
|
>
|
|
<span className={`w-1.5 h-1.5 rounded-full shrink-0 ${active ? 'bg-blue-600' : 'bg-transparent'}`} />
|
|
<span className="text-xs truncate flex-1">{v.name}</span>
|
|
{v.is_default && (
|
|
<span className="text-amber-500 shrink-0" style={{ fontSize: '10px' }} title="Default">★</span>
|
|
)}
|
|
<span
|
|
className={`shrink-0 ${v.status === 'open' ? 'text-green-600' : 'text-gray-400'}`}
|
|
style={{ fontSize: '10px' }}
|
|
>
|
|
{v.status}
|
|
</span>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
// Collapsed, the list has nowhere to go, so all that survives is which
|
|
// one is open — as a tooltip, on a button that opens the rail again.
|
|
<div className="p-2 border-b border-gray-100 shrink-0">
|
|
<button
|
|
onClick={() => setExpanded(true)}
|
|
title={selected ? `Forecast: ${selected.name}` : 'Forecasts'}
|
|
className="w-8 h-8 flex items-center justify-center rounded hover:bg-gray-100 text-gray-400"
|
|
>
|
|
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M4 3h8l4 4v10a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"/>
|
|
<polyline points="12,3 12,7 16,7"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
<nav className="flex flex-col gap-0.5 p-2 flex-1">
|
|
{NAV.map(item => {
|
|
const active = view === item.id
|
|
return (
|
|
<button
|
|
key={item.id}
|
|
onClick={() => setView(item.id)}
|
|
title={!expanded ? item.label : undefined}
|
|
className={`flex items-center gap-3 px-2 py-2 rounded text-left w-full transition-colors ${
|
|
active
|
|
? 'bg-blue-50 text-blue-700'
|
|
: 'text-gray-500 hover:bg-gray-100 hover:text-gray-800'
|
|
}`}
|
|
>
|
|
<span className="shrink-0">{item.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' }}
|
|
>
|
|
{item.label}
|
|
</span>
|
|
</button>
|
|
)
|
|
})}
|
|
</nav>
|
|
</div>
|
|
)
|
|
} |