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>
896 lines
39 KiB
JavaScript
896 lines
39 KiB
JavaScript
// 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>
|
||
)
|
||
}
|