Clicking the blank `partgroup` node under ALTMAN PLANTS selected the customer's whole $27,588,393.62 (6,851 rows) rather than the $6,198,860.42 (2,421) under the node clicked -- and the ledger agreed, because the panel totals through the same filter list the operation writes from. The key was not narrowed, it was absent, and an absent key means every value of it. The drop is the engine's: `getCellConfig` builds the click's filter list with `pivot_value ? [pivot, '==', pivot_value] : undefined`, so a blank group key is gone before `perspective-click` is dispatched -- and being falsy, so is a dimension whose value is 0. `cellFilters()` re-derives both axes from `detail.row.__ROW_PATH__` and the clicked column name instead; the click and a dragged region now share that one derivation rather than keeping two copies of the same falsy test. Both blanks are real here and they are different rows: `partgroup` is null on 51,964 and '' on 111,696. So they stay distinct end to end. `buildWhere` renders a null as IS NULL, where it used to fall through `esc()` to `= ''` and select *the other blank* -- the slice naming one node while the operation wrote the other. In a list the null arm is ORed on, since `IN (NULL)` is never true. The ledger filters with `is null` rather than `== null`, the engine matching on type and a null threshold against a string column being the same dropped predicate. Only `undefined` now means absent, which keeps a subtotal row (a shorter row path) and a collapsed column level reading as "not part of the slice". A blank on the *column* axis still cannot be isolated: a column name is its values joined with '|' and a null joins as the empty string, so the two blanks are the same text by the time it reaches the client. Those cells are refused with a message rather than widened. The panel prints (null) and (blank), and keeps em dash for a column genuinely absent from the slice -- isolating the blank node and ignoring the column are opposite statements and read identically before. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1340 lines
60 KiB
JavaScript
1340 lines
60 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, useRef, useLayoutEffect } from 'react'
|
||
|
||
const INPUT = 'border border-gray-200 rounded px-2 py-1 text-xs bg-white w-28 text-right font-mono tabular-nums'
|
||
const TEXT = 'border border-gray-200 rounded px-2 py-1 text-xs bg-white w-40 font-mono'
|
||
|
||
function fmtNum(n, decimals = 2) {
|
||
if (n == null || !isFinite(n)) return '—'
|
||
return n.toLocaleString(undefined, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })
|
||
}
|
||
|
||
function fmtDelta(n, decimals = 2) {
|
||
if (n == null || !isFinite(n) || n === 0) return null
|
||
const sign = n > 0 ? '+' : '−'
|
||
return `${sign}${fmtNum(Math.abs(n), decimals)}`
|
||
}
|
||
|
||
// A blank dimension value has to read as a value, or a slice isolated to the
|
||
// blank node is indistinguishable from one the column is absent from -- and the
|
||
// two mean opposite things. Both blanks occur in the same column here, so they
|
||
// are named apart: null is a missing value, '' is a present empty one.
|
||
function dispVal(v) {
|
||
if (v === null || v === undefined) return '(null)'
|
||
if (v === '') return '(blank)'
|
||
return String(v)
|
||
}
|
||
|
||
function sliceLabel(s) {
|
||
const entries = Object.entries(s)
|
||
return entries.length ? entries.map(([k, v]) => `${k}=${dispVal(v)}`).join(' · ') : '—'
|
||
}
|
||
|
||
// A light grouping with an optional caption — a rule between blocks in the
|
||
// bottom dock, nothing but spacing elsewhere.
|
||
function Block({ title, hint, horizontal, grow, children }) {
|
||
return (
|
||
<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, busy }) {
|
||
return (
|
||
<button onClick={onClick} disabled={disabled || busy}
|
||
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
|
||
inline-flex items-center gap-2">
|
||
{busy && (
|
||
<span className="inline-block w-3 h-3 rounded-full border-2 border-white/40 border-t-white animate-spin" />
|
||
)}
|
||
{children}
|
||
</button>
|
||
)
|
||
}
|
||
|
||
// ── 1. Selection ────────────────────────────────────────────────────────────
|
||
function SelectionList({ slices, viewScope = [], currentTotals, onRemove, onClear }) {
|
||
const multi = slices.length > 1
|
||
const perSlice = currentTotals?.perSlice || []
|
||
const valueCol = currentTotals?.valueCol
|
||
|
||
// The pivot's own filter. Shown because it scopes every figure below and
|
||
// every row the operation writes, while appearing in none of the slices --
|
||
// perspective-click reports only the cell's own dimensions, so without this
|
||
// the panel prints a selection wider than the one it is acting on.
|
||
const scopeLine = viewScope
|
||
.map(([col, op, ...rest]) => {
|
||
const vals = (Array.isArray(rest[0]) ? rest[0] : rest).filter(v => v !== undefined)
|
||
return `${col} ${op}${vals.length ? ' ' + vals.join(', ') : ''}`
|
||
})
|
||
.join(' · ')
|
||
|
||
if (!slices.length) {
|
||
return (
|
||
<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">
|
||
{scopeLine && (
|
||
<div className="mb-1 flex items-baseline gap-1.5 text-[11px]">
|
||
<span className="text-gray-400 uppercase tracking-wide shrink-0">within</span>
|
||
<span className="font-mono text-gray-600 truncate" title={scopeLine}>{scopeLine}</span>
|
||
</div>
|
||
)}
|
||
<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 font-mono ${v === null || v === '' ? 'text-gray-500 italic' : 'text-gray-700'}`}>
|
||
{dispVal(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),
|
||
// The same three as numbers, for anything that needs to compute with them.
|
||
// The strings above are grouped by toLocaleString, and parseFloat stops at
|
||
// the first separator -- "3,456,789" reads back as 3 -- so a caller that
|
||
// re-parsed the display got a plausible small number rather than an error.
|
||
// Nothing should have to know that; the numbers travel alongside.
|
||
n: { new: next, change, pct },
|
||
}
|
||
out[edit.field] = edit.raw // keep what you typed exactly as typed
|
||
return out
|
||
}
|
||
|
||
// Grouped while you type, because -2000000 and -20000000 are the same shape at
|
||
// a glance and the ledger deals in both.
|
||
//
|
||
// Formats for display only: what leaves here is always the raw string, so the
|
||
// arithmetic upstream never sees a comma. A partly-typed number has to survive
|
||
// intact -- "1." and "-" and "1.50" are all states on the way to a value, and
|
||
// reformatting them into something else as you type makes the field unusable.
|
||
function groupDigits(raw) {
|
||
const str = String(raw ?? '')
|
||
if (str === '' || str === '-') return str
|
||
const neg = str.startsWith('-')
|
||
const body = neg ? str.slice(1) : str
|
||
const dot = body.indexOf('.')
|
||
const whole = (dot === -1 ? body : body.slice(0, dot)).replace(/\D/g, '')
|
||
const frac = dot === -1 ? null : body.slice(dot + 1).replace(/\D/g, '')
|
||
if (whole === '' && frac === null) return neg ? '-' : ''
|
||
const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
||
return `${neg ? '-' : ''}${grouped}${dot === -1 ? '' : `.${frac}`}`
|
||
}
|
||
|
||
function LedgerInput({ value, active, held, onChange, onFocus, onClear, suffix }) {
|
||
const ref = useRef(null)
|
||
const caret = useRef(null)
|
||
const display = groupDigits(value)
|
||
|
||
// The commas shift every character after them, so a remembered offset lands
|
||
// in the wrong place. Count digits instead -- those are what the caret is
|
||
// actually sitting between -- and find that many digits into the new text.
|
||
useLayoutEffect(() => {
|
||
const el = ref.current
|
||
if (!el || caret.current == null) return
|
||
const wanted = caret.current
|
||
caret.current = null
|
||
let seen = 0, pos = display.length
|
||
for (let i = 0; i < display.length; i++) {
|
||
if (/[\d.-]/.test(display[i])) seen++
|
||
if (seen === wanted) { pos = i + 1; break }
|
||
}
|
||
if (wanted === 0) pos = 0
|
||
try { el.setSelectionRange(pos, pos) } catch {}
|
||
}, [display])
|
||
|
||
// The suffix is positioned inside the box rather than set beside it. As a
|
||
// sibling in the flex row it took width of its own, so in a right-aligned cell
|
||
// the % row's input sat a suffix-and-a-gap to the left of the rows above it,
|
||
// and the three boxes that are meant to be three ways of saying one thing did
|
||
// not line up. Inside, every row's box is the same width and the same edge.
|
||
return (
|
||
<span className="relative inline-flex items-center">
|
||
<input
|
||
ref={ref}
|
||
type="text" inputMode="decimal" value={display}
|
||
onChange={e => {
|
||
const el = e.target
|
||
const upto = el.value.slice(0, el.selectionStart ?? el.value.length)
|
||
caret.current = (upto.match(/[\d.-]/g) || []).length
|
||
onChange(el.value.replace(/,/g, ''))
|
||
}}
|
||
onFocus={onFocus} placeholder="—"
|
||
onKeyDown={e => { if (e.key === 'Escape' && onClear) { e.preventDefault(); onClear(); e.target.blur() } }}
|
||
title={held ? 'Held by the Absorbed by setting — type here to set it yourself' : undefined}
|
||
className={`border rounded py-0.5 text-xs w-24 text-right font-mono tabular-nums transition-colors
|
||
${suffix ? 'pl-2 pr-5' : 'px-2'}
|
||
${active ? 'border-blue-400 bg-blue-50/40 text-gray-800'
|
||
: held ? 'border-gray-100 bg-gray-50 text-gray-400'
|
||
: 'border-gray-200 bg-white text-gray-700'}`} />
|
||
{suffix && (
|
||
<span className="absolute right-1.5 text-gray-400 pointer-events-none"
|
||
style={{ fontSize: '10px' }}>{suffix}</span>
|
||
)}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
// Tag completion that survives how people actually type.
|
||
//
|
||
// The native datalist substring-matches, so "moved to different sku" never
|
||
// offers "moved to a different sku" -- one inserted word and the existing tag
|
||
// is invisible. That is how a version ends up with four spellings of one
|
||
// initiative and a ledger line for each.
|
||
//
|
||
// Words, not substrings: both sides are lowercased, stripped of punctuation,
|
||
// relieved of the words that carry no meaning here, and lightly stemmed, so
|
||
// "Pulled Forward" and "Pull Forward" reduce to the same thing. Nothing is
|
||
// merged automatically -- the match is offered and the typing is still yours.
|
||
const TAG_STOP = new Set(['a', 'an', 'the', 'to', 'of', 'for', 'in', 'on'])
|
||
|
||
function tagTokens(s) {
|
||
return String(s || '')
|
||
.toLowerCase()
|
||
.replace(/[^a-z0-9\s]/g, ' ')
|
||
.split(/\s+/)
|
||
.filter(w => w && !TAG_STOP.has(w))
|
||
.map(w => w.replace(/(ed|es|s)$/, ''))
|
||
}
|
||
|
||
const tagKey = (s) => tagTokens(s).sort().join(' ')
|
||
|
||
function TagInput({ value, onChange, tags = [], className }) {
|
||
const [open, setOpen] = useState(false)
|
||
const boxRef = useRef(null)
|
||
|
||
useEffect(() => {
|
||
if (!open) return
|
||
const onDown = (e) => { if (!boxRef.current?.contains(e.target)) setOpen(false) }
|
||
document.addEventListener('mousedown', onDown, true)
|
||
return () => document.removeEventListener('mousedown', onDown, true)
|
||
}, [open])
|
||
|
||
const typed = value.trim()
|
||
const tokens = tagTokens(typed)
|
||
|
||
// Ranked: every typed word present (as a whole word or the start of one)
|
||
// beats sharing some of them, and a shorter tag beats a longer one on a tie,
|
||
// being the more likely thing you meant.
|
||
const matches = (() => {
|
||
if (!tags.length) return []
|
||
const scored = tags.map(t => {
|
||
const tt = tagTokens(t.tag)
|
||
if (!tokens.length) return { t, score: 1 }
|
||
const hit = tokens.filter(w => tt.some(x => x === w || x.startsWith(w) || w.startsWith(x)))
|
||
if (!hit.length) return { t, score: 0 }
|
||
return { t, score: hit.length === tokens.length ? 2 : 1 }
|
||
})
|
||
return scored
|
||
.filter(x => x.score > 0)
|
||
.sort((a, b) => b.score - a.score || a.t.tag.length - b.t.tag.length)
|
||
.slice(0, 6)
|
||
.map(x => x.t)
|
||
})()
|
||
|
||
// Typed something that reduces to an existing tag without being it. Worth
|
||
// saying out loud, because the ledger will report the two separately.
|
||
const nearDupe = typed
|
||
&& !tags.some(t => t.tag === typed)
|
||
&& tags.find(t => tagKey(t.tag) && tagKey(t.tag) === tagKey(typed))
|
||
|
||
return (
|
||
<span ref={boxRef} className="relative inline-flex items-center">
|
||
<input
|
||
value={value}
|
||
onChange={e => { onChange(e.target.value); setOpen(true) }}
|
||
onFocus={() => setOpen(true)}
|
||
onKeyDown={e => { if (e.key === 'Escape') { setOpen(false); e.stopPropagation() } }}
|
||
placeholder="initiative, e.g. reduce_spend"
|
||
className={className} />
|
||
|
||
{open && (matches.length > 0 || nearDupe) && (
|
||
<div className="absolute top-full left-0 mt-1 z-50 min-w-full max-w-xs bg-white border
|
||
border-gray-200 rounded shadow-lg py-1 text-xs">
|
||
{nearDupe && (
|
||
<button onClick={() => { onChange(nearDupe.tag); setOpen(false) }}
|
||
className="block w-full text-left px-2 py-1 text-amber-700 hover:bg-amber-50">
|
||
Already used as “{nearDupe.tag}” — use that
|
||
</button>
|
||
)}
|
||
{matches.map(t => (
|
||
<button key={t.tag} onClick={() => { onChange(t.tag); setOpen(false) }}
|
||
className={`block w-full text-left px-2 py-1 truncate hover:bg-gray-50
|
||
${t.tag === typed ? 'text-blue-700' : 'text-gray-700'}`}>
|
||
{t.tag}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</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) {
|
||
const meta = logMeta[e.logid] || {}
|
||
// Named like every other line: the label the pivot shows, then the older
|
||
// fallbacks. "Baseline" was hardcoded, so a segment called 03 - New Orders
|
||
// everywhere else read as "Baseline" here alone.
|
||
if (e.key === 'baseline') {
|
||
const name = (meta.label || meta.tag || meta.note || '').trim()
|
||
baseline.push({ ...e, label: name || 'Baseline', kind: 'baseline' })
|
||
continue
|
||
}
|
||
const tag = (meta.label || meta.tag || '').trim()
|
||
if (tag) {
|
||
// Grouped case-insensitively. "Keystone reduction" and "Keystone
|
||
// Reduction" are one initiative typed twice, and splitting them here
|
||
// reports two lines for one thing. The spelling shown is whichever was
|
||
// used most; the tags themselves are untouched.
|
||
const k = tag.toLowerCase()
|
||
const g = byTag.get(k) ||
|
||
{ key: `tag:${k}`, label: tag, kind: 'tag', value: 0, units: 0, count: 0,
|
||
first: e.logid, spellings: new Map() }
|
||
g.value += e.value || 0
|
||
g.units += e.units || 0
|
||
g.count += 1
|
||
g.first = Math.min(g.first ?? e.logid, e.logid ?? g.first)
|
||
g.spellings.set(tag, (g.spellings.get(tag) || 0) + 1)
|
||
byTag.set(k, g)
|
||
} else {
|
||
// One line for all of them, not one line each. An untagged adjustment
|
||
// has no name to tell it from the next untagged one, so listing them
|
||
// separately spends a row apiece to say "Scale #226" -- 28 of them on
|
||
// the version this was built against.
|
||
loose.push(e)
|
||
}
|
||
}
|
||
const tagged = [...byTag.values()]
|
||
.sort((a, b) => (a.first ?? 0) - (b.first ?? 0))
|
||
.map(g => ({
|
||
...g,
|
||
label: [...g.spellings.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] || g.label,
|
||
}))
|
||
|
||
const untagged = loose.length ? [{
|
||
key: 'untagged',
|
||
kind: 'untagged',
|
||
label: `${loose.length} untagged adjustment${loose.length === 1 ? '' : 's'}`,
|
||
count: loose.length,
|
||
value: loose.reduce((a, e) => a + (e.value || 0), 0),
|
||
units: loose.reduce((a, e) => a + (e.units || 0), 0),
|
||
}] : []
|
||
|
||
return [...baseline, ...tagged, ...untagged]
|
||
})()
|
||
|
||
// A broad slice reaches every initiative on the version -- 15 tags and 48
|
||
// adjustments on the one this was built against -- and a walk that long stops
|
||
// being a walk. The loads stay whole, since they are the shape of the
|
||
// forecast; the adjustments keep the biggest few and roll the rest into one
|
||
// line, which is still arithmetically the walk: addition does not care about
|
||
// the order, and the line carries its own subtotal.
|
||
const MAX_ADJ_LINES = 6
|
||
const [showAllLines, setShowAllLines] = useState(false)
|
||
|
||
const loadLines = lines.filter(l => l.kind === 'baseline')
|
||
const adjLines = lines.filter(l => l.kind !== 'baseline')
|
||
const rolled = !showAllLines && adjLines.length > MAX_ADJ_LINES + 1
|
||
|
||
const displayLines = (() => {
|
||
if (!rolled) return lines
|
||
// Biggest by absolute value, then back into the order they happened, so the
|
||
// ones that are kept still read chronologically.
|
||
const rank = [...adjLines].sort((a, b) => Math.abs(b.value || 0) - Math.abs(a.value || 0))
|
||
const keep = new Set(rank.slice(0, MAX_ADJ_LINES).map(l => l.key))
|
||
const kept = adjLines.filter(l => keep.has(l.key))
|
||
const rest = adjLines.filter(l => !keep.has(l.key))
|
||
const sum = rest.reduce((a, l) => ({
|
||
value: a.value + (l.value || 0),
|
||
units: a.units + (l.units || 0),
|
||
count: a.count + (l.count || 1),
|
||
}), { value: 0, units: 0, count: 0 })
|
||
return [...loadLines, ...kept,
|
||
{ key: 'rollup', kind: 'rollup', label: `${rest.length} smaller changes`, ...sum }]
|
||
})()
|
||
|
||
const perSlice = multi && applyMode === 'each'
|
||
|
||
// rows the pivot shows but this operation cannot write
|
||
const excl = currentTotals?.excluded || { value: 0, units: 0, rows: 0 }
|
||
const hasExcl = excl.rows > 0 && (excl.value !== 0 || excl.units !== 0)
|
||
const onTotal = hasExcl && targetBasis !== 'adjustable' // 'selected total' is the default
|
||
const grand = { value: total.value + excl.value, units: total.units + excl.units }
|
||
// Named by the segments themselves where we have them -- "02 - Prior Year"
|
||
// reads as a thing a forecaster recognises, where "reference" names only the
|
||
// iter band that happens to exclude it.
|
||
const exclName = (currentTotals?.excluded?.names || []).join(' · ')
|
||
|| (currentTotals?.excludedIters || []).join(' / ')
|
||
|| 'excluded'
|
||
|
||
// Everything in the selection is immovable. Worth saying outright: the panel
|
||
// otherwise prints a row of zeros and leaves the reason to be worked out.
|
||
const nothingToAdjust = hasExcl && !total.value && !total.units
|
||
|
||
// One line per immovable segment. Falls back to the combined figure for a
|
||
// selection whose rows carry no segment name.
|
||
const exclLines = (currentTotals?.excluded?.bySegment?.length
|
||
? currentTotals.excluded.bySegment
|
||
: (hasExcl ? [{ name: exclName, ...excl }] : []))
|
||
|
||
// the basis decides which line the editable rows are measured from
|
||
const basisOf = (key) => {
|
||
if (!onTotal) return key === 'price' ? curPrice : total[key]
|
||
if (key === 'price') return grand.units ? grand.value / grand.units : null
|
||
return grand[key]
|
||
}
|
||
|
||
// Every line that has both measures has a price -- a bridge line's is the
|
||
// implied price of that initiative's own contribution, which is the number
|
||
// that says whether it was a price move or a volume move. Only a line with no
|
||
// units has nothing to show, since the price is undefined rather than zero.
|
||
const priceOf = (row) => (row && row.units) ? row.value / row.units : null
|
||
|
||
// measure columns, in ledger order
|
||
const measures = [
|
||
// Whole units for the measures -- the figures run to eight digits, where two
|
||
// decimals are noise. Price is the opposite: it sits around 0.27, so it needs
|
||
// the places to show a move at all.
|
||
valueCol && { key: 'value', label: valueCol, current: total.value, dp: 0 },
|
||
unitsCol && { key: 'units', label: unitsCol, current: total.units, dp: 0 },
|
||
(valueCol && unitsCol) && { key: 'price', label: 'price', current: curPrice, dp: 5, hint: 'value / units' },
|
||
].filter(Boolean)
|
||
|
||
const derived = Object.fromEntries(
|
||
measures.map(m => [m.key, derive(basisOf(m.key) ?? 0, scaleInputs[m.key], m.dp)])
|
||
)
|
||
|
||
// an edit is dollars-only when value carries a number and neither units nor
|
||
// price does -- the one case where price and volume are both still unknown
|
||
const filled = (key) => {
|
||
const raw = scaleInputs[key]?.raw
|
||
return raw != null && raw !== '' && isFinite(parseFloat(raw))
|
||
}
|
||
const dollarsOnly = filled('value') && !filled('units') && !filled('price')
|
||
|
||
// Where the edit lands, for all three measures at once. The rows above each
|
||
// show only their own input, so a dollar figure entered with plug = volume
|
||
// left units and price blank -- exactly the two numbers you need to see to
|
||
// know whether you asked for a price change or a volume change.
|
||
const numOf = (key, field) => {
|
||
const n = derived[key]?.n?.[field]
|
||
return n != null && isFinite(n) ? n : null
|
||
}
|
||
|
||
const outcome = (() => {
|
||
const curValue = basisOf('value') ?? 0
|
||
const curUnits = basisOf('units') ?? 0
|
||
if (!filled('value') && !filled('units') && !filled('price')) return null
|
||
|
||
let newValue, newUnits
|
||
|
||
if (filled('price') && !filled('value')) {
|
||
// price is the input; dollars fall out of price x volume
|
||
const targetPrice = numOf('price', 'new')
|
||
newUnits = filled('units') ? numOf('units', 'new') : curUnits
|
||
newValue = targetPrice != null && newUnits != null ? targetPrice * newUnits : null
|
||
} else {
|
||
newValue = filled('value') ? numOf('value', 'new') : curValue
|
||
if (filled('units')) newUnits = numOf('units', 'new')
|
||
else if (dollarsOnly && scalePlug === 'volume') newUnits = curValue === 0 ? null
|
||
: curUnits * (newValue / curValue)
|
||
else newUnits = curUnits
|
||
}
|
||
|
||
if (newValue == null || newUnits == null) return null
|
||
const newPrice = newUnits === 0 ? null : newValue / newUnits
|
||
const curPriceLocal = curUnits === 0 ? null : curValue / curUnits
|
||
return {
|
||
value: newValue,
|
||
units: newUnits,
|
||
price: newPrice,
|
||
moved: {
|
||
value: Math.abs(newValue - curValue) > 1e-9,
|
||
units: Math.abs(newUnits - curUnits) > 1e-9,
|
||
price: curPriceLocal != null && newPrice != null && Math.abs(newPrice - curPriceLocal) > 1e-9,
|
||
},
|
||
}
|
||
})()
|
||
|
||
// The same three readings for a measure nobody typed into, taken from where
|
||
// the edit actually lands it. Scaling dollars with price holding moves qty,
|
||
// and leaving that column blank until the Result row meant the number you had
|
||
// just implied was the one thing not shown next to the one you typed.
|
||
const backfill = (current, next, dp) => {
|
||
if (next == null || !isFinite(next) || current == null) return { new: '', change: '', pct: '' }
|
||
const change = next - current
|
||
const pct = current === 0 ? null : (change / Math.abs(current)) * 100
|
||
return {
|
||
new: fmtNum(next, dp),
|
||
change: fmtNum(change, dp),
|
||
pct: pct == null ? '—' : fmtNum(pct, 1),
|
||
n: { new: next, change, pct },
|
||
}
|
||
}
|
||
|
||
// What the rows render. derived stays the edit-driven map -- it is what
|
||
// outcome is computed from, so back-filling into it would feed the result
|
||
// back into its own inputs.
|
||
const shown = Object.fromEntries(measures.map(m => [
|
||
m.key,
|
||
(filled(m.key) || !outcome) ? derived[m.key] : backfill(basisOf(m.key), outcome[m.key], m.dp),
|
||
]))
|
||
|
||
const setEdit = (key, field, raw) =>
|
||
setScaleInputs(prev => ({ ...prev, [key]: { field, raw } }))
|
||
|
||
// Hand a measure back to being derived. Emptying the box does the same thing
|
||
// -- filled() is false either way -- but that is only obvious once you know
|
||
// it, and clicking into a held column to look at it and then wanting out
|
||
// again is the ordinary case, not the awkward one.
|
||
const clearEdit = (key) =>
|
||
setScaleInputs(prev => {
|
||
if (!(key in prev)) return prev
|
||
const { [key]: _drop, ...rest } = prev
|
||
return rest
|
||
})
|
||
|
||
// focusing a different row hands that measure's edit to the focused row,
|
||
// carrying across whatever it currently reads
|
||
const focusRow = (key, field) =>
|
||
setScaleInputs(prev => {
|
||
const cur = prev[key]
|
||
if (cur && cur.field === field) return prev
|
||
// Whatever the box reads is what you get, including a back-filled
|
||
// number: clicking into an implied figure is how you pin it, which is
|
||
// the difference between "units may move" and "units are this".
|
||
const text = shown[key]?.[field] ?? ''
|
||
const raw = text === '—' ? '' : String(text).replace(/,/g, '')
|
||
return { ...prev, [key]: { field, raw } }
|
||
})
|
||
|
||
// A running total across the walk, in the primary measure. The lines sum to
|
||
// Adjustable, and without this you are adding eight-digit numbers in your
|
||
// head to check that they do.
|
||
//
|
||
// Value only: a cumulative price is meaningless -- prices do not add -- and a
|
||
// second running column for units doubles the width to say something the
|
||
// value column already implies.
|
||
// Accumulates in display order across everything: the immovable segments
|
||
// first, then the walk. So it reads as the whole number being built up --
|
||
// billed, then booked, then the baseline, then each adjustment -- and closes
|
||
// on Selected total rather than on Adjustable, which is only the part of it
|
||
// that can still move.
|
||
const runningByKey = (() => {
|
||
const out = new Map()
|
||
let acc = 0
|
||
for (const seg of exclLines) { acc += seg.value || 0; out.set(`final:${seg.name}`, acc) }
|
||
for (const e of displayLines) { acc += e.value || 0; out.set(e.key, acc) }
|
||
return out
|
||
})()
|
||
const showRunning = !!valueCol && (displayLines.length + exclLines.length) > 1
|
||
|
||
// Which measure the plug holds still. Only meaningful while dollars are the
|
||
// sole input -- that is the one case where price and volume are both unknown
|
||
// and the toggle is answering for them. 'price' means price absorbs the
|
||
// change, so qty holds; 'volume' means qty absorbs it, so price holds.
|
||
const heldKey = (valueCol && unitsCol && dollarsOnly)
|
||
? (scalePlug === 'price' ? 'units' : 'price')
|
||
: null
|
||
|
||
const numCell = 'text-right font-mono tabular-nums whitespace-nowrap px-2'
|
||
const rule = <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.key === heldKey ? 'text-gray-400' : ''}`}>
|
||
{m.label}
|
||
{m.key === heldKey
|
||
? <span className="text-gray-400"> · holds</span>
|
||
: m.hint && <span className="text-gray-500"> · {m.hint}</span>}
|
||
{scaleInputs[m.key] && (
|
||
<button onClick={() => clearEdit(m.key)}
|
||
title={`Stop editing ${m.label} — let it follow from the others again`}
|
||
className="ml-1 text-gray-300 hover:text-red-500 leading-none">✕</button>
|
||
)}
|
||
</th>
|
||
))}
|
||
{showRunning && (
|
||
<th className="text-right font-normal pb-1 px-2 whitespace-nowrap text-gray-500">
|
||
running
|
||
</th>
|
||
)}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{/* What cannot move comes first: it is the constraint the rest is
|
||
worked out against. Then the walk, which sums to Adjustable, and
|
||
the two together make the selected total. */}
|
||
{exclLines.map(seg => (
|
||
<tr key={seg.name} className="text-amber-700">
|
||
<td className="pr-3 whitespace-nowrap max-w-[16rem] truncate" title={seg.name}>
|
||
{seg.name}
|
||
<span className="ml-1.5 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(seg), m.dp) : fmtNum(seg[m.key], m.dp)}
|
||
</td>
|
||
))}
|
||
{showRunning && (
|
||
<td className={`${numCell} text-amber-700`}>
|
||
{fmtNum(runningByKey.get(`final:${seg.name}`), 0)}
|
||
</td>
|
||
)}
|
||
</tr>
|
||
))}
|
||
|
||
{showAllLines && adjLines.length > MAX_ADJ_LINES + 1 && (
|
||
<tr>
|
||
<td colSpan={measures.length + 1 + (showRunning ? 1 : 0)} className="pr-3">
|
||
<button onClick={() => setShowAllLines(false)}
|
||
className="text-gray-400 hover:text-gray-600">▾ collapse the smaller changes</button>
|
||
</td>
|
||
</tr>
|
||
)}
|
||
|
||
{exclLines.length > 0 && (
|
||
<tr>{rule}{measures.map(m => <td key={m.key} className="p-0 px-2"><div className="border-t border-gray-300 my-1" /></td>)}{showRunning && <td className="p-0 px-2"><div className="border-t border-gray-300 my-1" /></td>}</tr>
|
||
)}
|
||
|
||
{/* the walk from baseline to current, by initiative */}
|
||
{displayLines.map(e => (
|
||
<tr key={e.key}
|
||
className={e.kind === 'rollup' ? 'text-gray-500 cursor-pointer hover:text-gray-700' : 'text-gray-600'}
|
||
onClick={e.kind === 'rollup' ? () => setShowAllLines(true) : undefined}>
|
||
<td className="pr-3 whitespace-nowrap max-w-[14rem] truncate" title={e.label}>
|
||
{e.kind === 'tag' && <span className="text-blue-600">▪ </span>}
|
||
{(e.kind === 'rollup' || e.kind === 'untagged') && <span className="text-gray-400">▸ </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>
|
||
))}
|
||
{showRunning && (
|
||
<td className={`${numCell} text-gray-500`}>{fmtNum(runningByKey.get(e.key), 0)}</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>)}{showRunning && <td 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>
|
||
))}
|
||
{showRunning && <td className={numCell} />}
|
||
</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>
|
||
))}
|
||
{showRunning && (
|
||
<td className={`${numCell} font-semibold text-gray-700`}>{fmtNum(grand.value, 0)}</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 + (showRunning ? 1 : 0)} 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>)}{showRunning && <td 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={shown[m.key]?.[field] ?? ''}
|
||
active={active}
|
||
held={m.key === heldKey}
|
||
onChange={(raw) => setEdit(m.key, field, raw)}
|
||
onFocus={() => focusRow(m.key, field)}
|
||
onClear={() => clearEdit(m.key)}
|
||
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>)}{showRunning && <td 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. */}
|
||
{/* Both of these are settings on the edit, so they use Field like every
|
||
other one -- an aligned label column and a hint below the control.
|
||
They used to hand-roll their own label row and hint, with two
|
||
different type sizes and no shared label width, so the two stacked
|
||
into a paragraph of loose words rather than reading as two settings. */}
|
||
{(valueCol && unitsCol && dollarsOnly || hasExcl || perSlice) && (
|
||
<div className="flex flex-col gap-2 mt-3 pt-3 border-t border-gray-100">
|
||
|
||
{valueCol && unitsCol && dollarsOnly && (
|
||
<Field label="Absorbed by">
|
||
<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`],
|
||
]}
|
||
/>
|
||
</Field>
|
||
)}
|
||
|
||
{hasExcl && (
|
||
<Field label="Target on"
|
||
hint={onTotal
|
||
? `${exclName} can't change, so the adjustable rows absorb the difference. The pivot will show your target.`
|
||
: `${exclName} ignored. The pivot will show your target plus ${fmtNum(excl.value)}.`}>
|
||
<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'],
|
||
]}
|
||
/>
|
||
</Field>
|
||
)}
|
||
|
||
{perSlice && (
|
||
<Field label="Per slice"
|
||
hint="The figures above are combined totals, so each slice's own change will differ.">
|
||
<span className="text-gray-500">Applied to each slice separately</span>
|
||
</Field>
|
||
)}
|
||
</div>
|
||
)}
|
||
</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 inSlice = c.cname in first
|
||
const cur = multi
|
||
? (new Set(slices.map(s => s[c.cname])).size > 1 ? '(varies)' : (inSlice ? dispVal(first[c.cname]) : '—'))
|
||
: (inSlice ? dispVal(first[c.cname]) : '—')
|
||
return (
|
||
<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,
|
||
viewScope = [],
|
||
opBusy = null,
|
||
applyMode, setApplyMode,
|
||
currentTotals,
|
||
activeOp, setActiveOp,
|
||
scaleInputs, setScaleInputs,
|
||
scalePlug, setScalePlug,
|
||
targetBasis, setTargetBasis,
|
||
opTag, setOpTag, knownTags = [], logMeta = {},
|
||
scaleNote, setScaleNote,
|
||
recodeSet, setRecodeSet,
|
||
recodeNote, setRecodeNote,
|
||
cloneSet, setCloneSet,
|
||
cloneScale, setCloneScale,
|
||
cloneFrom, setCloneFrom, cloneOffset, setCloneOffset, cloneSources,
|
||
cloneNote, setCloneNote,
|
||
dimCols, lookupDerivedCols, dimMembers, versionId,
|
||
buildPayload, submitOp,
|
||
}) {
|
||
const hasSlice = slices.length > 0
|
||
const multi = slices.length > 1
|
||
const horizontal = dock === 'bottom'
|
||
|
||
const note = activeOp === 'scale' ? scaleNote : activeOp === 'recode' ? recodeNote : cloneNote
|
||
const shifting = !!cloneOffset && cloneOffset.trim() !== '' && cloneOffset.trim() !== '0 days'
|
||
const setNote = activeOp === 'scale' ? setScaleNote : activeOp === 'recode' ? setRecodeNote : setCloneNote
|
||
const OP_LABEL = { scale: 'Apply Scale', recode: 'Apply Recode', clone: 'Apply Clone' }
|
||
const OP_BUSY_LABEL = { scale: 'Applying…', recode: 'Recoding…', clone: 'Cloning…' }
|
||
|
||
return (
|
||
<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}
|
||
viewScope={viewScope}
|
||
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">
|
||
<TagInput value={opTag} onChange={setOpTag} tags={knownTags}
|
||
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>
|
||
|
||
{/* The six quick-pick pills that used to sit here are gone. They
|
||
wrapped onto two rows once the tags were more than a word long,
|
||
which is most of them, and the panel docked at the bottom has a
|
||
fixed height to spend -- so they pushed the form past its own
|
||
border. The same tags complete from the input's datalist, which
|
||
costs no space and is not capped at six. */}
|
||
|
||
<Field label="note">
|
||
<input value={note} onChange={e => setNote(e.target.value)}
|
||
placeholder="optional" className={`${TEXT} w-48`} />
|
||
</Field>
|
||
|
||
<div className="pt-1 flex items-center gap-3">
|
||
<Submit onClick={() => submitOp(activeOp)} busy={!!opBusy}>
|
||
{opBusy ? OP_BUSY_LABEL[opBusy] : OP_LABEL[activeOp]}
|
||
</Submit>
|
||
{opBusy && (
|
||
<span className="text-gray-500 leading-snug">
|
||
Writing rows — the pivot updates when it finishes.
|
||
</span>
|
||
)}
|
||
</div>
|
||
<RequestPreview payload={buildPayload(activeOp)} />
|
||
</div>
|
||
</Block>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|