pf_app/ui/src/components/OperationPanel.jsx
Paul Trowbridge 50a0bb42aa Make a slice mean what it says, and say what cannot move
The phantom: pf_segment and pf_bucket are computed from pf.log when the rows
are served, so buildWhere had no column to compare and dropped them. Clicking
one bucket's cell and scaling therefore wrote every bucket at that dimension
intersection, while the panel showed only the bucket clicked. On the example
slice that is 350,524.74 displayed against 503,446.08 written.

They resolve exactly, without a new column: the name lives on the log row and
every forecast row carries the pf_logid that points at it, so the predicate is
pf_logid IN (SELECT id FROM pf.log WHERE <the same expression> = ...). Verified
against version 29 -- the clause returns 350,524.74 over 12 rows.

Any other pf_ key is now refused rather than skipped, since skipping is the
mechanism by which a selection silently widens. pf_iter stays exempt: the
client drops it deliberately, two cells differing only by iter band being the
same slice.

Client side they are ordinary columns in the loaded table, so both the
dispatch path and the panel's own totals filter on them directly -- the latter
matters as much, or the ledger reconciles against a wider selection than the
operation writes.

The ledger: excluded rows read "02 - Prior Year · FINAL" in amber rather than
"reference · fixed" -- named by the segment a forecaster recognises instead of
the iter band that happens to exclude it, and coloured because immovable is a
property worth seeing before reading a number. When the whole selection is
immovable it now says so in a sentence, where before it printed a row of zeros
and left the reason to be inferred from the edit rows failing below.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 00:17:25 -04:00

972 lines
42 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 (
<section className={[
horizontal ? 'pl-5 border-l border-gray-200 first:pl-0 first:border-l-0' : '',
grow ? 'flex-1 min-w-0' : 'shrink-0',
].join(' ')}>
{title && (
<header className="flex items-baseline gap-1.5 mb-2">
<h3 className="font-semibold text-gray-600 uppercase tracking-wide" style={{ fontSize: '10px' }}>{title}</h3>
{hint && <span className="text-gray-600" style={{ fontSize: '10px' }}>{hint}</span>}
</header>
)}
{children}
</section>
)
}
function Button({ onClick, active, children, title }) {
return (
<button onClick={onClick} title={title}
className={`px-3 py-1 rounded text-xs whitespace-nowrap transition-colors ${
active ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50'}`}>
{children}
</button>
)
}
// 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 (
<div className="flex items-start gap-2">
<span className={`text-gray-600 whitespace-nowrap shrink-0 pt-1 ${LABEL_W}`}>{label}</span>
<div className="flex flex-col gap-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">{children}</div>
{hint && <p className="text-gray-500 text-[11px] leading-snug max-w-xs">{hint}</p>}
</div>
</div>
)
}
// 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 (
<div className="text-gray-400 uppercase tracking-wide pt-1" style={{ fontSize: '10px' }}>
{children}
</div>
)
}
function Segmented({ options, value, onChange }) {
return (
<div className="inline-flex rounded border border-gray-200 overflow-hidden w-auto self-start">
{options.map(([val, label, title]) => (
<Button key={val} onClick={() => onChange(val)} active={value === val} title={title}>{label}</Button>
))}
</div>
)
}
function Submit({ onClick, children, disabled }) {
return (
<button onClick={onClick} disabled={disabled}
className="self-start px-4 py-1.5 rounded text-xs font-medium bg-blue-600 text-white hover:bg-blue-700
disabled:bg-gray-200 disabled:text-gray-600 disabled:cursor-not-allowed whitespace-nowrap">
{children}
</button>
)
}
// ── 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 (
<p className="text-gray-600 italic leading-relaxed">
Click a pivot row to select a slice.<br />
<span className="text-gray-500">Ctrl/-click to add more.</span>
</p>
)
}
return (
<div className="min-w-0">
<div className="overflow-auto max-h-36 -mx-1 px-1">
<table className="w-full">
<tbody>
{slices.map((s, i) => (
<tr key={i} className="align-top hover:bg-gray-50">
<td className="py-0.5 pr-2">
{multi
? <span className="font-mono text-gray-700">{sliceLabel(s)}</span>
: (
<div className="flex flex-col gap-0.5">
{Object.entries(s).map(([k, v]) => (
<div key={k} className="whitespace-nowrap">
<span className="text-gray-600">{k}</span>
<span className="text-gray-500"> = </span>
<span className="font-medium text-gray-700 font-mono">{v}</span>
</div>
))}
</div>
)}
</td>
{multi && valueCol && (
<td className="py-0.5 pl-2 text-right font-mono tabular-nums text-gray-600 whitespace-nowrap">
{fmtNum(perSlice[i]?.total?.value)}
</td>
)}
<td className="py-0.5 pl-1 text-right">
<button onClick={() => onRemove(i)} title="Remove from selection"
className="text-gray-500 hover:text-red-500 leading-none px-1">×</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<button onClick={onClear} className="text-gray-600 hover:text-red-500 mt-1.5">Clear selection</button>
</div>
)
}
// 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 (
<div className="mt-2">
<button onClick={() => setOpen(o => !o)} className="text-gray-600 hover:text-gray-600">
{open ? '▾' : '▸'} breakdown by iter
</button>
{open && (
<table className="mt-1 text-gray-500">
<tbody>
{rows.map(r => (
<tr key={r.iter}>
<td className="capitalize pr-3">{r.iter}</td>
{valueCol && <td className="text-right font-mono tabular-nums pl-2">{fmtNum(r.value)}</td>}
{unitsCol && <td className="text-right font-mono tabular-nums pl-2">{fmtNum(r.units)}</td>}
</tr>
))}
</tbody>
</table>
)}
</div>
)
}
// ── 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 (
<span className="inline-flex items-center gap-1">
<input
type="text" inputMode="decimal" value={value} onChange={e => 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 && <span className="text-gray-500" style={{ fontSize: '10px' }}>{suffix}</span>}
</span>
)
}
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 = <td className="p-0"><div className="border-t border-gray-300 my-1" /></td>
return (
<div className="min-w-0">
<table>
<thead>
<tr className="text-gray-600" style={{ fontSize: '10px' }}>
<th className="text-left font-normal pb-1 pr-3"></th>
{measures.map(m => (
<th key={m.key} className="text-right font-normal pb-1 px-2 whitespace-nowrap">
{m.label}
{m.hint && <span className="text-gray-500"> · {m.hint}</span>}
</th>
))}
</tr>
</thead>
<tbody>
{/* the walk from baseline to current, by initiative */}
{lines.map(e => (
<tr key={e.key} className="text-gray-600">
<td className="pr-3 whitespace-nowrap max-w-[14rem] truncate" title={e.label}>
{e.kind === 'tag' && <span className="text-blue-600"> </span>}
{e.label}
{e.kind === 'tag' && e.count > 1 && <span className="text-gray-500"> ×{e.count}</span>}
</td>
{measures.map(m => (
<td key={m.key} className={`${numCell} text-gray-600`}>
{m.key === 'price' ? fmtNum(priceOf(e), m.dp) : fmtNum(e[m.key], m.dp)}
</td>
))}
</tr>
))}
<tr>{rule}{measures.map(m => <td key={m.key} className="p-0 px-2"><div className="border-t border-gray-300 my-1" /></td>)}</tr>
<tr className={onTotal ? 'text-gray-600' : 'font-semibold text-gray-700'}>
<td className="pr-3 whitespace-nowrap">
{hasExcl ? 'Adjustable' : 'Current'}{perSlice ? ' (all)' : ''}
</td>
{measures.map(m => (
<td key={m.key} className={numCell}>{fmtNum(m.current, m.dp)}</td>
))}
</tr>
{/* Rows the pivot shows but operations cannot write. Listed so the
panel's figures reconcile with what the grid displays. */}
{hasExcl && (
<tr className="text-amber-700">
<td className="pr-3 whitespace-nowrap max-w-[16rem] truncate" title={exclName}>
{exclName}
<span className="ml-1 px-1 py-0.5 rounded bg-amber-50 text-amber-700 text-[10px] uppercase tracking-wide">
final
</span>
</td>
{measures.map(m => (
<td key={m.key} className={`${numCell} text-amber-700`}>
{m.key === 'price' ? fmtNum(priceOf(excl), m.dp) : fmtNum(excl[m.key], m.dp)}
</td>
))}
</tr>
)}
{hasExcl && (
<tr className={onTotal ? 'font-semibold text-gray-700' : 'text-gray-600'}>
<td className="pr-3 whitespace-nowrap">Selected total</td>
{measures.map(m => (
<td key={m.key} className={numCell}>
{m.key === 'price'
? fmtNum(grand.units ? grand.value / grand.units : null, m.dp)
: fmtNum(grand[m.key], m.dp)}
</td>
))}
</tr>
)}
{/* 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 && (
<tr>
<td colSpan={measures.length + 1} className="pt-2 text-amber-700 leading-snug">
Nothing in this selection can be adjusted all of it is {exclName},
loaded as {(currentTotals?.excludedIters || []).join(' / ') || 'reference'}.
</td>
</tr>
)}
<tr>{rule}{measures.map(m => <td key={m.key} className="p-0 px-2"><div className="border-t border-gray-300 my-1" /></td>)}</tr>
{/* the edit — three equivalent ways to say the same thing */}
{FIELDS.map(([field, label]) => (
<tr key={field}>
<td className="pr-3 py-0.5 text-gray-500 whitespace-nowrap">
{label}{perSlice && field === 'new' ? ' (each)' : ''}
</td>
{measures.map(m => {
const active = scaleInputs[m.key]?.field === field
return (
<td key={m.key} className="px-2 py-0.5 text-right">
<LedgerInput
value={derived[m.key]?.[field] ?? ''}
active={active}
onChange={(raw) => setEdit(m.key, field, raw)}
onFocus={() => focusRow(m.key, field)}
suffix={field === 'pct' ? '%' : null}
/>
</td>
)
})}
</tr>
))}
{outcome && (
<>
<tr>{rule}{measures.map(m => <td key={m.key} className="p-0 px-2"><div className="border-t border-gray-300 my-1" /></td>)}</tr>
<tr className="font-semibold text-gray-700">
<td className="pr-3 whitespace-nowrap">Result</td>
{measures.map(m => (
<td key={m.key} className={numCell}>
<span className={outcome.moved[m.key] ? 'text-blue-600' : 'text-gray-400'}>
{fmtNum(outcome[m.key], m.dp)}
</span>
</td>
))}
</tr>
</>
)}
</tbody>
</table>
{/* 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 && (
<div className="flex flex-col gap-1 mt-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-gray-600 whitespace-nowrap">Absorbed by</span>
<Segmented
value={scalePlug}
onChange={setScalePlug}
options={[
['price', 'Price', `${unitsCol} holds; price moves to reach the number`],
['volume', 'Volume', `Price holds; ${unitsCol} scales with the dollars`],
]}
/>
</div>
<div className="text-[11px] text-gray-500 leading-snug">
{scalePlug === 'price'
? `${unitsCol} holds; price moves to reach the number.`
: `Price holds; ${unitsCol} scales with the dollars.`}
</div>
</div>
)}
{hasExcl && (
<div className="flex flex-col gap-1 mt-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-gray-600 whitespace-nowrap">Target applies to</span>
<Segmented
value={onTotal ? 'selected' : 'adjustable'}
onChange={setTargetBasis}
options={[
['adjustable', 'Adjustable', 'Measure against only the rows this operation can write'],
['selected', 'Selected total', 'Measure against everything the pivot shows, fixed rows included'],
]}
/>
</div>
<p className="text-gray-600 leading-snug max-w-md">
{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)}.`}
</p>
</div>
)}
{perSlice && (
<p className="text-gray-600 leading-snug mt-2 max-w-md">
Applied to each slice separately the figures above are combined totals,
so each slice's own change will differ.
</p>
)}
</div>
)
}
// 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 (
<>
<input
value={value}
list={col.is_key ? listId : undefined}
onChange={onChange}
onBlur={onBlur}
placeholder="keep"
className={className} />
{col.is_key && (
<datalist id={listId}>
{options.map(o => <option key={o} value={o} />)}
</datalist>
)}
</>
)
}
// ── 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 (
<div className="flex flex-col gap-2.5 min-w-0">
<table>
<thead>
<tr className="text-gray-600" style={{ fontSize: '10px' }}>
<th className="text-left font-normal pb-1 pr-3">dimension</th>
<th className="text-left font-normal pb-1 px-2">current</th>
<th className="text-left font-normal pb-1 pl-2">new value</th>
</tr>
</thead>
<tbody>
{dimCols.map(c => {
const cur = multi
? (new Set(slices.map(s => s[c.cname])).size > 1 ? '(varies)' : (first[c.cname] ?? '—'))
: (first[c.cname] ?? '—')
return (
<tr key={c.cname}>
<td className="pr-3 py-0.5 text-gray-500 whitespace-nowrap" title={c.cname}>{c.label || c.cname}</td>
<td className="px-2 py-0.5 font-mono text-gray-600 max-w-[10rem] truncate" title={String(cur)}>{cur}</td>
<td className="pl-2 py-0.5">
<DimValueInput
col={c}
members={c.dim_group ? dimMembers?.[c.dim_group]?.members : null}
versionId={versionId}
value={setObj[c.cname] || ''}
onChange={e => 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} />
</td>
</tr>
)
})}
</tbody>
</table>
{extra}
</div>
)
}
// 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 (
<p className="text-gray-500">
{verb}{' '}
{valueCol && <span className="font-mono tabular-nums text-gray-700">{fmtNum(t.value)}</span>}
{valueCol && <span className="text-gray-600"> {valueCol}</span>}
{unitsCol && <>
<span className="text-gray-500"> · </span>
<span className="font-mono tabular-nums text-gray-700">{fmtNum(t.units)}</span>
<span className="text-gray-600"> {unitsCol}</span>
</>}
{scaled && valueCol && <>
<span className="text-gray-500"> </span>
<span className="font-mono tabular-nums text-gray-700">{fmtNum(t.value * factor)}</span>
</>}
</p>
)
}
// ── 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 (
<div className="flex flex-col gap-1.5">
<Segmented value={applyMode} onChange={setApplyMode}
options={options.map(([v, l, t]) => [v, l, t])} />
{active && <p className="text-gray-600 leading-snug max-w-xs">{active[2]}</p>}
</div>
)
}
function RequestPreview({ payload }) {
const [open, setOpen] = useState(false)
if (!payload) return null
return (
<div>
<button onClick={() => setOpen(o => !o)} className="text-gray-600 hover:text-gray-600">
{open ? '▾' : '▸'} request
</button>
{open && (
<pre className="mt-1 font-mono text-gray-600 bg-gray-50 border border-gray-100 rounded p-2 overflow-auto max-h-40 leading-relaxed">
{JSON.stringify(payload, null, 2)}
</pre>
)}
</div>
)
}
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 (
<div className={horizontal ? 'flex flex-row items-start p-3 gap-5 min-w-0' : 'flex flex-col p-3 gap-3 min-w-0'}>
<Block title="Slice" hint={hasSlice ? `${slices.length} selected` : null}
horizontal={horizontal} grow>
{/* 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 && (
<p className="text-amber-700 bg-amber-50 border border-amber-200 rounded px-2 py-1 mb-2 leading-snug">
{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.
</p>
)}
<SelectionList
slices={slices}
currentTotals={currentTotals}
onRemove={(i) => setSlices(prev => prev.filter((_, x) => x !== i))}
onClear={() => setSlices([])}
/>
</Block>
{hasSlice && (
<Block horizontal={horizontal} grow>
<div className="flex flex-col gap-2.5">
<div className="flex items-center gap-3 flex-wrap">
<div className="inline-flex rounded border border-gray-200 overflow-hidden">
{['scale', 'recode', 'clone'].map(op => (
<Button key={op} onClick={() => setActiveOp(op)} active={activeOp === op}>
<span className="capitalize">{op}</span>
</Button>
))}
</div>
{multi && (
<ApplyModeChooser op={activeOp} applyMode={applyMode} setApplyMode={setApplyMode} count={slices.length} />
)}
</div>
{activeOp === 'scale' && (
<ScaleLedger
currentTotals={currentTotals}
scaleInputs={scaleInputs} setScaleInputs={setScaleInputs}
scalePlug={scalePlug} setScalePlug={setScalePlug}
targetBasis={targetBasis} setTargetBasis={setTargetBasis}
logMeta={logMeta}
multi={multi} applyMode={applyMode}
/>
)}
{activeOp === 'recode' && (
<DimForm dimCols={dimCols} setObj={recodeSet} setSet={setRecodeSet}
slices={slices} lookupDerivedCols={lookupDerivedCols} dimMembers={dimMembers} versionId={versionId}
extra={
<div className="pt-1 border-t border-gray-100 mt-1">
<MovingTotal currentTotals={currentTotals} verb="Moving" />
</div>
} />
)}
{activeOp === 'clone' && (
<DimForm dimCols={dimCols} setObj={cloneSet} setSet={setCloneSet}
slices={slices} lookupDerivedCols={lookupDerivedCols} dimMembers={dimMembers} versionId={versionId}
extra={
<div className="flex flex-col gap-2 pt-1 border-t border-gray-100 mt-1">
<SectionLabel>source</SectionLabel>
{/* 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. */}
<Field label="rows from"
hint={cloneFrom
? 'Only rows from that segment, out of everything selected.'
: undefined}>
<select value={cloneFrom} onChange={e => setCloneFrom(e.target.value)}
className={`${TEXT} w-48`}>
<option value="">the whole selection</option>
{(cloneSources || []).map(s => (
<option key={s.id} value={s.id}>
{s.label || `${s.operation} #${s.id}`}
</option>
))}
</select>
</Field>
<SectionLabel>changes</SectionLabel>
<Field label="shift dates"
hint={shifting
? 'Every date column moves; season and month are re-derived from the calendar. So select the period you are copying from, not the one you are filling.'
: undefined}>
<input value={cloneOffset} list="pf-clone-offsets"
onChange={e => setCloneOffset(e.target.value)}
placeholder="0 days" className={`${TEXT} w-28`} />
<datalist id="pf-clone-offsets">
<option value="12 months" />
<option value="24 months" />
<option value="-90 days" />
<option value="-12 months" />
<option value="0 days" />
</datalist>
</Field>
<Field label="scale by">
<input type="number" step="any" value={cloneScale}
onChange={e => setCloneScale(e.target.value)}
className={`${INPUT} w-28`} />
<span className="text-gray-400 text-[11px]">×</span>
</Field>
{/* 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. */}
<div className="pt-1">
<MovingTotal currentTotals={currentTotals} verb="Copying"
factor={parseFloat(cloneScale) || 1}
includeExcluded />
</div>
</div>
} />
)}
</div>
</Block>
)}
{hasSlice && (
<Block horizontal={horizontal}>
<div className="flex flex-col gap-2.5 min-w-0">
<SectionLabel>label this change</SectionLabel>
{/* 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. */}
<Field label="tag">
<input
value={opTag} onChange={e => setOpTag(e.target.value)}
list="pf-tag-options" placeholder="initiative, e.g. reduce_spend"
className={`${TEXT} w-48`} />
{opTag.trim() && (
<button onClick={() => setOpTag('')} title="Clear tag"
className="text-gray-500 hover:text-red-500 leading-none px-1">×</button>
)}
</Field>
{/* 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 && (
<Field label="">
<div className="flex items-center gap-1 flex-wrap">
{knownTags.slice(0, 6).map(t => (
<button key={t.tag} onClick={() => setOpTag(t.tag)}
title={`${t.uses} previous use${t.uses === 1 ? '' : 's'}`}
className={`px-2 py-0.5 rounded-full border text-xs whitespace-nowrap ${
opTag.trim() === t.tag
? 'bg-blue-600 border-blue-600 text-white'
: 'bg-white border-gray-300 text-gray-700 hover:border-blue-400 hover:text-blue-700'}`}>
{t.tag}
</button>
))}
</div>
</Field>
)}
<Field label="note">
<input value={note} onChange={e => setNote(e.target.value)}
placeholder="optional" className={`${TEXT} w-48`} />
</Field>
<div className="pt-1">
<Submit onClick={() => submitOp(activeOp)}>{OP_LABEL[activeOp]}</Submit>
</div>
<RequestPreview payload={buildPayload(activeOp)} />
</div>
</Block>
)}
</div>
)
}