// 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, useRef, useLayoutEffect } 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)}` } // A blank dimension value has to read as a value, or a slice isolated to the // blank node is indistinguishable from one the column is absent from -- and the // two mean opposite things. Both blanks occur in the same column here, so they // are named apart: null is a missing value, '' is a present empty one. function dispVal(v) { if (v === null || v === undefined) return '(null)' if (v === '') return '(blank)' return String(v) } function sliceLabel(s) { const entries = Object.entries(s) return entries.length ? entries.map(([k, v]) => `${k}=${dispVal(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, busy }) { return ( ) } // ── 1. Selection ──────────────────────────────────────────────────────────── function SelectionList({ slices, viewScope = [], currentTotals, onRemove, onClear }) { const multi = slices.length > 1 const perSlice = currentTotals?.perSlice || [] const valueCol = currentTotals?.valueCol // The pivot's own filter. Shown because it scopes every figure below and // every row the operation writes, while appearing in none of the slices -- // perspective-click reports only the cell's own dimensions, so without this // the panel prints a selection wider than the one it is acting on. const scopeLine = viewScope .map(([col, op, ...rest]) => { const vals = (Array.isArray(rest[0]) ? rest[0] : rest).filter(v => v !== undefined) return `${col} ${op}${vals.length ? ' ' + vals.join(', ') : ''}` }) .join(' · ') if (!slices.length) { return (

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

) } return (
{scopeLine && (
within {scopeLine}
)}
{slices.map((s, i) => ( {multi && valueCol && ( )} ))}
{multi ? {sliceLabel(s)} : (
{Object.entries(s).map(([k, v]) => (
{k} = {dispVal(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), // The same three as numbers, for anything that needs to compute with them. // The strings above are grouped by toLocaleString, and parseFloat stops at // the first separator -- "3,456,789" reads back as 3 -- so a caller that // re-parsed the display got a plausible small number rather than an error. // Nothing should have to know that; the numbers travel alongside. n: { new: next, change, pct }, } out[edit.field] = edit.raw // keep what you typed exactly as typed return out } // Grouped while you type, because -2000000 and -20000000 are the same shape at // a glance and the ledger deals in both. // // Formats for display only: what leaves here is always the raw string, so the // arithmetic upstream never sees a comma. A partly-typed number has to survive // intact -- "1." and "-" and "1.50" are all states on the way to a value, and // reformatting them into something else as you type makes the field unusable. function groupDigits(raw) { const str = String(raw ?? '') if (str === '' || str === '-') return str const neg = str.startsWith('-') const body = neg ? str.slice(1) : str const dot = body.indexOf('.') const whole = (dot === -1 ? body : body.slice(0, dot)).replace(/\D/g, '') const frac = dot === -1 ? null : body.slice(dot + 1).replace(/\D/g, '') if (whole === '' && frac === null) return neg ? '-' : '' const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, ',') return `${neg ? '-' : ''}${grouped}${dot === -1 ? '' : `.${frac}`}` } function LedgerInput({ value, active, held, onChange, onFocus, onClear, suffix }) { const ref = useRef(null) const caret = useRef(null) const display = groupDigits(value) // The commas shift every character after them, so a remembered offset lands // in the wrong place. Count digits instead -- those are what the caret is // actually sitting between -- and find that many digits into the new text. useLayoutEffect(() => { const el = ref.current if (!el || caret.current == null) return const wanted = caret.current caret.current = null let seen = 0, pos = display.length for (let i = 0; i < display.length; i++) { if (/[\d.-]/.test(display[i])) seen++ if (seen === wanted) { pos = i + 1; break } } if (wanted === 0) pos = 0 try { el.setSelectionRange(pos, pos) } catch {} }, [display]) // The suffix is positioned inside the box rather than set beside it. As a // sibling in the flex row it took width of its own, so in a right-aligned cell // the % row's input sat a suffix-and-a-gap to the left of the rows above it, // and the three boxes that are meant to be three ways of saying one thing did // not line up. Inside, every row's box is the same width and the same edge. return ( { const el = e.target const upto = el.value.slice(0, el.selectionStart ?? el.value.length) caret.current = (upto.match(/[\d.-]/g) || []).length onChange(el.value.replace(/,/g, '')) }} onFocus={onFocus} placeholder="—" onKeyDown={e => { if (e.key === 'Escape' && onClear) { e.preventDefault(); onClear(); e.target.blur() } }} title={held ? 'Held by the Absorbed by setting — type here to set it yourself' : undefined} className={`border rounded py-0.5 text-xs w-24 text-right font-mono tabular-nums transition-colors ${suffix ? 'pl-2 pr-5' : 'px-2'} ${active ? 'border-blue-400 bg-blue-50/40 text-gray-800' : held ? 'border-gray-100 bg-gray-50 text-gray-400' : 'border-gray-200 bg-white text-gray-700'}`} /> {suffix && ( {suffix} )} ) } // Tag completion that survives how people actually type. // // The native datalist substring-matches, so "moved to different sku" never // offers "moved to a different sku" -- one inserted word and the existing tag // is invisible. That is how a version ends up with four spellings of one // initiative and a ledger line for each. // // Words, not substrings: both sides are lowercased, stripped of punctuation, // relieved of the words that carry no meaning here, and lightly stemmed, so // "Pulled Forward" and "Pull Forward" reduce to the same thing. Nothing is // merged automatically -- the match is offered and the typing is still yours. const TAG_STOP = new Set(['a', 'an', 'the', 'to', 'of', 'for', 'in', 'on']) function tagTokens(s) { return String(s || '') .toLowerCase() .replace(/[^a-z0-9\s]/g, ' ') .split(/\s+/) .filter(w => w && !TAG_STOP.has(w)) .map(w => w.replace(/(ed|es|s)$/, '')) } const tagKey = (s) => tagTokens(s).sort().join(' ') function TagInput({ value, onChange, tags = [], className }) { const [open, setOpen] = useState(false) const boxRef = useRef(null) useEffect(() => { if (!open) return const onDown = (e) => { if (!boxRef.current?.contains(e.target)) setOpen(false) } document.addEventListener('mousedown', onDown, true) return () => document.removeEventListener('mousedown', onDown, true) }, [open]) const typed = value.trim() const tokens = tagTokens(typed) // Ranked: every typed word present (as a whole word or the start of one) // beats sharing some of them, and a shorter tag beats a longer one on a tie, // being the more likely thing you meant. const matches = (() => { if (!tags.length) return [] const scored = tags.map(t => { const tt = tagTokens(t.tag) if (!tokens.length) return { t, score: 1 } const hit = tokens.filter(w => tt.some(x => x === w || x.startsWith(w) || w.startsWith(x))) if (!hit.length) return { t, score: 0 } return { t, score: hit.length === tokens.length ? 2 : 1 } }) return scored .filter(x => x.score > 0) .sort((a, b) => b.score - a.score || a.t.tag.length - b.t.tag.length) .slice(0, 6) .map(x => x.t) })() // Typed something that reduces to an existing tag without being it. Worth // saying out loud, because the ledger will report the two separately. const nearDupe = typed && !tags.some(t => t.tag === typed) && tags.find(t => tagKey(t.tag) && tagKey(t.tag) === tagKey(typed)) return ( { onChange(e.target.value); setOpen(true) }} onFocus={() => setOpen(true)} onKeyDown={e => { if (e.key === 'Escape') { setOpen(false); e.stopPropagation() } }} placeholder="initiative, e.g. reduce_spend" className={className} /> {open && (matches.length > 0 || nearDupe) && (
{nearDupe && ( )} {matches.map(t => ( ))}
)}
) } 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) { const meta = logMeta[e.logid] || {} // Named like every other line: the label the pivot shows, then the older // fallbacks. "Baseline" was hardcoded, so a segment called 03 - New Orders // everywhere else read as "Baseline" here alone. if (e.key === 'baseline') { const name = (meta.label || meta.tag || meta.note || '').trim() baseline.push({ ...e, label: name || 'Baseline', kind: 'baseline' }) continue } const tag = (meta.label || meta.tag || '').trim() if (tag) { // Grouped case-insensitively. "Keystone reduction" and "Keystone // Reduction" are one initiative typed twice, and splitting them here // reports two lines for one thing. The spelling shown is whichever was // used most; the tags themselves are untouched. const k = tag.toLowerCase() const g = byTag.get(k) || { key: `tag:${k}`, label: tag, kind: 'tag', value: 0, units: 0, count: 0, first: e.logid, spellings: new Map() } 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) g.spellings.set(tag, (g.spellings.get(tag) || 0) + 1) byTag.set(k, g) } else { // One line for all of them, not one line each. An untagged adjustment // has no name to tell it from the next untagged one, so listing them // separately spends a row apiece to say "Scale #226" -- 28 of them on // the version this was built against. loose.push(e) } } const tagged = [...byTag.values()] .sort((a, b) => (a.first ?? 0) - (b.first ?? 0)) .map(g => ({ ...g, label: [...g.spellings.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] || g.label, })) const untagged = loose.length ? [{ key: 'untagged', kind: 'untagged', label: `${loose.length} untagged adjustment${loose.length === 1 ? '' : 's'}`, count: loose.length, value: loose.reduce((a, e) => a + (e.value || 0), 0), units: loose.reduce((a, e) => a + (e.units || 0), 0), }] : [] return [...baseline, ...tagged, ...untagged] })() // A broad slice reaches every initiative on the version -- 15 tags and 48 // adjustments on the one this was built against -- and a walk that long stops // being a walk. The loads stay whole, since they are the shape of the // forecast; the adjustments keep the biggest few and roll the rest into one // line, which is still arithmetically the walk: addition does not care about // the order, and the line carries its own subtotal. const MAX_ADJ_LINES = 6 const [showAllLines, setShowAllLines] = useState(false) const loadLines = lines.filter(l => l.kind === 'baseline') const adjLines = lines.filter(l => l.kind !== 'baseline') const rolled = !showAllLines && adjLines.length > MAX_ADJ_LINES + 1 const displayLines = (() => { if (!rolled) return lines // Biggest by absolute value, then back into the order they happened, so the // ones that are kept still read chronologically. const rank = [...adjLines].sort((a, b) => Math.abs(b.value || 0) - Math.abs(a.value || 0)) const keep = new Set(rank.slice(0, MAX_ADJ_LINES).map(l => l.key)) const kept = adjLines.filter(l => keep.has(l.key)) const rest = adjLines.filter(l => !keep.has(l.key)) const sum = rest.reduce((a, l) => ({ value: a.value + (l.value || 0), units: a.units + (l.units || 0), count: a.count + (l.count || 1), }), { value: 0, units: 0, count: 0 }) return [...loadLines, ...kept, { key: 'rollup', kind: 'rollup', label: `${rest.length} smaller changes`, ...sum }] })() 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 // One line per immovable segment. Falls back to the combined figure for a // selection whose rows carry no segment name. const exclLines = (currentTotals?.excluded?.bySegment?.length ? currentTotals.excluded.bySegment : (hasExcl ? [{ name: exclName, ...excl }] : [])) // 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 n = derived[key]?.n?.[field] return n != null && 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, }, } })() // The same three readings for a measure nobody typed into, taken from where // the edit actually lands it. Scaling dollars with price holding moves qty, // and leaving that column blank until the Result row meant the number you had // just implied was the one thing not shown next to the one you typed. const backfill = (current, next, dp) => { if (next == null || !isFinite(next) || current == null) return { new: '', change: '', pct: '' } const change = next - current const pct = current === 0 ? null : (change / Math.abs(current)) * 100 return { new: fmtNum(next, dp), change: fmtNum(change, dp), pct: pct == null ? '—' : fmtNum(pct, 1), n: { new: next, change, pct }, } } // What the rows render. derived stays the edit-driven map -- it is what // outcome is computed from, so back-filling into it would feed the result // back into its own inputs. const shown = Object.fromEntries(measures.map(m => [ m.key, (filled(m.key) || !outcome) ? derived[m.key] : backfill(basisOf(m.key), outcome[m.key], m.dp), ])) const setEdit = (key, field, raw) => setScaleInputs(prev => ({ ...prev, [key]: { field, raw } })) // Hand a measure back to being derived. Emptying the box does the same thing // -- filled() is false either way -- but that is only obvious once you know // it, and clicking into a held column to look at it and then wanting out // again is the ordinary case, not the awkward one. const clearEdit = (key) => setScaleInputs(prev => { if (!(key in prev)) return prev const { [key]: _drop, ...rest } = prev return rest }) // 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 // Whatever the box reads is what you get, including a back-filled // number: clicking into an implied figure is how you pin it, which is // the difference between "units may move" and "units are this". const text = shown[key]?.[field] ?? '' const raw = text === '—' ? '' : String(text).replace(/,/g, '') return { ...prev, [key]: { field, raw } } }) // A running total across the walk, in the primary measure. The lines sum to // Adjustable, and without this you are adding eight-digit numbers in your // head to check that they do. // // Value only: a cumulative price is meaningless -- prices do not add -- and a // second running column for units doubles the width to say something the // value column already implies. // Accumulates in display order across everything: the immovable segments // first, then the walk. So it reads as the whole number being built up -- // billed, then booked, then the baseline, then each adjustment -- and closes // on Selected total rather than on Adjustable, which is only the part of it // that can still move. const runningByKey = (() => { const out = new Map() let acc = 0 for (const seg of exclLines) { acc += seg.value || 0; out.set(`final:${seg.name}`, acc) } for (const e of displayLines) { acc += e.value || 0; out.set(e.key, acc) } return out })() const showRunning = !!valueCol && (displayLines.length + exclLines.length) > 1 // Which measure the plug holds still. Only meaningful while dollars are the // sole input -- that is the one case where price and volume are both unknown // and the toggle is answering for them. 'price' means price absorbs the // change, so qty holds; 'volume' means qty absorbs it, so price holds. const heldKey = (valueCol && unitsCol && dollarsOnly) ? (scalePlug === 'price' ? 'units' : 'price') : null const numCell = 'text-right font-mono tabular-nums whitespace-nowrap px-2' const rule =
return (
{measures.map(m => ( ))} {showRunning && ( )} {/* What cannot move comes first: it is the constraint the rest is worked out against. Then the walk, which sums to Adjustable, and the two together make the selected total. */} {exclLines.map(seg => ( {measures.map(m => ( ))} {showRunning && ( )} ))} {showAllLines && adjLines.length > MAX_ADJ_LINES + 1 && ( )} {exclLines.length > 0 && ( {rule}{measures.map(m => )}{showRunning && } )} {/* the walk from baseline to current, by initiative */} {displayLines.map(e => ( setShowAllLines(true) : undefined}> {measures.map(m => ( ))} {showRunning && ( )} ))} {rule}{measures.map(m => )}{showRunning && } {measures.map(m => ( ))} {showRunning && {hasExcl && ( {measures.map(m => ( ))} {showRunning && ( )} )} {/* 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 => )}{showRunning && } {/* 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 => )}{showRunning && } {measures.map(m => ( ))} )}
{m.label} {m.key === heldKey ? · holds : m.hint && · {m.hint}} {scaleInputs[m.key] && ( )} running
{seg.name} final {m.key === 'price' ? fmtNum(priceOf(seg), m.dp) : fmtNum(seg[m.key], m.dp)} {fmtNum(runningByKey.get(`final:${seg.name}`), 0)}
{e.kind === 'tag' && ▪ } {(e.kind === 'rollup' || e.kind === 'untagged') && ▸ } {e.label} {e.kind === 'tag' && e.count > 1 && ×{e.count}} {m.key === 'price' ? fmtNum(priceOf(e), m.dp) : fmtNum(e[m.key], m.dp)} {fmtNum(runningByKey.get(e.key), 0)}
{hasExcl ? 'Adjustable' : 'Current'}{perSlice ? ' (all)' : ''} {fmtNum(m.current, m.dp)}}
Selected total {m.key === 'price' ? fmtNum(grand.units ? grand.value / grand.units : null, m.dp) : fmtNum(grand[m.key], m.dp)} {fmtNum(grand.value, 0)}
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)} onClear={() => clearEdit(m.key)} 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. */} {/* Both of these are settings on the edit, so they use Field like every other one -- an aligned label column and a hint below the control. They used to hand-roll their own label row and hint, with two different type sizes and no shared label width, so the two stacked into a paragraph of loose words rather than reading as two settings. */} {(valueCol && unitsCol && dollarsOnly || hasExcl || perSlice) && (
{valueCol && unitsCol && dollarsOnly && ( )} {hasExcl && ( )} {perSlice && ( Applied to each slice separately )}
)}
) } // 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 inSlice = c.cname in first const cur = multi ? (new Set(slices.map(s => s[c.cname])).size > 1 ? '(varies)' : (inSlice ? dispVal(first[c.cname]) : '—')) : (inSlice ? dispVal(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, viewScope = [], opBusy = null, 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' } const OP_BUSY_LABEL = { scale: 'Applying…', recode: 'Recoding…', clone: 'Cloning…' } 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. */} {opTag.trim() && ( )} {/* The six quick-pick pills that used to sit here are gone. They wrapped onto two rows once the tags were more than a word long, which is most of them, and the panel docked at the bottom has a fixed height to spend -- so they pushed the form past its own border. The same tags complete from the input's datalist, which costs no space and is not capped at six. */} setNote(e.target.value)} placeholder="optional" className={`${TEXT} w-48`} />
submitOp(activeOp)} busy={!!opBusy}> {opBusy ? OP_BUSY_LABEL[opBusy] : OP_LABEL[activeOp]} {opBusy && ( Writing rows — the pivot updates when it finishes. )}
)} ) }