Dark theme for Forecast view controls and operation panel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-04-25 21:20:40 -04:00
parent 6449fff573
commit cda3943515

View File

@ -118,8 +118,11 @@ export default function Forecast() {
const unitsCol = colMetaRef.current.find(c => c.role === 'units')?.cname
if (!valueCol && !unitsCol) return
try {
const dimNames = new Set(colMetaRef.current.filter(c => c.role === 'dimension').map(c => c.cname))
const filters = [
...Object.entries(sliceObj).map(([col, val]) => [col, '==', val]),
...Object.entries(sliceObj)
.filter(([col]) => dimNames.has(col))
.map(([col, val]) => [col, '==', val]),
['pf_iter', '!=', 'reference'],
]
const view = await tableRef.current.view({ filter: filters })
@ -378,110 +381,123 @@ export default function Forecast() {
const hasSlice = Object.keys(slice).length > 0
return (
<div className="h-full flex flex-col">
<div className="h-full flex flex-col bg-gray-900">
{/* 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="px-3 py-2 border-b border-gray-700 bg-gray-900 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">
<select value={sourceId} onChange={e => setSourceId(e.target.value)} className="border border-gray-600 rounded px-2 py-1 text-sm bg-gray-800 text-gray-200">
{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}>
<select value={versionId} onChange={e => setVersionId(e.target.value)} className="border border-gray-600 rounded px-2 py-1 text-sm bg-gray-800 text-gray-200" 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'}`}>
<span className={`text-xs font-medium ${selectedVersion.status === 'open' ? 'text-green-400' : 'text-gray-500'}`}>
{selectedVersion.status}
</span>
)}
</div>
{msg && (
<span className={`text-xs font-medium px-2 py-0.5 rounded ml-auto ${msg.type === 'error' ? 'bg-red-50 text-red-600' : 'bg-green-50 text-green-600'}`}>
{msg.text}
</span>
)}
</div>
{/* Layout / depth bar */}
<div className="px-3 py-1.5 border-b border-gray-200 bg-white flex items-center gap-2 shrink-0 flex-wrap">
<span className="text-xs text-gray-400 uppercase tracking-wide">Layouts</span>
{/* Toolbar bar */}
<div className="px-3 py-1.5 border-b border-gray-700 bg-gray-900 flex items-center gap-3 shrink-0 flex-wrap text-xs">
{layouts.map(l => (
<div key={l.id} onClick={() => applyLayout(l)}
className={`flex items-center gap-1 text-xs rounded px-2 py-0.5 cursor-pointer border transition-colors
${activeLayoutId === l.id ? 'bg-blue-50 border-blue-300 text-blue-700' : 'bg-white border-gray-200 text-gray-600 hover:border-gray-400'}`}>
{l.name}
<button onClick={e => deleteLayout(l.id, e)} className="text-gray-300 hover:text-red-400 text-sm leading-none ml-0.5">×</button>
</div>
))}
{/* Layouts group */}
<div className="flex items-center gap-1.5">
<span className="text-gray-600 uppercase tracking-wide" style={{fontSize:'10px'}}>Layout</span>
{layouts.map(l => (
<div key={l.id} onClick={() => applyLayout(l)}
className={`flex items-center gap-1 rounded px-2 py-0.5 cursor-pointer border transition-colors
${activeLayoutId === l.id ? 'bg-blue-900 border-blue-600 text-blue-300' : 'bg-gray-800 border-gray-600 text-gray-400 hover:border-gray-400 hover:text-gray-200'}`}>
{l.name}
<button onClick={e => deleteLayout(l.id, e)} className="text-gray-600 hover:text-red-400 text-sm leading-none ml-0.5">×</button>
</div>
))}
{showSaveAs ? (
<div className="flex items-center gap-1">
<input autoFocus value={saveAsName} onChange={e => setSaveAsName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleSaveAs(); if (e.key === 'Escape') { setShowSaveAs(false); setSaveAsName('') } }}
placeholder="Layout name…" className="border border-gray-600 rounded px-2 py-0.5 w-32 bg-gray-800 text-gray-200 focus:outline-none focus:border-blue-500 placeholder:text-gray-600" />
<button onClick={handleSaveAs} className="text-blue-400 hover:text-blue-300 px-1">Save</button>
<button onClick={() => { setShowSaveAs(false); setSaveAsName('') }} className="text-gray-600 hover:text-gray-400 px-1">Cancel</button>
</div>
) : (
<>
{activeLayoutId !== null && (
<button onClick={handleSaveOver} className="border border-blue-700 text-blue-400 hover:text-blue-300 hover:border-blue-500 rounded px-2 py-0.5">
Save
</button>
)}
<button onClick={() => setShowSaveAs(true)} className="border border-dashed border-gray-700 text-gray-500 hover:text-gray-300 hover:border-gray-500 rounded px-2 py-0.5">
Save as
</button>
{activeLayoutId !== null && (
<button onClick={resetLayout} className="text-gray-600 hover:text-red-400">Reset</button>
)}
</>
)}
</div>
{activeLayoutId !== null && !showSaveAs && (
<button onClick={handleSaveOver} className="text-xs border border-blue-200 text-blue-500 hover:text-blue-700 rounded px-2 py-0.5">Save</button>
)}
<div className="w-px h-4 bg-gray-700 shrink-0" />
{showSaveAs ? (
<div className="flex items-center gap-1">
<input autoFocus value={saveAsName} onChange={e => setSaveAsName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleSaveAs(); if (e.key === 'Escape') { setShowSaveAs(false); setSaveAsName('') } }}
placeholder="Layout name…" className="text-xs border border-gray-300 rounded px-2 py-0.5 w-32 focus:outline-none focus:border-blue-400" />
<button onClick={handleSaveAs} className="text-xs text-blue-600 hover:text-blue-800 px-1">Save</button>
<button onClick={() => { setShowSaveAs(false); setSaveAsName('') }} className="text-xs text-gray-400 px-1">Cancel</button>
</div>
) : (
<button onClick={() => setShowSaveAs(true)} className="text-xs border border-dashed border-gray-200 text-gray-400 hover:text-gray-600 rounded px-2 py-0.5">
+ Save as
</button>
)}
{activeLayoutId !== null && (
<button onClick={resetLayout} className="text-xs text-gray-300 hover:text-gray-500">reset</button>
)}
<button onClick={openLog} disabled={!versionId}
className="text-xs border border-gray-200 rounded px-2 py-0.5 text-gray-500 hover:bg-gray-50 disabled:opacity-40">
History
</button>
{/* Depth controls */}
<div className="ml-auto flex items-center gap-1.5">
<span className="text-xs text-gray-400">depth</span>
{/* Expand depth group */}
<div className="flex items-center gap-1.5">
<span className="text-gray-600 uppercase tracking-wide" style={{fontSize:'10px'}}>Expand</span>
{[0, 1, 2, 3].map(d => (
<button key={d} onClick={() => applyDepth(d)}
className={`text-xs border rounded px-1.5 py-0.5 transition-colors
${expandDepthRef.current === d ? 'border-blue-300 text-blue-600 bg-blue-50' : 'border-gray-200 text-gray-500 hover:border-gray-400'}`}>
className={`border rounded px-1.5 py-0.5 transition-colors
${expandDepthRef.current === d ? 'border-blue-600 text-blue-400 bg-blue-900' : 'border-gray-600 text-gray-400 hover:border-gray-400 hover:text-gray-200'}`}>
{d}
</button>
))}
</div>
<div className="w-px h-4 bg-gray-700 shrink-0" />
{/* Data group */}
<div className="flex items-center gap-1.5">
<button onClick={() => initViewer(versionId, sourceId)} disabled={loading || !versionId}
className="text-xs border border-gray-200 rounded px-2 py-0.5 text-gray-500 hover:bg-gray-50 disabled:opacity-40 ml-2">
{loading ? 'Loading…' : 'Refresh'}
className="border border-gray-600 rounded px-2 py-0.5 text-gray-400 hover:text-gray-200 hover:border-gray-400 disabled:opacity-30">
{loading ? 'Loading…' : 'Refresh data'}
</button>
<button onClick={openLog} disabled={!versionId}
className="border border-gray-600 rounded px-2 py-0.5 text-gray-400 hover:text-gray-200 hover:border-gray-400 disabled:opacity-30">
Change log
</button>
</div>
{msg && (
<span className={`ml-2 font-medium px-2 py-0.5 rounded ${msg.type === 'error' ? 'bg-red-900 text-red-400' : 'bg-green-900 text-green-400'}`}>
{msg.text}
</span>
)}
</div>
{/* History modal */}
{showLog && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={() => setShowLog(false)}>
<div className="bg-white rounded-lg shadow-xl w-full max-w-4xl mx-4 flex flex-col max-h-[80vh]" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-200 shrink-0">
<span className="font-medium text-gray-700 text-sm">Change History</span>
<button onClick={() => setShowLog(false)} className="text-gray-400 hover:text-gray-600 text-lg leading-none">×</button>
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={() => setShowLog(false)}>
<div className="bg-gray-900 border border-gray-700 rounded-lg shadow-2xl w-full max-w-4xl mx-4 flex flex-col max-h-[80vh]" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-700 shrink-0">
<span className="font-medium text-gray-200 text-sm">Change log</span>
<button onClick={() => setShowLog(false)} className="text-gray-500 hover:text-gray-300 text-lg leading-none">×</button>
</div>
<div className="overflow-y-auto flex-1">
{logLoading ? (
<div className="p-8 text-center text-sm text-gray-400">Loading</div>
<div className="p-8 text-center text-sm text-gray-500">Loading</div>
) : logEntries.length === 0 ? (
<div className="p-8 text-center text-sm text-gray-400">No log entries yet.</div>
<div className="p-8 text-center text-sm text-gray-500">No log entries yet.</div>
) : (
<table className="w-full text-xs border-collapse">
<thead className="sticky top-0 bg-gray-50 text-gray-400 uppercase tracking-wide" style={{fontSize:'10px'}}>
<thead className="sticky top-0 bg-gray-800 text-gray-500 uppercase tracking-wide" style={{fontSize:'10px'}}>
<tr>
<th className="text-left px-4 py-2 font-medium w-32">Time</th>
<th className="text-left px-4 py-2 font-medium w-24">Op</th>
@ -493,15 +509,15 @@ export default function Forecast() {
</thead>
<tbody>
{logEntries.map(entry => (
<tr key={entry.id} className="border-t border-gray-100 hover:bg-gray-50">
<td className="px-4 py-2 text-gray-400 whitespace-nowrap">{fmtStamp(entry.stamp)}</td>
<tr key={entry.id} className="border-t border-gray-800 hover:bg-gray-800/50">
<td className="px-4 py-2 text-gray-500 whitespace-nowrap">{fmtStamp(entry.stamp)}</td>
<td className="px-4 py-2">
<span className={`px-1.5 py-0.5 rounded text-xs font-medium ${opBadge(entry.operation)}`}>
{entry.operation}
</span>
</td>
<td className="px-4 py-2 text-gray-600 font-mono">{fmtSlice(entry.slice)}</td>
<td className="px-4 py-2 text-gray-600 max-w-xs">
<td className="px-4 py-2 text-gray-400 font-mono">{fmtSlice(entry.slice)}</td>
<td className="px-4 py-2 text-gray-400 max-w-xs">
{editingNote?.id === entry.id ? (
<div className="flex items-center gap-1">
<input autoFocus value={editingNote.text}
@ -510,15 +526,15 @@ export default function Forecast() {
if (e.key === 'Enter') saveNote(entry.id, editingNote.text)
if (e.key === 'Escape') setEditingNote(null)
}}
className="border border-blue-300 rounded px-1.5 py-0.5 text-xs flex-1 focus:outline-none" />
<button onClick={() => saveNote(entry.id, editingNote.text)} className="text-blue-600 hover:text-blue-800"></button>
<button onClick={() => setEditingNote(null)} className="text-gray-400 hover:text-gray-600"></button>
className="border border-blue-600 rounded px-1.5 py-0.5 text-xs flex-1 bg-gray-800 text-gray-200 focus:outline-none" />
<button onClick={() => saveNote(entry.id, editingNote.text)} className="text-blue-400 hover:text-blue-300"></button>
<button onClick={() => setEditingNote(null)} className="text-gray-500 hover:text-gray-300"></button>
</div>
) : (
<span onClick={() => setEditingNote({ id: entry.id, text: entry.note || '' })}
className="cursor-text hover:bg-blue-50 rounded px-1 -mx-1 block truncate"
className="cursor-text hover:bg-gray-700 rounded px-1 -mx-1 block truncate"
title={entry.note || 'Click to add note'}>
{entry.note || <span className="text-gray-300 italic">add note</span>}
{entry.note || <span className="text-gray-600 italic">add note</span>}
</span>
)}
</td>
@ -527,7 +543,7 @@ export default function Forecast() {
<button
onClick={() => undoEntry(entry.id)}
disabled={undoingId === entry.id}
className="text-xs border border-red-200 text-red-400 hover:text-red-600 hover:border-red-400 rounded px-2 py-0.5 disabled:opacity-40 whitespace-nowrap">
className="text-xs border border-red-800 text-red-500 hover:text-red-400 hover:border-red-600 rounded px-2 py-0.5 disabled:opacity-40 whitespace-nowrap">
{undoingId === entry.id ? '…' : 'Undo'}
</button>
</td>
@ -546,40 +562,40 @@ export default function Forecast() {
{/* Perspective viewer */}
<div className="relative flex-1 min-w-0">
{loading && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-50 z-10">
<span className="text-sm text-gray-400">Loading</span>
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 z-10">
<span className="text-sm text-gray-500">Loading</span>
</div>
)}
<perspective-viewer ref={viewerRef} style={{ position: 'absolute', inset: 0 }} />
</div>
{/* Drag handle */}
<div onMouseDown={onDragStart} className="w-1 shrink-0 cursor-col-resize hover:bg-blue-400 bg-transparent transition-colors" />
<div onMouseDown={onDragStart} className="w-1 shrink-0 cursor-col-resize hover:bg-blue-500 bg-gray-800 transition-colors" />
{/* Operation panel */}
<div className="shrink-0 border-l border-gray-200 bg-white flex flex-col overflow-y-auto text-xs" style={{ width: panelWidth }}>
<div className="p-3 border-b border-gray-100">
<div className="font-medium text-gray-400 uppercase tracking-wide mb-2" style={{fontSize:'10px'}}>Slice</div>
<div className="shrink-0 border-l border-gray-700 bg-gray-900 flex flex-col overflow-y-auto text-xs" style={{ width: panelWidth }}>
<div className="p-3 border-b border-gray-800">
<div className="font-medium text-gray-600 uppercase tracking-wide mb-2" style={{fontSize:'10px'}}>Slice</div>
{!hasSlice ? (
<div className="text-gray-300 italic">Click a pivot row to select a slice</div>
<div className="text-gray-600 italic">Click a pivot row to select a slice</div>
) : (
<div className="flex flex-col gap-1">
{Object.entries(slice).map(([k, v]) => (
<div key={k} className="text-gray-700">
<span className="text-gray-400">{k}</span> = <span className="font-medium">{v}</span>
<div key={k} className="text-gray-300">
<span className="text-gray-500">{k}</span> = <span className="font-medium">{v}</span>
</div>
))}
<button onClick={() => setSlice({})} className="text-gray-300 hover:text-red-500 mt-1 text-left">Clear</button>
<button onClick={() => setSlice({})} className="text-gray-600 hover:text-red-400 mt-1 text-left">Clear</button>
</div>
)}
</div>
{hasSlice && (
<>
<div className="flex border-b border-gray-100">
<div className="flex border-b border-gray-800">
{['scale', 'recode', 'clone'].map(op => (
<button key={op} onClick={() => setActiveOp(op)}
className={`flex-1 py-2 capitalize ${activeOp === op ? 'border-b-2 border-blue-500 text-blue-600 font-medium' : 'text-gray-400 hover:text-gray-600'}`}>
className={`flex-1 py-2 capitalize ${activeOp === op ? 'border-b-2 border-blue-500 text-blue-400 font-medium' : 'text-gray-600 hover:text-gray-300'}`}>
{op}
</button>
))}
@ -588,10 +604,10 @@ export default function Forecast() {
<div className="p-3 flex flex-col gap-2.5">
{activeOp === 'scale' && <>
{/* Mode toggle */}
<div className="flex rounded border border-gray-200 overflow-hidden">
<div className="flex rounded border border-gray-700 overflow-hidden">
{[['target','= Target'],['delta','Δ Increment']].map(([m, label]) => (
<button key={m} onClick={() => { setScaleMode(m); setScaleValue(''); setScaleUnits('') }}
className={`flex-1 py-1 text-xs ${scaleMode === m ? 'bg-blue-600 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'}`}>
className={`flex-1 py-1 text-xs ${scaleMode === m ? 'bg-blue-600 text-white' : 'bg-gray-800 text-gray-400 hover:bg-gray-700'}`}>
{label}
</button>
))}
@ -600,9 +616,9 @@ export default function Forecast() {
{/* Value row */}
{currentTotals?.valueCol && (
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between text-gray-400">
<div className="flex items-center justify-between text-gray-500">
<span>{currentTotals.valueCol}</span>
<span className="font-mono">{currentTotals.value?.toLocaleString(undefined, {maximumFractionDigits:2})}</span>
<span className="font-mono text-gray-300">{currentTotals.value?.toLocaleString(undefined, {maximumFractionDigits:2})}</span>
</div>
<input type="number" step="any" value={scaleValue}
onChange={e => setScaleValue(e.target.value)}
@ -614,9 +630,9 @@ export default function Forecast() {
{/* Units row */}
{currentTotals?.unitsCol && (
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between text-gray-400">
<div className="flex items-center justify-between text-gray-500">
<span>{currentTotals.unitsCol}</span>
<span className="font-mono">{currentTotals.units?.toLocaleString(undefined, {maximumFractionDigits:2})}</span>
<span className="font-mono text-gray-300">{currentTotals.units?.toLocaleString(undefined, {maximumFractionDigits:2})}</span>
</div>
<input type="number" step="any" value={scaleUnits}
onChange={e => setScaleUnits(e.target.value)}
@ -636,7 +652,7 @@ export default function Forecast() {
</>}
{activeOp === 'recode' && <>
<p className="text-gray-400">New values for dimensions to replace. Leave blank to keep.</p>
<p className="text-gray-500">New values for dimensions to replace. Leave blank to keep.</p>
{dimCols.map(c => (
<Row key={c.cname} label={c.cname}>
<input value={recodeSet[c.cname] || ''} onChange={e => setRecodeSet(s => ({ ...s, [c.cname]: e.target.value }))}
@ -648,7 +664,7 @@ export default function Forecast() {
</>}
{activeOp === 'clone' && <>
<p className="text-gray-400">Override dimensions on cloned rows. Leave blank to keep.</p>
<p className="text-gray-500">Override dimensions on cloned rows. Leave blank to keep.</p>
{dimCols.map(c => (
<Row key={c.cname} label={c.cname}>
<input value={cloneSet[c.cname] || ''} onChange={e => setCloneSet(s => ({ ...s, [c.cname]: e.target.value }))}
@ -668,7 +684,7 @@ export default function Forecast() {
)
}
const inp = 'border border-gray-200 rounded px-2 py-1 text-xs flex-1 bg-white min-w-0'
const inp = 'border border-gray-700 rounded px-2 py-1 text-xs flex-1 bg-gray-800 text-gray-200 placeholder:text-gray-600 min-w-0 focus:outline-none focus:border-blue-600'
function fmtStamp(stamp) {
return new Date(stamp).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
@ -680,18 +696,18 @@ function fmtSlice(slice) {
}
const OP_BADGE = {
baseline: 'bg-gray-100 text-gray-600',
reference: 'bg-blue-50 text-blue-600',
scale: 'bg-green-50 text-green-700',
recode: 'bg-amber-50 text-amber-700',
clone: 'bg-purple-50 text-purple-700',
baseline: 'bg-gray-700 text-gray-300',
reference: 'bg-blue-900 text-blue-300',
scale: 'bg-green-900 text-green-400',
recode: 'bg-amber-900 text-amber-400',
clone: 'bg-purple-900 text-purple-400',
}
function opBadge(op) { return OP_BADGE[op] || 'bg-gray-100 text-gray-500' }
function opBadge(op) { return OP_BADGE[op] || 'bg-gray-700 text-gray-400' }
function Row({ label, children }) {
return (
<div className="flex items-center gap-2">
<span className="text-gray-400 w-14 shrink-0 truncate" title={label}>{label}</span>
<span className="text-gray-500 w-14 shrink-0 truncate" title={label}>{label}</span>
{children}
</div>
)
@ -699,7 +715,7 @@ function Row({ label, children }) {
function Submit({ onClick, children }) {
return (
<button onClick={onClick} className="mt-1 bg-blue-600 text-white text-xs px-3 py-1.5 rounded hover:bg-blue-700 w-full">
<button onClick={onClick} className="mt-1 bg-blue-600 text-white text-xs px-3 py-1.5 rounded hover:bg-blue-500 w-full">
{children}
</button>
)