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 [scaleValue, setScaleValue] = useState('')
|
||||
const [scaleUnits, setScaleUnits] = useState('')
|
||||
const [scalePrice, setScalePrice] = useState('')
|
||||
const [scalePct, setScalePct] = useState(false)
|
||||
const [scaleNote, setScaleNote] = useState('')
|
||||
const [recodeSet, setRecodeSet] = useState({})
|
||||
@ -101,6 +102,7 @@ export default function Forecast({ sourceId, versionId }) {
|
||||
setCloneSet(blank)
|
||||
setScaleValue('')
|
||||
setScaleUnits('')
|
||||
setScalePrice('')
|
||||
if (Object.keys(slice).length > 0) fetchCurrentTotals(slice)
|
||||
else setCurrentTotals(null)
|
||||
}, [slice])
|
||||
@ -121,8 +123,22 @@ export default function Forecast({ sourceId, versionId }) {
|
||||
const view = await tableRef.current.view({ filter: filters })
|
||||
const rows = await view.to_json()
|
||||
await view.delete()
|
||||
const sum = (col) => col ? rows.reduce((s, r) => s + (parseFloat(r[col]) || 0), 0) : null
|
||||
setCurrentTotals({ value: sum(valueCol), units: sum(unitsCol), valueCol, unitsCol })
|
||||
const buckets = new Map()
|
||||
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 {
|
||||
setCurrentTotals(null)
|
||||
}
|
||||
@ -344,10 +360,16 @@ export default function Forecast({ sourceId, versionId }) {
|
||||
if (op === 'scale') {
|
||||
let vi = null, ui = null
|
||||
if (scaleMode === 'target') {
|
||||
if (scaleValue !== '' && currentTotals?.value != null)
|
||||
vi = parseFloat(scaleValue) - currentTotals.value
|
||||
if (scaleUnits !== '' && currentTotals?.units != null)
|
||||
ui = parseFloat(scaleUnits) - currentTotals.units
|
||||
const curValue = currentTotals?.total?.value
|
||||
const curUnits = currentTotals?.total?.units
|
||||
if (scalePrice !== '' && curUnits != null && curValue != null) {
|
||||
// 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 {
|
||||
if (scaleValue !== '') vi = scalePct ? parseFloat(scaleValue) : parseFloat(scaleValue)
|
||||
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 (data.rows?.length && tableRef.current) await tableRef.current.update(data.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 === 'clone') { setCloneNote(''); setCloneScale('1') }
|
||||
} catch (err) { flash(err.message, 'error') }
|
||||
@ -637,6 +659,40 @@ export default function Forecast({ sourceId, versionId }) {
|
||||
)}
|
||||
</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 && (
|
||||
<>
|
||||
<div className="flex border-b border-gray-100">
|
||||
@ -653,39 +709,38 @@ export default function Forecast({ sourceId, versionId }) {
|
||||
{/* Mode toggle */}
|
||||
<div className="flex rounded border border-gray-200 overflow-hidden">
|
||||
{[['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'}`}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Value row */}
|
||||
{currentTotals?.valueCol && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<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>
|
||||
<Row label={currentTotals.valueCol}>
|
||||
<input type="number" step="any" value={scaleValue}
|
||||
onChange={e => setScaleValue(e.target.value)}
|
||||
placeholder={scaleMode === 'target' ? 'target total' : '+ / − amount'}
|
||||
className={inp} />
|
||||
</div>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* Units row */}
|
||||
{currentTotals?.unitsCol && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<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>
|
||||
<Row label={currentTotals.unitsCol}>
|
||||
<input type="number" step="any" value={scaleUnits}
|
||||
onChange={e => setScaleUnits(e.target.value)}
|
||||
placeholder={scaleMode === 'target' ? 'target total' : '+ / − amount'}
|
||||
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' && (
|
||||
@ -739,6 +794,11 @@ function fmtBytes(n) {
|
||||
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) {
|
||||
return new Date(stamp).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user