// The operation workbench: what is selected, what it currently totals, and the
// scale / recode / clone forms. Rendered by Forecast into one of three shells
// (bottom dock, right rail, floating window).
//
// Two layout rules drive this file:
// 1. Every value you are replacing sits on the same row as the input that
// replaces it — current on the left, new on the right, delta after it.
// Reading a total in one place and typing its replacement somewhere else
// is what made the old panel hard to follow.
// 2. Controls never stretch. The bottom dock is as wide as the window, so
// flex-1 buttons grew to absurd sizes; everything here is fixed-width and
// left-aligned instead.
import { useState, useEffect } from 'react'
const INPUT = 'border border-gray-200 rounded px-2 py-1 text-xs bg-white w-28 text-right font-mono tabular-nums'
const TEXT = 'border border-gray-200 rounded px-2 py-1 text-xs bg-white w-40 font-mono'
function fmtNum(n, decimals = 2) {
if (n == null || !isFinite(n)) return '—'
return n.toLocaleString(undefined, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })
}
function fmtDelta(n, decimals = 2) {
if (n == null || !isFinite(n) || n === 0) return null
const sign = n > 0 ? '+' : '−'
return `${sign}${fmtNum(Math.abs(n), decimals)}`
}
function sliceLabel(s) {
const entries = Object.entries(s)
return entries.length ? entries.map(([k, v]) => `${k}=${v}`).join(' · ') : '—'
}
// A light grouping with an optional caption — a rule between blocks in the
// bottom dock, nothing but spacing elsewhere.
function Block({ title, hint, horizontal, grow, children }) {
return (
{title && (
{title}
{hint && {hint}}
)}
{children}
)
}
function Button({ onClick, active, children, title }) {
return (
)
}
function Segmented({ options, value, onChange }) {
return (
Click a pivot row to select a slice. Ctrl/⌘-click to add more.
)
}
return (
{slices.map((s, i) => (
{multi
? {sliceLabel(s)}
: (
{Object.entries(s).map(([k, v]) => (
{k} = {v}
))}
)}
{multi && valueCol && (
{fmtNum(perSlice[i]?.total?.value)}
)}
))}
)
}
// Rows by pf_iter — useful context, but secondary to the numbers you are editing,
// so it collapses out of the way.
function IterBreakdown({ currentTotals }) {
const [open, setOpen] = useState(false)
const rows = currentTotals?.byIter || []
if (rows.length < 2) return null
const { valueCol, unitsCol } = currentTotals
return (
{open && (
{rows.map(r => (
{r.iter}
{valueCol &&
{fmtNum(r.value)}
}
{unitsCol &&
{fmtNum(r.units)}
}
))}
)}
)
}
// ── 2. Scale ledger ─────────────────────────────────────────────────────────
// One continuous statement: where the number came from, what it is now, and what
// you want it to be — with the edit attached to the bottom of the same table
// rather than lifted into a separate block.
//
// Baseline 1,000.00
// Scale -20.00
// ─────────────────────────
// Current 1,070.00
// ─────────────────────────
// New value [ 2,000 ]
// Change [ 930 ]
// % change [ 86.9 ]
//
// The last three rows are all editable and all describe the same change: type in
// any one and the other two follow. Whichever you typed in is what gets sent.
const FIELDS = [
['new', 'New value'],
['change', 'Change'],
['pct', '% change'],
]
// given the active edit for a measure, what do the three rows read?
// dp is the measure's own precision, so the editable rows read the same way as
// the lines above them -- whole dollars against whole dollars, five places
// against five. The percentage is its own scale and stays at one.
function derive(current, edit, dp = 2) {
const blank = { new: '', change: '', pct: '' }
if (!edit || edit.raw === '' || edit.raw == null) return blank
const n = parseFloat(edit.raw)
if (!isFinite(n)) return { ...blank, [edit.field]: edit.raw }
let next
if (edit.field === 'new') next = n
else if (edit.field === 'change') next = current + n
else next = current + current * n / 100
const change = next - current
const pct = current === 0 ? null : (change / Math.abs(current)) * 100
const out = {
new: fmtNum(next, dp),
change: fmtNum(change, dp),
pct: pct == null ? '—' : fmtNum(pct, 1),
}
out[edit.field] = edit.raw // keep what you typed exactly as typed
return out
}
function LedgerInput({ value, active, onChange, onFocus, suffix }) {
return (
onChange(e.target.value)}
onFocus={onFocus} placeholder="—"
className={`border rounded px-2 py-0.5 text-xs w-24 text-right font-mono tabular-nums
${active ? 'border-blue-400 bg-blue-50/40 text-gray-800' : 'border-gray-200 bg-white text-gray-700'}`} />
{suffix && {suffix}}
)
}
function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, setScalePlug, targetBasis, setTargetBasis, logMeta = {}, multi, applyMode }) {
const valueCol = currentTotals?.valueCol
const unitsCol = currentTotals?.unitsCol
const total = currentTotals?.total || { value: 0, units: 0 }
const curPrice = total.units ? total.value / total.units : null
const entries = currentTotals?.byEntry || []
// The bridge: baseline, then one line per initiative, then whatever is untagged.
// Adjustments sharing a tag collapse into a single line, so the ledger reads as a
// walk from baseline to current rather than a list of log ids.
const lines = (() => {
const baseline = []
const loose = []
const byTag = new Map()
for (const e of entries) {
if (e.key === 'baseline') { baseline.push({ ...e, label: 'Baseline', kind: 'baseline' }); continue }
const meta = logMeta[e.logid] || {}
const tag = (meta.tag || '').trim()
if (tag) {
const g = byTag.get(tag) ||
{ key: `tag:${tag}`, label: tag, kind: 'tag', value: 0, units: 0, count: 0, first: e.logid }
g.value += e.value || 0
g.units += e.units || 0
g.count += 1
g.first = Math.min(g.first ?? e.logid, e.logid ?? g.first)
byTag.set(tag, g)
} else {
const op = meta.operation || e.iter || 'adjustment'
loose.push({
...e, kind: 'entry', count: 1,
label: (meta.note || '').trim() || `${op.charAt(0).toUpperCase()}${op.slice(1)} #${e.logid}`,
})
}
}
const tagged = [...byTag.values()].sort((a, b) => (a.first ?? 0) - (b.first ?? 0))
return [...baseline, ...tagged, ...loose]
})()
const perSlice = multi && applyMode === 'each'
// rows the pivot shows but this operation cannot write
const excl = currentTotals?.excluded || { value: 0, units: 0, rows: 0 }
const hasExcl = excl.rows > 0 && (excl.value !== 0 || excl.units !== 0)
const onTotal = hasExcl && targetBasis !== 'adjustable' // 'selected total' is the default
const grand = { value: total.value + excl.value, units: total.units + excl.units }
const exclName = (currentTotals?.excludedIters || []).join(' / ') || 'excluded'
// the basis decides which line the editable rows are measured from
const basisOf = (key) => {
if (!onTotal) return key === 'price' ? curPrice : total[key]
if (key === 'price') return grand.units ? grand.value / grand.units : null
return grand[key]
}
// Every line that has both measures has a price -- a bridge line's is the
// implied price of that initiative's own contribution, which is the number
// that says whether it was a price move or a volume move. Only a line with no
// units has nothing to show, since the price is undefined rather than zero.
const priceOf = (row) => (row && row.units) ? row.value / row.units : null
// measure columns, in ledger order
const measures = [
// Whole units for the measures -- the figures run to eight digits, where two
// decimals are noise. Price is the opposite: it sits around 0.27, so it needs
// the places to show a move at all.
valueCol && { key: 'value', label: valueCol, current: total.value, dp: 0 },
unitsCol && { key: 'units', label: unitsCol, current: total.units, dp: 0 },
(valueCol && unitsCol) && { key: 'price', label: 'price', current: curPrice, dp: 5, hint: 'value / units' },
].filter(Boolean)
const derived = Object.fromEntries(
measures.map(m => [m.key, derive(basisOf(m.key) ?? 0, scaleInputs[m.key], m.dp)])
)
// an edit is dollars-only when value carries a number and neither units nor
// price does -- the one case where price and volume are both still unknown
const filled = (key) => {
const raw = scaleInputs[key]?.raw
return raw != null && raw !== '' && isFinite(parseFloat(raw))
}
const dollarsOnly = filled('value') && !filled('units') && !filled('price')
// Where the edit lands, for all three measures at once. The rows above each
// show only their own input, so a dollar figure entered with plug = volume
// left units and price blank -- exactly the two numbers you need to see to
// know whether you asked for a price change or a volume change.
const numOf = (key, field) => {
const raw = derived[key]?.[field]
const n = parseFloat(raw)
return isFinite(n) ? n : null
}
const outcome = (() => {
const curValue = basisOf('value') ?? 0
const curUnits = basisOf('units') ?? 0
if (!filled('value') && !filled('units') && !filled('price')) return null
let newValue, newUnits
if (filled('price') && !filled('value')) {
// price is the input; dollars fall out of price x volume
const targetPrice = numOf('price', 'new')
newUnits = filled('units') ? numOf('units', 'new') : curUnits
newValue = targetPrice != null && newUnits != null ? targetPrice * newUnits : null
} else {
newValue = filled('value') ? numOf('value', 'new') : curValue
if (filled('units')) newUnits = numOf('units', 'new')
else if (dollarsOnly && scalePlug === 'volume') newUnits = curValue === 0 ? null
: curUnits * (newValue / curValue)
else newUnits = curUnits
}
if (newValue == null || newUnits == null) return null
const newPrice = newUnits === 0 ? null : newValue / newUnits
const curPriceLocal = curUnits === 0 ? null : curValue / curUnits
return {
value: newValue,
units: newUnits,
price: newPrice,
moved: {
value: Math.abs(newValue - curValue) > 1e-9,
units: Math.abs(newUnits - curUnits) > 1e-9,
price: curPriceLocal != null && newPrice != null && Math.abs(newPrice - curPriceLocal) > 1e-9,
},
}
})()
const setEdit = (key, field, raw) =>
setScaleInputs(prev => ({ ...prev, [key]: { field, raw } }))
// focusing a different row hands that measure's edit to the focused row,
// carrying across whatever it currently reads
const focusRow = (key, field) =>
setScaleInputs(prev => {
const cur = prev[key]
if (cur && cur.field === field) return prev
const shown = derived[key]?.[field] ?? ''
const raw = shown === '—' ? '' : String(shown).replace(/,/g, '')
return { ...prev, [key]: { field, raw: cur ? raw : '' } }
})
const numCell = 'text-right font-mono tabular-nums whitespace-nowrap px-2'
const rule =
return (
{measures.map(m => (
{m.label}
{m.hint && · {m.hint}}
))}
{/* the walk from baseline to current, by initiative */}
{lines.map(e => (
{/* A dollar figure on its own does not say whether price or volume moved.
Only ask once the edit is actually dollars-only -- naming units or price
has already answered it, and the control would just be noise. */}
{valueCol && unitsCol && dollarsOnly && (
Absorbed by
{scalePlug === 'price'
? `${unitsCol} holds; price moves to reach the number.`
: `Price holds; ${unitsCol} scales with the dollars.`}
)}
{hasExcl && (
Target applies to
{onTotal
? `The ${exclName} rows cannot change, so the adjustable rows absorb the whole difference — the pivot will show your target.`
: `${exclName} rows are ignored. The pivot will show your target plus ${fmtNum(excl.value)}.`}
)}
{perSlice && (
Applied to each slice separately — the figures above are combined totals,
so each slice's own change will differ.
)}
)
}
// Completion for a key dimension. The list is fetched as you type rather than up
// front: part alone has 11,290 distinct values, and a native datalist given all of
// them is slow to open and no easier to read than a short filtered one.
function DimValueInput({ col, members, versionId, value, onChange, onBlur, className }) {
const [fallback, setFallback] = useState([])
const listId = `pf-vals-${col.cname}`
const hasList = !!members?.length
// Without a member list for this group -- never refreshed, or the column is not
// in one -- fall back to the version's own values. That is a 2s scan held in
// memory server-side, so it stays debounced rather than firing per keystroke.
useEffect(() => {
if (hasList || !versionId || !col.is_key) return
let cancelled = false
const t = setTimeout(async () => {
try {
const url = `/api/versions/${versionId}/values/${encodeURIComponent(col.cname)}`
+ `?limit=50${value ? `&q=${encodeURIComponent(value)}` : ''}`
const rows = await fetch(url).then(r => r.ok ? r.json() : [])
if (!cancelled) setFallback(Array.isArray(rows) ? rows : [])
} catch { if (!cancelled) setFallback([]) }
}, 200)
return () => { cancelled = true; clearTimeout(t) }
}, [hasList, versionId, col.cname, col.is_key, value])
// The member list is already in memory, so filtering it costs nothing and needs
// no debounce -- the options move with the keystroke.
const options = hasList
? (() => {
const q = (value || '').trim().toLowerCase()
const all = members.map(m => m.key_value)
return (q ? all.filter(v => v.toLowerCase().includes(q)) : all).slice(0, 50)
})()
: fallback
return (
<>
{col.is_key && (
)}
>
)
}
// ── 2b. Recode / clone form ─────────────────────────────────────────────────
// Same pairing: the dimension's current value sits beside the box that replaces it.
function DimForm({ dimCols, setObj, setSet, slices, lookupDerivedCols, dimMembers, versionId, extra }) {
const multi = slices.length > 1
const first = slices[0] || {}
return (
dimension
current
new value
{dimCols.map(c => {
const cur = multi
? (new Set(slices.map(s => s[c.cname])).size > 1 ? '(varies)' : (first[c.cname] ?? '—'))
: (first[c.cname] ?? '—')
return (
)
}
// Recode and clone change dimensions rather than amounts, but you still want to
// see how much is on the move — and for clone, what it becomes after scaling.
function MovingTotal({ currentTotals, verb, factor }) {
const t = currentTotals?.total
if (!t) return null
const { valueCol, unitsCol } = currentTotals
const scaled = factor != null && factor !== 1
return (
)
}
// ── Apply mode ──────────────────────────────────────────────────────────────
function ApplyModeChooser({ op, applyMode, setApplyMode, count }) {
const options = op === 'scale'
? [
['prorate', 'Together', `One pool of ${count} slices — a target is the new combined total, and each slice keeps its share of the mix.`],
['each', 'Each', `All ${count} slices independently — every slice reaches the target on its own.`],
]
: [
['prorate', 'Together', `One operation over all ${count} slices — a single log entry.`],
['each', 'Each', `One operation per slice — ${count} log entries, undoable separately.`],
]
const active = options.find(([v]) => v === applyMode)
return (
[v, l, t])} />
{active &&
{active[2]}
}
)
}
function RequestPreview({ payload }) {
const [open, setOpen] = useState(false)
if (!payload) return null
return (
{/* Cells that differ only by a column operations cannot filter on collapse
to the same slice — say so, rather than implying more reach than there is */}
{distinctSlices != null && distinctSlices < slices.length && (
{slices.length} cells selected, but they cover {distinctSlices} distinct{' '}
{distinctSlices === 1 ? 'slice' : 'slices'} — some differ only by a column
operations cannot target. The duplicates are applied once.
{activeOp === 'scale' && (
)}
{activeOp === 'recode' && (
} />
)}
{activeOp === 'clone' && (
{/* The selection is the SOURCE, not the destination: pick
the cells you want to copy -- last December, say -- and
the shift below is what lands them in the target period.
Naming a segment narrows that selection to one entry,
including the reference ones operations are normally kept
away from, which is the point: a period with no baseline
borrows its shape from prior year or plan. */}
copy rows from
{/* Always shown. This was gated on picking a segment, from
when naming one was the only way to reach reference rows
-- once clone could read them directly, the offset became
unreachable in the ordinary case. */}
shift dates by
setCloneOffset(e.target.value)}
placeholder="0 days" className={`${INPUT} w-28`} />
The cells you have selected are what gets copied. Shifting
moves every date column, and the season and month
dimensions are re-derived from the calendar to match — so
select the period you are copying from, not the
one you are filling.
)}
scale cloned rows by
setCloneScale(e.target.value)} className={INPUT} />
{!cloneFrom && (
)}
} />
)}
)}
{hasSlice && (
{/* Tag first: it is the field that gives an adjustment meaning later,
in the ledger and in the bridge. Completes from initiatives already
used on this source; free text is still accepted. */}
tag
setOpTag(e.target.value)}
list="pf-tag-options" placeholder="initiative, e.g. reduce_spend"
className={`${TEXT} w-48`} />
{opTag.trim() && (
)}