pf_app/ui/src/components/OperationPanel.jsx
Paul Trowbridge cc3d83268f Show the clone date offset without requiring a segment
The offset field was rendered only when a segment had been named, from when
that was the only way clone could reach reference rows. Once clone could read
them directly the field became unreachable in the ordinary case -- so
"shift dates by" was invisible and the only typeable-looking control was
"copy rows from", which is a dropdown.

It is shown whenever clone is open now, and the payload carries the offset
independently of from_logid: shifting a selection through time is the common
case, and narrowing to one segment is the occasional one.

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

896 lines
39 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>
)
}
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 }
const exclName = (currentTotals?.excludedIters || []).join(' / ') || 'excluded'
// the basis decides which line the editable rows are measured from
const basisOf = (key) => {
if (!onTotal) return key === 'price' ? curPrice : total[key]
if (key === 'price') return grand.units ? grand.value / grand.units : null
return grand[key]
}
// Every line that has both measures has a price -- a bridge line's is the
// implied price of that initiative's own contribution, which is the number
// that says whether it was a price move or a volume move. Only a line with no
// units has nothing to show, since the price is undefined rather than zero.
const priceOf = (row) => (row && row.units) ? row.value / row.units : null
// measure columns, in ledger order
const measures = [
// Whole units for the measures -- the figures run to eight digits, where two
// decimals are noise. Price is the opposite: it sits around 0.27, so it needs
// the places to show a move at all.
valueCol && { key: 'value', label: valueCol, current: total.value, dp: 0 },
unitsCol && { key: 'units', label: unitsCol, current: total.units, dp: 0 },
(valueCol && unitsCol) && { key: 'price', label: 'price', current: curPrice, dp: 5, hint: 'value / units' },
].filter(Boolean)
const derived = Object.fromEntries(
measures.map(m => [m.key, derive(basisOf(m.key) ?? 0, scaleInputs[m.key], m.dp)])
)
// an edit is dollars-only when value carries a number and neither units nor
// price does -- the one case where price and volume are both still unknown
const filled = (key) => {
const raw = scaleInputs[key]?.raw
return raw != null && raw !== '' && isFinite(parseFloat(raw))
}
const dollarsOnly = filled('value') && !filled('units') && !filled('price')
// Where the edit lands, for all three measures at once. The rows above each
// show only their own input, so a dollar figure entered with plug = volume
// left units and price blank -- exactly the two numbers you need to see to
// know whether you asked for a price change or a volume change.
const numOf = (key, field) => {
const raw = derived[key]?.[field]
const n = parseFloat(raw)
return isFinite(n) ? n : null
}
const outcome = (() => {
const curValue = basisOf('value') ?? 0
const curUnits = basisOf('units') ?? 0
if (!filled('value') && !filled('units') && !filled('price')) return null
let newValue, newUnits
if (filled('price') && !filled('value')) {
// price is the input; dollars fall out of price x volume
const targetPrice = numOf('price', 'new')
newUnits = filled('units') ? numOf('units', 'new') : curUnits
newValue = targetPrice != null && newUnits != null ? targetPrice * newUnits : null
} else {
newValue = filled('value') ? numOf('value', 'new') : curValue
if (filled('units')) newUnits = numOf('units', 'new')
else if (dollarsOnly && scalePlug === 'volume') newUnits = curValue === 0 ? null
: curUnits * (newValue / curValue)
else newUnits = curUnits
}
if (newValue == null || newUnits == null) return null
const newPrice = newUnits === 0 ? null : newValue / newUnits
const curPriceLocal = curUnits === 0 ? null : curValue / curUnits
return {
value: newValue,
units: newUnits,
price: newPrice,
moved: {
value: Math.abs(newValue - curValue) > 1e-9,
units: Math.abs(newUnits - curUnits) > 1e-9,
price: curPriceLocal != null && newPrice != null && Math.abs(newPrice - curPriceLocal) > 1e-9,
},
}
})()
const setEdit = (key, field, raw) =>
setScaleInputs(prev => ({ ...prev, [key]: { field, raw } }))
// focusing a different row hands that measure's edit to the focused row,
// carrying across whatever it currently reads
const focusRow = (key, field) =>
setScaleInputs(prev => {
const cur = prev[key]
if (cur && cur.field === field) return prev
const shown = derived[key]?.[field] ?? ''
const raw = shown === '—' ? '' : String(shown).replace(/,/g, '')
return { ...prev, [key]: { field, raw: cur ? raw : '' } }
})
const numCell = 'text-right font-mono tabular-nums whitespace-nowrap px-2'
const rule = <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-gray-600">
<td className="pr-3 whitespace-nowrap">
{exclName} <span className="text-gray-500">· fixed</span>
</td>
{measures.map(m => (
<td key={m.key} className={numCell}>
{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>
)}
<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.
function MovingTotal({ currentTotals, verb, factor }) {
const t = currentTotals?.total
if (!t) return null
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 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={<MovingTotal currentTotals={currentTotals} verb="Moving" />} />
)}
{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">
{/* The selection is the SOURCE, not the destination: pick
the cells you want to copy -- last December, say -- and
the shift below is what lands them in the target period.
Naming a segment narrows that selection to one entry,
including the reference ones operations are normally kept
away from, which is the point: a period with no baseline
borrows its shape from prior year or plan. */}
<div className="flex items-center gap-2 flex-wrap">
<span className="text-gray-500 whitespace-nowrap">copy rows from</span>
<select value={cloneFrom} onChange={e => setCloneFrom(e.target.value)}
className={`${INPUT} w-auto`}>
<option value="">the whole selection</option>
{(cloneSources || []).map(s => (
<option key={s.id} value={s.id}>
{s.label || `${s.operation} #${s.id}`}
</option>
))}
</select>
</div>
{/* Always shown. This was gated on picking a segment, from
when naming one was the only way to reach reference rows
-- once clone could read them directly, the offset became
unreachable in the ordinary case. */}
<div className="flex items-center gap-2 flex-wrap">
<span className="text-gray-500 whitespace-nowrap">shift dates by</span>
<input value={cloneOffset} list="pf-clone-offsets"
onChange={e => setCloneOffset(e.target.value)}
placeholder="0 days" className={`${INPUT} 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>
</div>
{cloneOffset.trim() && cloneOffset.trim() !== '0 days' && (
<p className="text-gray-500 text-[11px] leading-snug max-w-sm">
The cells you have selected are what gets copied. Shifting
moves every date column, and the season and month
dimensions are re-derived from the calendar to match so
select the period you are copying <em>from</em>, not the
one you are filling.
</p>
)}
<div className="flex items-center gap-2">
<span className="text-gray-500">scale cloned rows by</span>
<input type="number" step="any" value={cloneScale}
onChange={e => setCloneScale(e.target.value)} className={INPUT} />
</div>
{!cloneFrom && (
<MovingTotal currentTotals={currentTotals} verb="Copying"
factor={parseFloat(cloneScale) || 1} />
)}
</div>
} />
)}
</div>
</Block>
)}
{hasSlice && (
<Block horizontal={horizontal}>
<div className="flex flex-col gap-2.5 min-w-0">
{/* 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. */}
<div className="flex items-center gap-2">
<span className="text-gray-600 whitespace-nowrap w-9">tag</span>
<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>
)}
</div>
{knownTags.length > 0 && (
<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>
)}
<div className="flex items-center gap-2">
<span className="text-gray-600 whitespace-nowrap w-9">note</span>
<input value={note} onChange={e => setNote(e.target.value)} placeholder="optional" className={TEXT} />
</div>
<Submit onClick={() => submitOp(activeOp)}>{OP_LABEL[activeOp]}</Submit>
<RequestPreview payload={buildPayload(activeOp)} />
</div>
</Block>
)}
</div>
)
}