diff --git a/ui/src/views/Forecast.jsx b/ui/src/views/Forecast.jsx index f6a8e54..fdafd76 100644 --- a/ui/src/views/Forecast.jsx +++ b/ui/src/views/Forecast.jsx @@ -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 }) { )} + {hasSlice && currentTotals?.byIter?.length > 0 && ( +
| + {currentTotals.valueCol && | {currentTotals.valueCol} | } + {currentTotals.unitsCol &&{currentTotals.unitsCol} | } + {currentTotals.valueCol && currentTotals.unitsCol &&price | } +
|---|---|---|---|
| {r.iter} | + {currentTotals.valueCol &&{fmtNum(r.value)} | } + {currentTotals.unitsCol &&{fmtNum(r.units)} | } + {currentTotals.valueCol && currentTotals.unitsCol &&{fmtNum(r.units ? r.value / r.units : null, 4)} | } +
| total | + {currentTotals.valueCol &&{fmtNum(currentTotals.total.value)} | } + {currentTotals.unitsCol &&{fmtNum(currentTotals.total.units)} | } + {currentTotals.valueCol && currentTotals.unitsCol &&{fmtNum(currentTotals.total.units ? currentTotals.total.value / currentTotals.total.units : null, 4)} | } +