The lines sum to Adjustable, and checking that they do meant adding eight-digit numbers in your head. The column closes on the Adjustable row, so the walk visibly lands where it says it does. Value only. A cumulative price is meaningless -- prices do not add -- and a second running column for units doubles the width to say what the value column already implies. It appears only when there is more than one line to accumulate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1085 lines
48 KiB
JavaScript
1085 lines
48 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)}`
|
||
}
|
||
|
||
function sliceLabel(s) {
|
||
const entries = Object.entries(s)
|
||
return entries.length ? entries.map(([k, v]) => `${k}=${v}`).join(' · ') : '—'
|
||
}
|
||
|
||
// A light grouping with an optional caption — a rule between blocks in the
|
||
// bottom dock, nothing but spacing elsewhere.
|
||
function Block({ title, hint, horizontal, grow, children }) {
|
||
return (
|
||
<section className={[
|
||
horizontal ? 'pl-5 border-l border-gray-200 first:pl-0 first:border-l-0' : '',
|
||
grow ? 'flex-1 min-w-0' : 'shrink-0',
|
||
].join(' ')}>
|
||
{title && (
|
||
<header className="flex items-baseline gap-1.5 mb-2">
|
||
<h3 className="font-semibold text-gray-600 uppercase tracking-wide" style={{ fontSize: '10px' }}>{title}</h3>
|
||
{hint && <span className="text-gray-600" style={{ fontSize: '10px' }}>{hint}</span>}
|
||
</header>
|
||
)}
|
||
{children}
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function Button({ onClick, active, children, title }) {
|
||
return (
|
||
<button onClick={onClick} title={title}
|
||
className={`px-3 py-1 rounded text-xs whitespace-nowrap transition-colors ${
|
||
active ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50'}`}>
|
||
{children}
|
||
</button>
|
||
)
|
||
}
|
||
|
||
// A label and its control, on a grid. Every row in the panel uses the same label
|
||
// width, so the controls line up down the column instead of each row starting
|
||
// wherever its label happens to end -- which was the whole problem with
|
||
// "copy rows from" sitting above "scale cloned rows by" above "tag".
|
||
const LABEL_W = 'w-24'
|
||
|
||
function Field({ label, children, hint }) {
|
||
return (
|
||
<div className="flex items-start gap-2">
|
||
<span className={`text-gray-600 whitespace-nowrap shrink-0 pt-1 ${LABEL_W}`}>{label}</span>
|
||
<div className="flex flex-col gap-1 min-w-0">
|
||
<div className="flex items-center gap-2 flex-wrap">{children}</div>
|
||
{hint && <p className="text-gray-500 text-[11px] leading-snug max-w-xs">{hint}</p>}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Same 10px uppercase as the ledger table headers, so the groupings read as part
|
||
// of the same family rather than as a second style.
|
||
function SectionLabel({ children }) {
|
||
return (
|
||
<div className="text-gray-400 uppercase tracking-wide pt-1" style={{ fontSize: '10px' }}>
|
||
{children}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function Segmented({ options, value, onChange }) {
|
||
return (
|
||
<div className="inline-flex rounded border border-gray-200 overflow-hidden w-auto self-start">
|
||
{options.map(([val, label, title]) => (
|
||
<Button key={val} onClick={() => onChange(val)} active={value === val} title={title}>{label}</Button>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function Submit({ onClick, children, disabled }) {
|
||
return (
|
||
<button onClick={onClick} disabled={disabled}
|
||
className="self-start px-4 py-1.5 rounded text-xs font-medium bg-blue-600 text-white hover:bg-blue-700
|
||
disabled:bg-gray-200 disabled:text-gray-600 disabled:cursor-not-allowed whitespace-nowrap">
|
||
{children}
|
||
</button>
|
||
)
|
||
}
|
||
|
||
// ── 1. Selection ────────────────────────────────────────────────────────────
|
||
function SelectionList({ slices, 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 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
|
||
}
|
||
|
||
// 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, onChange, onFocus, 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])
|
||
|
||
return (
|
||
<span className="inline-flex items-center gap-1">
|
||
<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="—"
|
||
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) {
|
||
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) {
|
||
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.label || meta.note || '').trim()
|
||
|| `${op.charAt(0).toUpperCase()}${op.slice(1)} #${e.logid}`,
|
||
})
|
||
}
|
||
}
|
||
const tagged = [...byTag.values()].sort((a, b) => (a.first ?? 0) - (b.first ?? 0))
|
||
return [...baseline, ...tagged, ...loose]
|
||
})()
|
||
const perSlice = multi && applyMode === 'each'
|
||
|
||
// rows the pivot shows but this operation cannot write
|
||
const excl = currentTotals?.excluded || { value: 0, units: 0, rows: 0 }
|
||
const hasExcl = excl.rows > 0 && (excl.value !== 0 || excl.units !== 0)
|
||
const onTotal = hasExcl && targetBasis !== 'adjustable' // 'selected total' is the default
|
||
const grand = { value: total.value + excl.value, units: total.units + excl.units }
|
||
// Named by the segments themselves where we have them -- "02 - Prior Year"
|
||
// reads as a thing a forecaster recognises, where "reference" names only the
|
||
// iter band that happens to exclude it.
|
||
const exclName = (currentTotals?.excluded?.names || []).join(' · ')
|
||
|| (currentTotals?.excludedIters || []).join(' / ')
|
||
|| 'excluded'
|
||
|
||
// Everything in the selection is immovable. Worth saying outright: the panel
|
||
// otherwise prints a row of zeros and leaves the reason to be worked out.
|
||
const nothingToAdjust = hasExcl && !total.value && !total.units
|
||
|
||
// 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 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 : '' } }
|
||
})
|
||
|
||
// 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.
|
||
const runningByKey = (() => {
|
||
const out = new Map()
|
||
let acc = 0
|
||
for (const e of lines) { acc += e.value || 0; out.set(e.key, acc) }
|
||
return out
|
||
})()
|
||
const showRunning = !!valueCol && lines.length > 1
|
||
|
||
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>
|
||
))}
|
||
{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>
|
||
))}
|
||
</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 */}
|
||
{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>
|
||
))}
|
||
{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} text-gray-500`}>{fmtNum(total.value, 0)}</td>
|
||
)}
|
||
</tr>
|
||
|
||
|
||
{hasExcl && (
|
||
<tr className={onTotal ? 'font-semibold text-gray-700' : 'text-gray-600'}>
|
||
<td className="pr-3 whitespace-nowrap">Selected total</td>
|
||
{measures.map(m => (
|
||
<td key={m.key} className={numCell}>
|
||
{m.key === 'price'
|
||
? fmtNum(grand.units ? grand.value / grand.units : null, m.dp)
|
||
: fmtNum(grand[m.key], m.dp)}
|
||
</td>
|
||
))}
|
||
</tr>
|
||
)}
|
||
|
||
{/* Zeros in the Adjustable row are a true answer to the wrong question:
|
||
they say how much can move, not why none of it can. Spell it out
|
||
where the eye already is, rather than leaving the edit rows to fail
|
||
silently below. */}
|
||
{nothingToAdjust && (
|
||
<tr>
|
||
<td colSpan={measures.length + 1 + (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={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>)}{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. */}
|
||
{valueCol && unitsCol && dollarsOnly && (
|
||
<div className="flex flex-col gap-1 mt-2">
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<span className="text-gray-600 whitespace-nowrap">Absorbed by</span>
|
||
<Segmented
|
||
value={scalePlug}
|
||
onChange={setScalePlug}
|
||
options={[
|
||
['price', 'Price', `${unitsCol} holds; price moves to reach the number`],
|
||
['volume', 'Volume', `Price holds; ${unitsCol} scales with the dollars`],
|
||
]}
|
||
/>
|
||
</div>
|
||
<div className="text-[11px] text-gray-500 leading-snug">
|
||
{scalePlug === 'price'
|
||
? `${unitsCol} holds; price moves to reach the number.`
|
||
: `Price holds; ${unitsCol} scales with the dollars.`}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{hasExcl && (
|
||
<div className="flex flex-col gap-1 mt-2">
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<span className="text-gray-600 whitespace-nowrap">Target applies to</span>
|
||
<Segmented
|
||
value={onTotal ? 'selected' : 'adjustable'}
|
||
onChange={setTargetBasis}
|
||
options={[
|
||
['adjustable', 'Adjustable', 'Measure against only the rows this operation can write'],
|
||
['selected', 'Selected total', 'Measure against everything the pivot shows, fixed rows included'],
|
||
]}
|
||
/>
|
||
</div>
|
||
<p className="text-gray-600 leading-snug max-w-md">
|
||
{onTotal
|
||
? `The ${exclName} rows cannot change, so the adjustable rows absorb the whole difference — the pivot will show your target.`
|
||
: `${exclName} rows are ignored. The pivot will show your target plus ${fmtNum(excl.value)}.`}
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{perSlice && (
|
||
<p className="text-gray-600 leading-snug mt-2 max-w-md">
|
||
Applied to each slice separately — the figures above are combined totals,
|
||
so each slice's own change will differ.
|
||
</p>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Completion for a key dimension. The list is fetched as you type rather than up
|
||
// front: part alone has 11,290 distinct values, and a native datalist given all of
|
||
// them is slow to open and no easier to read than a short filtered one.
|
||
function DimValueInput({ col, members, versionId, value, onChange, onBlur, className }) {
|
||
const [fallback, setFallback] = useState([])
|
||
const listId = `pf-vals-${col.cname}`
|
||
const hasList = !!members?.length
|
||
|
||
// Without a member list for this group -- never refreshed, or the column is not
|
||
// in one -- fall back to the version's own values. That is a 2s scan held in
|
||
// memory server-side, so it stays debounced rather than firing per keystroke.
|
||
useEffect(() => {
|
||
if (hasList || !versionId || !col.is_key) return
|
||
let cancelled = false
|
||
const t = setTimeout(async () => {
|
||
try {
|
||
const url = `/api/versions/${versionId}/values/${encodeURIComponent(col.cname)}`
|
||
+ `?limit=50${value ? `&q=${encodeURIComponent(value)}` : ''}`
|
||
const rows = await fetch(url).then(r => r.ok ? r.json() : [])
|
||
if (!cancelled) setFallback(Array.isArray(rows) ? rows : [])
|
||
} catch { if (!cancelled) setFallback([]) }
|
||
}, 200)
|
||
return () => { cancelled = true; clearTimeout(t) }
|
||
}, [hasList, versionId, col.cname, col.is_key, value])
|
||
|
||
// The member list is already in memory, so filtering it costs nothing and needs
|
||
// no debounce -- the options move with the keystroke.
|
||
const options = hasList
|
||
? (() => {
|
||
const q = (value || '').trim().toLowerCase()
|
||
const all = members.map(m => m.key_value)
|
||
return (q ? all.filter(v => v.toLowerCase().includes(q)) : all).slice(0, 50)
|
||
})()
|
||
: fallback
|
||
|
||
return (
|
||
<>
|
||
<input
|
||
value={value}
|
||
list={col.is_key ? listId : undefined}
|
||
onChange={onChange}
|
||
onBlur={onBlur}
|
||
placeholder="keep"
|
||
className={className} />
|
||
{col.is_key && (
|
||
<datalist id={listId}>
|
||
{options.map(o => <option key={o} value={o} />)}
|
||
</datalist>
|
||
)}
|
||
</>
|
||
)
|
||
}
|
||
|
||
// ── 2b. Recode / clone form ─────────────────────────────────────────────────
|
||
// Same pairing: the dimension's current value sits beside the box that replaces it.
|
||
function DimForm({ dimCols, setObj, setSet, slices, lookupDerivedCols, dimMembers, versionId, extra }) {
|
||
const multi = slices.length > 1
|
||
const first = slices[0] || {}
|
||
return (
|
||
<div className="flex flex-col gap-2.5 min-w-0">
|
||
<table>
|
||
<thead>
|
||
<tr className="text-gray-600" style={{ fontSize: '10px' }}>
|
||
<th className="text-left font-normal pb-1 pr-3">dimension</th>
|
||
<th className="text-left font-normal pb-1 px-2">current</th>
|
||
<th className="text-left font-normal pb-1 pl-2">new value</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{dimCols.map(c => {
|
||
const cur = multi
|
||
? (new Set(slices.map(s => s[c.cname])).size > 1 ? '(varies)' : (first[c.cname] ?? '—'))
|
||
: (first[c.cname] ?? '—')
|
||
return (
|
||
<tr key={c.cname}>
|
||
<td className="pr-3 py-0.5 text-gray-500 whitespace-nowrap" title={c.cname}>{c.label || c.cname}</td>
|
||
<td className="px-2 py-0.5 font-mono text-gray-600 max-w-[10rem] truncate" title={String(cur)}>{cur}</td>
|
||
<td className="pl-2 py-0.5">
|
||
<DimValueInput
|
||
col={c}
|
||
members={c.dim_group ? dimMembers?.[c.dim_group]?.members : null}
|
||
versionId={versionId}
|
||
value={setObj[c.cname] || ''}
|
||
onChange={e => setSet(s => ({ ...s, [c.cname]: e.target.value }))}
|
||
onBlur={c.is_key && c.dim_group
|
||
? e => lookupDerivedCols(c.cname, e.target.value, setSet)
|
||
: undefined}
|
||
className={TEXT} />
|
||
</td>
|
||
</tr>
|
||
)
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
{extra}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Recode and clone change dimensions rather than amounts, but you still want to
|
||
// see how much is on the move — and for clone, what it becomes after scaling.
|
||
// includeExcluded: clone reads reference rows too, so its preview has to count
|
||
// them. Reporting the adjustable total alone said "Copying 0.00" for a selection
|
||
// made entirely of prior year or plan -- the exact case clone exists for.
|
||
function MovingTotal({ currentTotals, verb, factor, includeExcluded }) {
|
||
const adj = currentTotals?.total
|
||
if (!adj) return null
|
||
const ex = currentTotals?.excluded
|
||
const t = includeExcluded && ex
|
||
? { value: (adj.value || 0) + (ex.value || 0), units: (adj.units || 0) + (ex.units || 0) }
|
||
: adj
|
||
const { valueCol, unitsCol } = currentTotals
|
||
const scaled = factor != null && factor !== 1
|
||
return (
|
||
<p className="text-gray-500">
|
||
{verb}{' '}
|
||
{valueCol && <span className="font-mono tabular-nums text-gray-700">{fmtNum(t.value)}</span>}
|
||
{valueCol && <span className="text-gray-600"> {valueCol}</span>}
|
||
{unitsCol && <>
|
||
<span className="text-gray-500"> · </span>
|
||
<span className="font-mono tabular-nums text-gray-700">{fmtNum(t.units)}</span>
|
||
<span className="text-gray-600"> {unitsCol}</span>
|
||
</>}
|
||
{scaled && valueCol && <>
|
||
<span className="text-gray-500"> → </span>
|
||
<span className="font-mono tabular-nums text-gray-700">{fmtNum(t.value * factor)}</span>
|
||
</>}
|
||
</p>
|
||
)
|
||
}
|
||
|
||
// ── Apply mode ──────────────────────────────────────────────────────────────
|
||
function ApplyModeChooser({ op, applyMode, setApplyMode, count }) {
|
||
const options = op === 'scale'
|
||
? [
|
||
['prorate', 'Together', `One pool of ${count} slices — a target is the new combined total, and each slice keeps its share of the mix.`],
|
||
['each', 'Each', `All ${count} slices independently — every slice reaches the target on its own.`],
|
||
]
|
||
: [
|
||
['prorate', 'Together', `One operation over all ${count} slices — a single log entry.`],
|
||
['each', 'Each', `One operation per slice — ${count} log entries, undoable separately.`],
|
||
]
|
||
const active = options.find(([v]) => v === applyMode)
|
||
return (
|
||
<div className="flex flex-col gap-1.5">
|
||
<Segmented value={applyMode} onChange={setApplyMode}
|
||
options={options.map(([v, l, t]) => [v, l, t])} />
|
||
{active && <p className="text-gray-600 leading-snug max-w-xs">{active[2]}</p>}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function RequestPreview({ payload }) {
|
||
const [open, setOpen] = useState(false)
|
||
if (!payload) return null
|
||
return (
|
||
<div>
|
||
<button onClick={() => setOpen(o => !o)} className="text-gray-600 hover:text-gray-600">
|
||
{open ? '▾' : '▸'} request
|
||
</button>
|
||
{open && (
|
||
<pre className="mt-1 font-mono text-gray-600 bg-gray-50 border border-gray-100 rounded p-2 overflow-auto max-h-40 leading-relaxed">
|
||
{JSON.stringify(payload, null, 2)}
|
||
</pre>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default function OperationPanel({
|
||
dock,
|
||
slices, setSlices, distinctSlices,
|
||
viewScope = [],
|
||
applyMode, setApplyMode,
|
||
currentTotals,
|
||
activeOp, setActiveOp,
|
||
scaleInputs, setScaleInputs,
|
||
scalePlug, setScalePlug,
|
||
targetBasis, setTargetBasis,
|
||
opTag, setOpTag, knownTags = [], logMeta = {},
|
||
scaleNote, setScaleNote,
|
||
recodeSet, setRecodeSet,
|
||
recodeNote, setRecodeNote,
|
||
cloneSet, setCloneSet,
|
||
cloneScale, setCloneScale,
|
||
cloneFrom, setCloneFrom, cloneOffset, setCloneOffset, cloneSources,
|
||
cloneNote, setCloneNote,
|
||
dimCols, lookupDerivedCols, dimMembers, versionId,
|
||
buildPayload, submitOp,
|
||
}) {
|
||
const hasSlice = slices.length > 0
|
||
const multi = slices.length > 1
|
||
const horizontal = dock === 'bottom'
|
||
|
||
const note = activeOp === 'scale' ? scaleNote : activeOp === 'recode' ? recodeNote : cloneNote
|
||
const shifting = !!cloneOffset && cloneOffset.trim() !== '' && cloneOffset.trim() !== '0 days'
|
||
const setNote = activeOp === 'scale' ? setScaleNote : activeOp === 'recode' ? setRecodeNote : setCloneNote
|
||
const OP_LABEL = { scale: 'Apply Scale', recode: 'Apply Recode', clone: 'Apply Clone' }
|
||
|
||
return (
|
||
<div className={horizontal ? 'flex flex-row items-start p-3 gap-5 min-w-0' : 'flex flex-col p-3 gap-3 min-w-0'}>
|
||
|
||
<Block title="Slice" hint={hasSlice ? `${slices.length} selected` : null}
|
||
horizontal={horizontal} grow>
|
||
{/* Cells that differ only by a column operations cannot filter on collapse
|
||
to the same slice — say so, rather than implying more reach than there is */}
|
||
{distinctSlices != null && distinctSlices < slices.length && (
|
||
<p className="text-amber-700 bg-amber-50 border border-amber-200 rounded px-2 py-1 mb-2 leading-snug">
|
||
{slices.length} cells selected, but they cover {distinctSlices} distinct{' '}
|
||
{distinctSlices === 1 ? 'slice' : 'slices'} — some differ only by a column
|
||
operations cannot target. The duplicates are applied once.
|
||
</p>
|
||
)}
|
||
<SelectionList
|
||
slices={slices}
|
||
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">
|
||
<input
|
||
value={opTag} onChange={e => setOpTag(e.target.value)}
|
||
list="pf-tag-options" placeholder="initiative, e.g. reduce_spend"
|
||
className={`${TEXT} w-48`} />
|
||
{opTag.trim() && (
|
||
<button onClick={() => setOpTag('')} title="Clear tag"
|
||
className="text-gray-500 hover:text-red-500 leading-none px-1">×</button>
|
||
)}
|
||
</Field>
|
||
|
||
{/* Under the tag field and inside the same column, so they read as
|
||
values for it rather than as a row of unexplained buttons. */}
|
||
{knownTags.length > 0 && (
|
||
<Field label="">
|
||
<div className="flex items-center gap-1 flex-wrap">
|
||
{knownTags.slice(0, 6).map(t => (
|
||
<button key={t.tag} onClick={() => setOpTag(t.tag)}
|
||
title={`${t.uses} previous use${t.uses === 1 ? '' : 's'}`}
|
||
className={`px-2 py-0.5 rounded-full border text-xs whitespace-nowrap ${
|
||
opTag.trim() === t.tag
|
||
? 'bg-blue-600 border-blue-600 text-white'
|
||
: 'bg-white border-gray-300 text-gray-700 hover:border-blue-400 hover:text-blue-700'}`}>
|
||
{t.tag}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</Field>
|
||
)}
|
||
|
||
<Field label="note">
|
||
<input value={note} onChange={e => setNote(e.target.value)}
|
||
placeholder="optional" className={`${TEXT} w-48`} />
|
||
</Field>
|
||
|
||
<div className="pt-1">
|
||
<Submit onClick={() => submitOp(activeOp)}>{OP_LABEL[activeOp]}</Submit>
|
||
</div>
|
||
<RequestPreview payload={buildPayload(activeOp)} />
|
||
</div>
|
||
</Block>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|