Dark theme for Forecast view controls and operation panel
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6449fff573
commit
cda3943515
@ -118,8 +118,11 @@ export default function Forecast() {
|
|||||||
const unitsCol = colMetaRef.current.find(c => c.role === 'units')?.cname
|
const unitsCol = colMetaRef.current.find(c => c.role === 'units')?.cname
|
||||||
if (!valueCol && !unitsCol) return
|
if (!valueCol && !unitsCol) return
|
||||||
try {
|
try {
|
||||||
|
const dimNames = new Set(colMetaRef.current.filter(c => c.role === 'dimension').map(c => c.cname))
|
||||||
const filters = [
|
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'],
|
['pf_iter', '!=', 'reference'],
|
||||||
]
|
]
|
||||||
const view = await tableRef.current.view({ filter: filters })
|
const view = await tableRef.current.view({ filter: filters })
|
||||||
@ -378,110 +381,123 @@ export default function Forecast() {
|
|||||||
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 bg-gray-900">
|
||||||
|
|
||||||
{/* Source / version bar */}
|
{/* 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">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs text-gray-500">Source</span>
|
<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>)}
|
{sources.map(s => <option key={s.id} value={s.id}>{s.tname}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs text-gray-500">Version</span>
|
<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
|
{versions.length === 0
|
||||||
? <option value="">— no versions —</option>
|
? <option value="">— no versions —</option>
|
||||||
: versions.map(v => <option key={v.id} value={v.id}>{v.name}</option>)}
|
: versions.map(v => <option key={v.id} value={v.id}>{v.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
{selectedVersion && (
|
{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}
|
{selectedVersion.status}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Layout / depth bar */}
|
{/* Toolbar bar */}
|
||||||
<div className="px-3 py-1.5 border-b border-gray-200 bg-white flex items-center gap-2 shrink-0 flex-wrap">
|
<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">
|
||||||
<span className="text-xs text-gray-400 uppercase tracking-wide">Layouts</span>
|
|
||||||
|
|
||||||
{layouts.map(l => (
|
{/* Layouts group */}
|
||||||
<div key={l.id} onClick={() => applyLayout(l)}
|
<div className="flex items-center gap-1.5">
|
||||||
className={`flex items-center gap-1 text-xs rounded px-2 py-0.5 cursor-pointer border transition-colors
|
<span className="text-gray-600 uppercase tracking-wide" style={{fontSize:'10px'}}>Layout</span>
|
||||||
${activeLayoutId === l.id ? 'bg-blue-50 border-blue-300 text-blue-700' : 'bg-white border-gray-200 text-gray-600 hover:border-gray-400'}`}>
|
{layouts.map(l => (
|
||||||
{l.name}
|
<div key={l.id} onClick={() => applyLayout(l)}
|
||||||
<button onClick={e => deleteLayout(l.id, e)} className="text-gray-300 hover:text-red-400 text-sm leading-none ml-0.5">×</button>
|
className={`flex items-center gap-1 rounded px-2 py-0.5 cursor-pointer border transition-colors
|
||||||
</div>
|
${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 && (
|
<div className="w-px h-4 bg-gray-700 shrink-0" />
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showSaveAs ? (
|
{/* Expand depth group */}
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1.5">
|
||||||
<input autoFocus value={saveAsName} onChange={e => setSaveAsName(e.target.value)}
|
<span className="text-gray-600 uppercase tracking-wide" style={{fontSize:'10px'}}>Expand</span>
|
||||||
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>
|
|
||||||
{[0, 1, 2, 3].map(d => (
|
{[0, 1, 2, 3].map(d => (
|
||||||
<button key={d} onClick={() => applyDepth(d)}
|
<button key={d} onClick={() => applyDepth(d)}
|
||||||
className={`text-xs border rounded px-1.5 py-0.5 transition-colors
|
className={`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'}`}>
|
${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}
|
{d}
|
||||||
</button>
|
</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}
|
<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">
|
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'}
|
{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>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* History modal */}
|
{/* History modal */}
|
||||||
{showLog && (
|
{showLog && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={() => setShowLog(false)}>
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" 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="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-200 shrink-0">
|
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-700 shrink-0">
|
||||||
<span className="font-medium text-gray-700 text-sm">Change History</span>
|
<span className="font-medium text-gray-200 text-sm">Change log</span>
|
||||||
<button onClick={() => setShowLog(false)} className="text-gray-400 hover:text-gray-600 text-lg leading-none">×</button>
|
<button onClick={() => setShowLog(false)} className="text-gray-500 hover:text-gray-300 text-lg leading-none">×</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-y-auto flex-1">
|
<div className="overflow-y-auto flex-1">
|
||||||
{logLoading ? (
|
{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 ? (
|
) : 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">
|
<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>
|
<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-32">Time</th>
|
||||||
<th className="text-left px-4 py-2 font-medium w-24">Op</th>
|
<th className="text-left px-4 py-2 font-medium w-24">Op</th>
|
||||||
@ -493,15 +509,15 @@ export default function Forecast() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{logEntries.map(entry => (
|
{logEntries.map(entry => (
|
||||||
<tr key={entry.id} className="border-t border-gray-100 hover:bg-gray-50">
|
<tr key={entry.id} className="border-t border-gray-800 hover:bg-gray-800/50">
|
||||||
<td className="px-4 py-2 text-gray-400 whitespace-nowrap">{fmtStamp(entry.stamp)}</td>
|
<td className="px-4 py-2 text-gray-500 whitespace-nowrap">{fmtStamp(entry.stamp)}</td>
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
<span className={`px-1.5 py-0.5 rounded text-xs font-medium ${opBadge(entry.operation)}`}>
|
<span className={`px-1.5 py-0.5 rounded text-xs font-medium ${opBadge(entry.operation)}`}>
|
||||||
{entry.operation}
|
{entry.operation}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2 text-gray-600 font-mono">{fmtSlice(entry.slice)}</td>
|
<td className="px-4 py-2 text-gray-400 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 max-w-xs">
|
||||||
{editingNote?.id === entry.id ? (
|
{editingNote?.id === entry.id ? (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<input autoFocus value={editingNote.text}
|
<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 === 'Enter') saveNote(entry.id, editingNote.text)
|
||||||
if (e.key === 'Escape') setEditingNote(null)
|
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" />
|
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-600 hover:text-blue-800">✓</button>
|
<button onClick={() => saveNote(entry.id, editingNote.text)} className="text-blue-400 hover:text-blue-300">✓</button>
|
||||||
<button onClick={() => setEditingNote(null)} className="text-gray-400 hover:text-gray-600">✕</button>
|
<button onClick={() => setEditingNote(null)} className="text-gray-500 hover:text-gray-300">✕</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span onClick={() => setEditingNote({ id: entry.id, text: entry.note || '' })}
|
<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'}>
|
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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
@ -527,7 +543,7 @@ export default function Forecast() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => undoEntry(entry.id)}
|
onClick={() => undoEntry(entry.id)}
|
||||||
disabled={undoingId === 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'}
|
{undoingId === entry.id ? '…' : 'Undo'}
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
@ -546,40 +562,40 @@ export default function Forecast() {
|
|||||||
{/* Perspective viewer */}
|
{/* Perspective viewer */}
|
||||||
<div className="relative flex-1 min-w-0">
|
<div className="relative flex-1 min-w-0">
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-50 z-10">
|
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 z-10">
|
||||||
<span className="text-sm text-gray-400">Loading…</span>
|
<span className="text-sm text-gray-500">Loading…</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<perspective-viewer ref={viewerRef} style={{ position: 'absolute', inset: 0 }} />
|
<perspective-viewer ref={viewerRef} style={{ position: 'absolute', inset: 0 }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Drag handle */}
|
{/* 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 */}
|
{/* 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="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-100">
|
<div className="p-3 border-b border-gray-800">
|
||||||
<div className="font-medium text-gray-400 uppercase tracking-wide mb-2" style={{fontSize:'10px'}}>Slice</div>
|
<div className="font-medium text-gray-600 uppercase tracking-wide mb-2" style={{fontSize:'10px'}}>Slice</div>
|
||||||
{!hasSlice ? (
|
{!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">
|
<div className="flex flex-col gap-1">
|
||||||
{Object.entries(slice).map(([k, v]) => (
|
{Object.entries(slice).map(([k, v]) => (
|
||||||
<div key={k} className="text-gray-700">
|
<div key={k} className="text-gray-300">
|
||||||
<span className="text-gray-400">{k}</span> = <span className="font-medium">{v}</span>
|
<span className="text-gray-500">{k}</span> = <span className="font-medium">{v}</span>
|
||||||
</div>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasSlice && (
|
{hasSlice && (
|
||||||
<>
|
<>
|
||||||
<div className="flex border-b border-gray-100">
|
<div className="flex border-b border-gray-800">
|
||||||
{['scale', 'recode', 'clone'].map(op => (
|
{['scale', 'recode', 'clone'].map(op => (
|
||||||
<button key={op} onClick={() => setActiveOp(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}
|
{op}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@ -588,10 +604,10 @@ export default function Forecast() {
|
|||||||
<div className="p-3 flex flex-col gap-2.5">
|
<div className="p-3 flex flex-col gap-2.5">
|
||||||
{activeOp === 'scale' && <>
|
{activeOp === 'scale' && <>
|
||||||
{/* Mode toggle */}
|
{/* 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]) => (
|
{[['target','= Target'],['delta','Δ Increment']].map(([m, label]) => (
|
||||||
<button key={m} onClick={() => { setScaleMode(m); setScaleValue(''); setScaleUnits('') }}
|
<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}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@ -600,9 +616,9 @@ export default function Forecast() {
|
|||||||
{/* Value row */}
|
{/* Value row */}
|
||||||
{currentTotals?.valueCol && (
|
{currentTotals?.valueCol && (
|
||||||
<div className="flex flex-col gap-1">
|
<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>{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>
|
</div>
|
||||||
<input type="number" step="any" value={scaleValue}
|
<input type="number" step="any" value={scaleValue}
|
||||||
onChange={e => setScaleValue(e.target.value)}
|
onChange={e => setScaleValue(e.target.value)}
|
||||||
@ -614,9 +630,9 @@ export default function Forecast() {
|
|||||||
{/* Units row */}
|
{/* Units row */}
|
||||||
{currentTotals?.unitsCol && (
|
{currentTotals?.unitsCol && (
|
||||||
<div className="flex flex-col gap-1">
|
<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>{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>
|
</div>
|
||||||
<input type="number" step="any" value={scaleUnits}
|
<input type="number" step="any" value={scaleUnits}
|
||||||
onChange={e => setScaleUnits(e.target.value)}
|
onChange={e => setScaleUnits(e.target.value)}
|
||||||
@ -636,7 +652,7 @@ export default function Forecast() {
|
|||||||
</>}
|
</>}
|
||||||
|
|
||||||
{activeOp === 'recode' && <>
|
{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 => (
|
{dimCols.map(c => (
|
||||||
<Row key={c.cname} label={c.cname}>
|
<Row key={c.cname} label={c.cname}>
|
||||||
<input value={recodeSet[c.cname] || ''} onChange={e => setRecodeSet(s => ({ ...s, [c.cname]: e.target.value }))}
|
<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' && <>
|
{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 => (
|
{dimCols.map(c => (
|
||||||
<Row key={c.cname} label={c.cname}>
|
<Row key={c.cname} label={c.cname}>
|
||||||
<input value={cloneSet[c.cname] || ''} onChange={e => setCloneSet(s => ({ ...s, [c.cname]: e.target.value }))}
|
<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) {
|
function fmtStamp(stamp) {
|
||||||
return new Date(stamp).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
|
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 = {
|
const OP_BADGE = {
|
||||||
baseline: 'bg-gray-100 text-gray-600',
|
baseline: 'bg-gray-700 text-gray-300',
|
||||||
reference: 'bg-blue-50 text-blue-600',
|
reference: 'bg-blue-900 text-blue-300',
|
||||||
scale: 'bg-green-50 text-green-700',
|
scale: 'bg-green-900 text-green-400',
|
||||||
recode: 'bg-amber-50 text-amber-700',
|
recode: 'bg-amber-900 text-amber-400',
|
||||||
clone: 'bg-purple-50 text-purple-700',
|
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 }) {
|
function Row({ label, children }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<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}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@ -699,7 +715,7 @@ function Row({ label, children }) {
|
|||||||
|
|
||||||
function Submit({ onClick, children }) {
|
function Submit({ onClick, children }) {
|
||||||
return (
|
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}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user