// 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 ( ) } // A label and its control, on a grid. Every row in the panel uses the same label // width, so the controls line up down the column instead of each row starting // wherever its label happens to end -- which was the whole problem with // "copy rows from" sitting above "scale cloned rows by" above "tag". const LABEL_W = 'w-24' function Field({ label, children, hint }) { return (
{label}
{children}
{hint &&

{hint}

}
) } // Same 10px uppercase as the ledger table headers, so the groupings read as part // of the same family rather than as a second style. function SectionLabel({ children }) { return (
{children}
) } function Segmented({ options, value, onChange }) { return (
{options.map(([val, label, title]) => ( ))}
) } function Submit({ onClick, children, disabled }) { return ( ) } // ── 1. Selection ──────────────────────────────────────────────────────────── function SelectionList({ slices, currentTotals, onRemove, onClear }) { const multi = slices.length > 1 const perSlice = currentTotals?.perSlice || [] const valueCol = currentTotals?.valueCol if (!slices.length) { return (

Click a pivot row to select a slice.
Ctrl/⌘-click to add more.

) } return (
{slices.map((s, i) => ( {multi && valueCol && ( )} ))}
{multi ? {sliceLabel(s)} : (
{Object.entries(s).map(([k, v]) => (
{k} = {v}
))}
)}
{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 => ( {valueCol && } {unitsCol && } ))}
{r.iter}{fmtNum(r.value)}{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 } // Named by the segments themselves where we have them -- "02 - Prior Year" // reads as a thing a forecaster recognises, where "reference" names only the // iter band that happens to exclude it. const exclName = (currentTotals?.excluded?.names || []).join(' · ') || (currentTotals?.excludedIters || []).join(' / ') || 'excluded' // Everything in the selection is immovable. Worth saying outright: the panel // otherwise prints a row of zeros and leaves the reason to be worked out. const nothingToAdjust = hasExcl && !total.value && !total.units // 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 => ( ))} {/* the walk from baseline to current, by initiative */} {lines.map(e => ( {measures.map(m => ( ))} ))} {rule}{measures.map(m => )} {measures.map(m => ( ))} {/* Rows the pivot shows but operations cannot write. Listed so the panel's figures reconcile with what the grid displays. */} {hasExcl && ( {measures.map(m => ( ))} )} {hasExcl && ( {measures.map(m => ( ))} )} {/* Zeros in the Adjustable row are a true answer to the wrong question: they say how much can move, not why none of it can. Spell it out where the eye already is, rather than leaving the edit rows to fail silently below. */} {nothingToAdjust && ( )} {rule}{measures.map(m => )} {/* the edit — three equivalent ways to say the same thing */} {FIELDS.map(([field, label]) => ( {measures.map(m => { const active = scaleInputs[m.key]?.field === field return ( ) })} ))} {outcome && ( <> {rule}{measures.map(m => )} {measures.map(m => ( ))} )}
{m.label} {m.hint && · {m.hint}}
{e.kind === 'tag' && } {e.label} {e.kind === 'tag' && e.count > 1 && ×{e.count}} {m.key === 'price' ? fmtNum(priceOf(e), m.dp) : fmtNum(e[m.key], m.dp)}
{hasExcl ? 'Adjustable' : 'Current'}{perSlice ? ' (all)' : ''} {fmtNum(m.current, m.dp)}
{exclName} final {m.key === 'price' ? fmtNum(priceOf(excl), m.dp) : fmtNum(excl[m.key], m.dp)}
Selected total {m.key === 'price' ? fmtNum(grand.units ? grand.value / grand.units : null, m.dp) : fmtNum(grand[m.key], m.dp)}
Nothing in this selection can be adjusted — all of it is {exclName}, loaded as {(currentTotals?.excludedIters || []).join(' / ') || 'reference'}.
{label}{perSlice && field === 'new' ? ' (each)' : ''} setEdit(m.key, field, raw)} onFocus={() => focusRow(m.key, field)} suffix={field === 'pct' ? '%' : null} />
Result {fmtNum(outcome[m.key], m.dp)}
{/* 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 && ( {options.map(o => )} ) } // ── 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 (
{dimCols.map(c => { const cur = multi ? (new Set(slices.map(s => s[c.cname])).size > 1 ? '(varies)' : (first[c.cname] ?? '—')) : (first[c.cname] ?? '—') return ( ) })}
dimension current new value
{c.label || c.cname} {cur} setSet(s => ({ ...s, [c.cname]: e.target.value }))} onBlur={c.is_key && c.dim_group ? e => lookupDerivedCols(c.cname, e.target.value, setSet) : undefined} className={TEXT} />
{extra}
) } // 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. // includeExcluded: clone reads reference rows too, so its preview has to count // them. Reporting the adjustable total alone said "Copying 0.00" for a selection // made entirely of prior year or plan -- the exact case clone exists for. function MovingTotal({ currentTotals, verb, factor, includeExcluded }) { const adj = currentTotals?.total if (!adj) return null const ex = currentTotals?.excluded const t = includeExcluded && ex ? { value: (adj.value || 0) + (ex.value || 0), units: (adj.units || 0) + (ex.units || 0) } : adj const { valueCol, unitsCol } = currentTotals const scaled = factor != null && factor !== 1 return (

{verb}{' '} {valueCol && {fmtNum(t.value)}} {valueCol && {valueCol}} {unitsCol && <> · {fmtNum(t.units)} {unitsCol} } {scaled && valueCol && <> {fmtNum(t.value * factor)} }

) } // ── 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 (
{open && (
          {JSON.stringify(payload, null, 2)}
        
)}
) } export default function OperationPanel({ dock, slices, setSlices, distinctSlices, applyMode, setApplyMode, currentTotals, activeOp, setActiveOp, scaleInputs, setScaleInputs, scalePlug, setScalePlug, targetBasis, setTargetBasis, opTag, setOpTag, knownTags = [], logMeta = {}, scaleNote, setScaleNote, recodeSet, setRecodeSet, recodeNote, setRecodeNote, cloneSet, setCloneSet, cloneScale, setCloneScale, cloneFrom, setCloneFrom, cloneOffset, setCloneOffset, cloneSources, cloneNote, setCloneNote, dimCols, lookupDerivedCols, dimMembers, versionId, buildPayload, submitOp, }) { const hasSlice = slices.length > 0 const multi = slices.length > 1 const horizontal = dock === 'bottom' const note = activeOp === 'scale' ? scaleNote : activeOp === 'recode' ? recodeNote : cloneNote const shifting = !!cloneOffset && cloneOffset.trim() !== '' && cloneOffset.trim() !== '0 days' const setNote = activeOp === 'scale' ? setScaleNote : activeOp === 'recode' ? setRecodeNote : setCloneNote const OP_LABEL = { scale: 'Apply Scale', recode: 'Apply Recode', clone: 'Apply Clone' } 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.

)} setSlices(prev => prev.filter((_, x) => x !== i))} onClear={() => setSlices([])} />
{hasSlice && (
{['scale', 'recode', 'clone'].map(op => ( ))}
{multi && ( )}
{activeOp === 'scale' && ( )} {activeOp === 'recode' && (
} /> )} {activeOp === 'clone' && ( source {/* The selection is the SOURCE, not the destination: pick the cells you want to copy -- last December, say -- and the shift 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. */} changes setCloneOffset(e.target.value)} placeholder="0 days" className={`${TEXT} w-28`} /> setCloneScale(e.target.value)} className={`${INPUT} w-28`} /> × {/* Sits under the controls it reflects rather than floating after them, and counts the reference rows clone can now read -- it used to report the adjustable total, which is zero when the selection is entirely prior year or plan. */}
} /> )}
)} {hasSlice && (
label this change {/* 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. */} setOpTag(e.target.value)} list="pf-tag-options" placeholder="initiative, e.g. reduce_spend" className={`${TEXT} w-48`} /> {opTag.trim() && ( )} {/* Under the tag field and inside the same column, so they read as values for it rather than as a row of unexplained buttons. */} {knownTags.length > 0 && (
{knownTags.slice(0, 6).map(t => ( ))}
)} setNote(e.target.value)} placeholder="optional" className={`${TEXT} w-48`} />
submitOp(activeOp)}>{OP_LABEL[activeOp]}
)} ) }