Workbench: per-iteration totals and price-driven scaling
The slice panel was a single muted line; now it shows a breakdown table — value, units, and derived price for baseline / scale / recode / clone, with a bold total row when more than one iteration applies. Numbers use full text contrast so the current state is legible at a glance during adjustments. Scale gains a price input that holds units constant and translates to a value-target call (target value = new_price × current_units). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
15ab5a3a13
commit
a5e59f823a
@ -49,6 +49,7 @@ export default function Forecast({ sourceId, versionId }) {
|
|||||||
const [scaleMode, setScaleMode] = useState('target') // 'target' | 'delta'
|
const [scaleMode, setScaleMode] = useState('target') // 'target' | 'delta'
|
||||||
const [scaleValue, setScaleValue] = useState('')
|
const [scaleValue, setScaleValue] = useState('')
|
||||||
const [scaleUnits, setScaleUnits] = useState('')
|
const [scaleUnits, setScaleUnits] = useState('')
|
||||||
|
const [scalePrice, setScalePrice] = useState('')
|
||||||
const [scalePct, setScalePct] = useState(false)
|
const [scalePct, setScalePct] = useState(false)
|
||||||
const [scaleNote, setScaleNote] = useState('')
|
const [scaleNote, setScaleNote] = useState('')
|
||||||
const [recodeSet, setRecodeSet] = useState({})
|
const [recodeSet, setRecodeSet] = useState({})
|
||||||
@ -101,6 +102,7 @@ export default function Forecast({ sourceId, versionId }) {
|
|||||||
setCloneSet(blank)
|
setCloneSet(blank)
|
||||||
setScaleValue('')
|
setScaleValue('')
|
||||||
setScaleUnits('')
|
setScaleUnits('')
|
||||||
|
setScalePrice('')
|
||||||
if (Object.keys(slice).length > 0) fetchCurrentTotals(slice)
|
if (Object.keys(slice).length > 0) fetchCurrentTotals(slice)
|
||||||
else setCurrentTotals(null)
|
else setCurrentTotals(null)
|
||||||
}, [slice])
|
}, [slice])
|
||||||
@ -121,8 +123,22 @@ export default function Forecast({ sourceId, versionId }) {
|
|||||||
const view = await tableRef.current.view({ filter: filters })
|
const view = await tableRef.current.view({ filter: filters })
|
||||||
const rows = await view.to_json()
|
const rows = await view.to_json()
|
||||||
await view.delete()
|
await view.delete()
|
||||||
const sum = (col) => col ? rows.reduce((s, r) => s + (parseFloat(r[col]) || 0), 0) : null
|
const buckets = new Map()
|
||||||
setCurrentTotals({ value: sum(valueCol), units: sum(unitsCol), valueCol, unitsCol })
|
for (const r of rows) {
|
||||||
|
const k = r.pf_iter || '?'
|
||||||
|
const t = buckets.get(k) || { value: 0, units: 0 }
|
||||||
|
if (valueCol) t.value += parseFloat(r[valueCol]) || 0
|
||||||
|
if (unitsCol) t.units += parseFloat(r[unitsCol]) || 0
|
||||||
|
buckets.set(k, t)
|
||||||
|
}
|
||||||
|
const ITER_ORDER = ['baseline', 'scale', 'recode', 'clone']
|
||||||
|
const byIter = Array.from(buckets, ([iter, t]) => ({ iter, ...t }))
|
||||||
|
.sort((a, b) => {
|
||||||
|
const ai = ITER_ORDER.indexOf(a.iter), bi = ITER_ORDER.indexOf(b.iter)
|
||||||
|
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi)
|
||||||
|
})
|
||||||
|
const total = byIter.reduce((s, r) => ({ value: s.value + (r.value || 0), units: s.units + (r.units || 0) }), { value: 0, units: 0 })
|
||||||
|
setCurrentTotals({ byIter, total, valueCol, unitsCol })
|
||||||
} catch {
|
} catch {
|
||||||
setCurrentTotals(null)
|
setCurrentTotals(null)
|
||||||
}
|
}
|
||||||
@ -344,10 +360,16 @@ export default function Forecast({ sourceId, versionId }) {
|
|||||||
if (op === 'scale') {
|
if (op === 'scale') {
|
||||||
let vi = null, ui = null
|
let vi = null, ui = null
|
||||||
if (scaleMode === 'target') {
|
if (scaleMode === 'target') {
|
||||||
if (scaleValue !== '' && currentTotals?.value != null)
|
const curValue = currentTotals?.total?.value
|
||||||
vi = parseFloat(scaleValue) - currentTotals.value
|
const curUnits = currentTotals?.total?.units
|
||||||
if (scaleUnits !== '' && currentTotals?.units != null)
|
if (scalePrice !== '' && curUnits != null && curValue != null) {
|
||||||
ui = parseFloat(scaleUnits) - currentTotals.units
|
// hold units constant; new value = price × current units
|
||||||
|
vi = (parseFloat(scalePrice) * curUnits) - curValue
|
||||||
|
}
|
||||||
|
if (scaleValue !== '' && curValue != null)
|
||||||
|
vi = parseFloat(scaleValue) - curValue
|
||||||
|
if (scaleUnits !== '' && curUnits != null)
|
||||||
|
ui = parseFloat(scaleUnits) - curUnits
|
||||||
} else {
|
} else {
|
||||||
if (scaleValue !== '') vi = scalePct ? parseFloat(scaleValue) : parseFloat(scaleValue)
|
if (scaleValue !== '') vi = scalePct ? parseFloat(scaleValue) : parseFloat(scaleValue)
|
||||||
if (scaleUnits !== '') ui = scalePct ? parseFloat(scaleUnits) : parseFloat(scaleUnits)
|
if (scaleUnits !== '') ui = scalePct ? parseFloat(scaleUnits) : parseFloat(scaleUnits)
|
||||||
@ -372,7 +394,7 @@ export default function Forecast({ sourceId, versionId }) {
|
|||||||
if (!res.ok) { flash(data.error, 'error'); return }
|
if (!res.ok) { flash(data.error, 'error'); return }
|
||||||
if (data.rows?.length && tableRef.current) await tableRef.current.update(data.rows)
|
if (data.rows?.length && tableRef.current) await tableRef.current.update(data.rows)
|
||||||
flash(`${op}: ${data.rows_affected ?? data.rows?.length ?? ''} rows`)
|
flash(`${op}: ${data.rows_affected ?? data.rows?.length ?? ''} rows`)
|
||||||
if (op === 'scale') { setScaleValue(''); setScaleUnits(''); setScaleNote(''); fetchCurrentTotals(slice) }
|
if (op === 'scale') { setScaleValue(''); setScaleUnits(''); setScalePrice(''); setScaleNote(''); fetchCurrentTotals(slice) }
|
||||||
if (op === 'recode') { setRecodeNote('') }
|
if (op === 'recode') { setRecodeNote('') }
|
||||||
if (op === 'clone') { setCloneNote(''); setCloneScale('1') }
|
if (op === 'clone') { setCloneNote(''); setCloneScale('1') }
|
||||||
} catch (err) { flash(err.message, 'error') }
|
} catch (err) { flash(err.message, 'error') }
|
||||||
@ -637,6 +659,40 @@ export default function Forecast({ sourceId, versionId }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{hasSlice && currentTotals?.byIter?.length > 0 && (
|
||||||
|
<div className="px-3 py-2 border-b border-gray-100">
|
||||||
|
<div className="font-medium text-gray-400 uppercase tracking-wide mb-1.5" style={{fontSize:'10px'}}>Current</div>
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-gray-400" style={{fontSize:'10px'}}>
|
||||||
|
<th className="text-left font-normal pb-1"></th>
|
||||||
|
{currentTotals.valueCol && <th className="text-right font-normal pb-1 pl-1">{currentTotals.valueCol}</th>}
|
||||||
|
{currentTotals.unitsCol && <th className="text-right font-normal pb-1 pl-1">{currentTotals.unitsCol}</th>}
|
||||||
|
{currentTotals.valueCol && currentTotals.unitsCol && <th className="text-right font-normal pb-1 pl-1">price</th>}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{currentTotals.byIter.map(r => (
|
||||||
|
<tr key={r.iter}>
|
||||||
|
<td className="text-gray-500 capitalize pr-1">{r.iter}</td>
|
||||||
|
{currentTotals.valueCol && <td className="text-right font-mono text-gray-700 pl-1">{fmtNum(r.value)}</td>}
|
||||||
|
{currentTotals.unitsCol && <td className="text-right font-mono text-gray-700 pl-1">{fmtNum(r.units)}</td>}
|
||||||
|
{currentTotals.valueCol && currentTotals.unitsCol && <td className="text-right font-mono text-gray-700 pl-1">{fmtNum(r.units ? r.value / r.units : null, 4)}</td>}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{currentTotals.byIter.length > 1 && (
|
||||||
|
<tr className="border-t border-gray-100">
|
||||||
|
<td className="text-gray-600 font-medium pt-1 pr-1">total</td>
|
||||||
|
{currentTotals.valueCol && <td className="text-right font-mono font-semibold text-gray-800 pt-1 pl-1">{fmtNum(currentTotals.total.value)}</td>}
|
||||||
|
{currentTotals.unitsCol && <td className="text-right font-mono font-semibold text-gray-800 pt-1 pl-1">{fmtNum(currentTotals.total.units)}</td>}
|
||||||
|
{currentTotals.valueCol && currentTotals.unitsCol && <td className="text-right font-mono font-semibold text-gray-800 pt-1 pl-1">{fmtNum(currentTotals.total.units ? currentTotals.total.value / currentTotals.total.units : null, 4)}</td>}
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{hasSlice && (
|
{hasSlice && (
|
||||||
<>
|
<>
|
||||||
<div className="flex border-b border-gray-100">
|
<div className="flex border-b border-gray-100">
|
||||||
@ -653,39 +709,38 @@ export default function Forecast({ sourceId, versionId }) {
|
|||||||
{/* Mode toggle */}
|
{/* Mode toggle */}
|
||||||
<div className="flex rounded border border-gray-200 overflow-hidden">
|
<div className="flex rounded border border-gray-200 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(''); setScalePrice('') }}
|
||||||
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-white text-gray-500 hover:bg-gray-50'}`}>
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Value row */}
|
|
||||||
{currentTotals?.valueCol && (
|
{currentTotals?.valueCol && (
|
||||||
<div className="flex flex-col gap-1">
|
<Row label={currentTotals.valueCol}>
|
||||||
<div className="flex items-center justify-between text-gray-400">
|
|
||||||
<span>{currentTotals.valueCol}</span>
|
|
||||||
<span className="font-mono">{currentTotals.value?.toLocaleString(undefined, {maximumFractionDigits:2})}</span>
|
|
||||||
</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)}
|
||||||
placeholder={scaleMode === 'target' ? 'target total' : '+ / − amount'}
|
placeholder={scaleMode === 'target' ? 'target total' : '+ / − amount'}
|
||||||
className={inp} />
|
className={inp} />
|
||||||
</div>
|
</Row>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Units row */}
|
|
||||||
{currentTotals?.unitsCol && (
|
{currentTotals?.unitsCol && (
|
||||||
<div className="flex flex-col gap-1">
|
<Row label={currentTotals.unitsCol}>
|
||||||
<div className="flex items-center justify-between text-gray-400">
|
|
||||||
<span>{currentTotals.unitsCol}</span>
|
|
||||||
<span className="font-mono">{currentTotals.units?.toLocaleString(undefined, {maximumFractionDigits:2})}</span>
|
|
||||||
</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)}
|
||||||
placeholder={scaleMode === 'target' ? 'target total' : '+ / − amount'}
|
placeholder={scaleMode === 'target' ? 'target total' : '+ / − amount'}
|
||||||
className={inp} />
|
className={inp} />
|
||||||
</div>
|
</Row>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{scaleMode === 'target' && currentTotals?.valueCol && currentTotals?.unitsCol && (
|
||||||
|
<Row label="price">
|
||||||
|
<input type="number" step="any" value={scalePrice}
|
||||||
|
onChange={e => setScalePrice(e.target.value)}
|
||||||
|
placeholder="target price (holds units)"
|
||||||
|
className={inp} />
|
||||||
|
</Row>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{scaleMode === 'delta' && (
|
{scaleMode === 'delta' && (
|
||||||
@ -739,6 +794,11 @@ function fmtBytes(n) {
|
|||||||
return `${(n / 1048576).toFixed(1)} MB`
|
return `${(n / 1048576).toFixed(1)} MB`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fmtNum(n, decimals = 2) {
|
||||||
|
if (n == null || !isFinite(n)) return '—'
|
||||||
|
return n.toLocaleString(undefined, { maximumFractionDigits: decimals })
|
||||||
|
}
|
||||||
|
|
||||||
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' })
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user