Rebuild the operation panel around the ledger

The panel said what it was doing only on the button that did it. The
operation tabs sat unlabelled in the middle column, the slice list held
a third of the dock to repeat cells you had just clicked, and the
ledger -- the thing being read and edited -- got whatever width was
left over.

- One strip across the top: operation, apply mode, slice summary, Post
- The slice list is a peek; the summary keeps the two facts not visible
  elsewhere, the count and the pivot filter scoping the write
- The body is two grid tracks, ledger first, label second. Both used to
  carry flex-1, so the ledger's table overflowed its shrunken box and
  painted over the label column on a narrow window; the tables scroll
  inside their own zone now
- "Apply Scale" is "Post". It renamed itself whenever the operation
  changed, so the primary action never looked the same twice
- Target on: "Forecast" / "Total" rather than "Adjustable" / "Selected
  total" -- what a rep is choosing is whether the number is the forecast
  alone or the whole year including what has shipped
- The minor actions (clear, peek, unpin, request, collapse) were bare
  buttons in the surrounding grey; they share one text-button style now,
  and the segmented groups have rules between their segments

Also drops the bridge's "Whole version" scope.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-23 21:55:34 -04:00
parent 6c26872443
commit d01dd2dbc8
2 changed files with 313 additions and 190 deletions

View File

@ -270,7 +270,13 @@ export default function BridgeView({
excludeIters = ['reference'], versionName, forecastBucket,
}) {
const hasSelection = slices.length > 0
// 'selection' | 'filtered' | 'all'
// 'selection' | 'filtered'. There is no whole-version scope: the pivot's
// filter is part of what the figures mean, and dropping it moves one end of
// the walk and not the other. On the version this was built against the grid
// is scoped `sseas_e <= 2027`, and the rows outside it -- orders with a null
// ship date, plus a tail beyond 2027 -- sit entirely in the forecast loads,
// with no counterpart in Plan. Ignoring the filter therefore added $9.4M to
// the endpoint and nothing to the basis, and the bridge said nothing about it.
const [scope, setScope] = useState(hasSelection ? 'selection' : 'filtered')
// Whether the user has picked a scope button themselves. Bridge mounts once,
// up front, before anything has ever been selected on the pivot — so the
@ -359,7 +365,7 @@ export default function BridgeView({
// filter can name a column that exists only as an expression, and a view
// built without it cannot resolve the column and fails outright.
let expressions = {}
if (scope === 'filtered' && viewerRef?.current) {
if (viewerRef?.current) {
const cfg = await viewerRef.current.save()
filter = (cfg.filter || []).filter(f => Array.isArray(f) && f.length >= 2)
expressions = cfg.expressions || {}
@ -426,7 +432,7 @@ export default function BridgeView({
<span className="text-gray-600 text-xs">
· {scope === 'selection'
? `${slices.length} selected slice${slices.length === 1 ? '' : 's'}`
: scope === 'filtered' ? "pivot's filters" : 'whole version'}
: "pivot's filters"}
</span>
</div>
<button onClick={onClose} className="text-gray-600 hover:text-gray-800 text-lg leading-none">×</button>
@ -441,7 +447,6 @@ export default function BridgeView({
hasSelection ? 'The slices selected in the operation panel'
: 'Select one or more pivot rows first'],
['filtered', "Pivot's filters", 'Everything the pivot currently shows'],
['all', 'Whole version', 'Every row in the version, filters ignored'],
].map(([v, l, title]) => (
<button key={v} onClick={() => { setScope(v); setScopeTouched(true) }} title={title}
disabled={v === 'selection' && !hasSelection}

View File

@ -47,8 +47,9 @@ function sliceLabel(s) {
function Block({ title, hint, horizontal, grow, children }) {
return (
<section className={[
'min-w-0',
horizontal ? 'pl-5 border-l border-gray-200 first:pl-0 first:border-l-0' : '',
grow ? 'flex-1 min-w-0' : 'shrink-0',
grow ? 'flex-1' : 'shrink-0',
].join(' ')}>
{title && (
<header className="flex items-baseline gap-1.5 mb-2">
@ -61,6 +62,19 @@ function Block({ title, hint, horizontal, grow, children }) {
)
}
// The panel's minor actions -- clear, peek, unpin, show the request -- were
// each a bare <button> in whatever grey the surrounding text happened to be, so
// none of them read as something you could click. They all use this now:
// blue, underlined on hover, which is the one affordance the app already has.
function TextButton({ onClick, children, title, danger }) {
return (
<button onClick={onClick} title={title}
className={`hover:underline whitespace-nowrap ${danger ? 'text-gray-600 hover:text-red-600' : 'text-blue-600'}`}>
{children}
</button>
)
}
function Button({ onClick, active, children, title, disabled }) {
return (
<button onClick={onClick} title={title} disabled={disabled}
@ -79,7 +93,19 @@ function Button({ onClick, active, children, title, disabled }) {
// "copy rows from" sitting above "scale cloned rows by" above "tag".
const LABEL_W = 'w-24'
function Field({ label, children, hint }) {
// `stack` puts the label above its control instead of beside it. The aligned
// label column costs 6rem, which is most of a narrow zone -- the tag field in
// the bottom dock had 12rem of box left to type an initiative name into.
function Field({ label, children, hint, stack }) {
if (stack) {
return (
<div className="flex flex-col gap-1 min-w-0">
<span className="text-gray-600 whitespace-nowrap">{label}</span>
<div className="flex items-center gap-2 min-w-0">{children}</div>
{hint && <p className="text-gray-500 text-[11px] leading-snug">{hint}</p>}
</div>
)
}
return (
<div className="flex items-start gap-2">
<span className={`text-gray-600 whitespace-nowrap shrink-0 pt-1 ${LABEL_W}`}>{label}</span>
@ -106,7 +132,7 @@ function SectionLabel({ children }) {
// says the choice has been made elsewhere.
function Segmented({ options, value, onChange, disabled }) {
return (
<div className={`inline-flex rounded border overflow-hidden w-auto self-start ${
<div className={`inline-flex rounded border divide-x divide-gray-200 overflow-hidden w-auto self-start ${
disabled ? 'border-gray-200 bg-gray-50' : 'border-gray-200'}`}>
{options.map(([val, label, title]) => (
<Button key={val} onClick={() => onChange(val)} active={value === val}
@ -131,6 +157,53 @@ function Submit({ onClick, children, disabled, busy }) {
}
// ── 1. Selection ────────────────────────────────────────────────────────────
function scopeText(viewScope = []) {
return 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(' · ')
}
// The selection, in one line you can open. What it always shows is what is not
// visible anywhere else: how many slices, what they total, and the pivot filter
// they sit inside -- which scopes every figure in the ledger and every row the
// write touches, while appearing in none of the slices themselves.
function SliceSummary({ slices, viewScope, distinctSlices, currentTotals, open, onToggle }) {
const scope = scopeText(viewScope)
const one = slices.length === 1 ? sliceLabel(slices[0]) : null
const total = currentTotals
? (currentTotals.total?.value || 0) + (currentTotals.excluded?.value || 0)
: null
const dupes = distinctSlices != null && distinctSlices < slices.length
return (
<div className="flex items-baseline gap-2 min-w-0">
<span className="text-gray-600 shrink-0">
{one ? '1 slice' : `${slices.length} slices`}
</span>
{one && <span className="font-mono text-gray-700 truncate max-w-[22rem]" title={one}>{one}</span>}
{total != null && (
<span className="font-mono tabular-nums text-gray-700 shrink-0">{fmtNum(total)}</span>
)}
{scope && (
<span className="text-gray-500 truncate max-w-[16rem]" title={scope}>
<span className="uppercase tracking-wide text-gray-400">within </span>
<span className="font-mono">{scope}</span>
</span>
)}
{dupes && (
<span className="shrink-0 px-1 py-0.5 rounded bg-amber-50 text-amber-700 text-[10px] uppercase tracking-wide"
title={`${slices.length} cells cover ${distinctSlices} distinct slices — some differ only by a column operations cannot target. The duplicates are applied once.`}>
{distinctSlices} distinct
</span>
)}
<TextButton onClick={onToggle}>{open ? '▾ hide' : '▸ peek'}</TextButton>
</div>
)
}
function SelectionList({ slices, viewScope = [], currentTotals, onRemove, onClear }) {
const multi = slices.length > 1
const perSlice = currentTotals?.perSlice || []
@ -140,12 +213,7 @@ function SelectionList({ slices, viewScope = [], currentTotals, onRemove, onClea
// 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(' · ')
const scopeLine = scopeText(viewScope)
if (!slices.length) {
return (
@ -200,7 +268,7 @@ function SelectionList({ slices, viewScope = [], currentTotals, onRemove, onClea
</tbody>
</table>
</div>
<button onClick={onClear} className="text-gray-600 hover:text-red-500 mt-1.5">Clear selection</button>
<div className="mt-1.5"><TextButton onClick={onClear} danger>Clear selection</TextButton></div>
</div>
)
}
@ -214,9 +282,9 @@ function IterBreakdown({ currentTotals }) {
const { valueCol, unitsCol } = currentTotals
return (
<div className="mt-2">
<button onClick={() => setOpen(o => !o)} className="text-gray-600 hover:text-gray-600">
<TextButton onClick={() => setOpen(o => !o)}>
{open ? '▾' : '▸'} breakdown by iter
</button>
</TextButton>
{open && (
<table className="mt-1 text-gray-500">
<tbody>
@ -785,6 +853,12 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se
return (
<div className="min-w-0">
{/* The ledger is the widest thing in the panel and the dock is as wide as
the window, so on a narrow one it used to overflow its own zone and
paint straight over the label/apply column beside it -- a table is not
clipped by a flex item that has been shrunk under it. It scrolls
within its own zone instead. */}
<div className="overflow-x-auto">
<table>
<thead>
<tr className="text-gray-600" style={{ fontSize: '10px' }}>
@ -839,8 +913,7 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se
{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>
<TextButton onClick={() => setShowAllLines(false)}>▾ collapse the smaller changes</TextButton>
</td>
</tr>
)}
@ -875,7 +948,7 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se
<tr className={onTotal ? 'text-gray-600' : 'font-semibold text-gray-700'}>
<td className="pr-3 whitespace-nowrap">
{hasExcl ? 'Adjustable' : 'Current'}{perSlice ? ' (all)' : ''}
{hasExcl ? 'Forecast' : 'Current'}{perSlice ? ' (all)' : ''}
</td>
{measures.map(m => (
<td key={m.key} className={numCell}>{fmtNum(m.current, m.dp)}</td>
@ -886,7 +959,7 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se
{hasExcl && (
<tr className={onTotal ? 'font-semibold text-gray-700' : 'text-gray-600'}>
<td className="pr-3 whitespace-nowrap">Selected total</td>
<td className="pr-3 whitespace-nowrap">Total selected</td>
{measures.map(m => (
<td key={m.key} className={numCell}>
{m.key === 'price'
@ -957,6 +1030,7 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se
)}
</tbody>
</table>
</div>
{/* A dollar figure on its own does not say whether price or volume moved,
so the toggle answers for it. Kept visible even when it is not the one
@ -983,11 +1057,10 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se
]}
/>
{plugState.unpin && (
<button onClick={() => clearEdit(plugState.unpin)}
title={`Stop pinning ${plugState.unpin === 'units' ? unitsCol : 'price'} and choose here instead`}
className="text-blue-600 hover:underline">
<TextButton onClick={() => clearEdit(plugState.unpin)}
title={`Stop pinning ${plugState.unpin === 'units' ? unitsCol : 'price'} and choose here instead`}>
unpin {plugState.unpin === 'units' ? unitsCol : 'price'}
</button>
</TextButton>
)}
</Field>
)}
@ -997,12 +1070,17 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se
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)}.`}>
{/* "Adjustable" and "Selected total" are the writer's words for
it -- which rows the SQL may touch. What a rep is choosing is
whether the number they type is the forecast alone or the
whole year including what has already shipped, so the buttons
say that and the hint names the fixed segments. */}
<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'],
['adjustable', 'Forecast', `Your number is the forecast alone — ${exclName} is left out of it`],
['selected', 'Total', `Your number is everything selected, ${exclName} included`],
]}
/>
</Field>
@ -1080,6 +1158,7 @@ function DimForm({ dimCols, setObj, setSet, slices, lookupDerivedCols, dimMember
const first = slices[0] || {}
return (
<div className="flex flex-col gap-2.5 min-w-0">
<div className="overflow-x-auto">
<table>
<thead>
<tr className="text-gray-600" style={{ fontSize: '10px' }}>
@ -1115,6 +1194,7 @@ function DimForm({ dimCols, setObj, setSet, slices, lookupDerivedCols, dimMember
})}
</tbody>
</table>
</div>
{extra}
</div>
)
@ -1178,9 +1258,9 @@ function RequestPreview({ payload }) {
if (!payload) return null
return (
<div>
<button onClick={() => setOpen(o => !o)} className="text-gray-600 hover:text-gray-600">
<TextButton onClick={() => setOpen(o => !o)}>
{open ? '▾' : '▸'} request
</button>
</TextButton>
{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)}
@ -1219,179 +1299,217 @@ export default function OperationPanel({
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…' }
// Slices are confirmation, not the work. The list used to hold a whole column
// of the dock to repeat cells you had just clicked; it is a peek now, and the
// summary line keeps the two facts that are not visible anywhere else -- how
// many slices, and the pivot filter they sit inside, which scopes every figure
// below and every row the write touches.
const [peek, setPeek] = useState(false)
useEffect(() => { if (!hasSlice) setPeek(false) }, [hasSlice])
// One verb for all three operations. "Apply Scale" renamed itself under the
// cursor whenever the operation changed, so the primary action was the one
// control that never looked the same twice; what it does -- write the
// adjustment onto the version -- does not change with the operation, and the
// operation is named by the tabs beside it.
const POST = 'Post', POSTING = 'Posting…'
const ledger = (
<>
{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>
} />
)}
</>
)
const labelZone = (
<div className="flex flex-col gap-2.5 min-w-0">
{/* Tag first: it is the field that gives an adjustment meaning later,
in the ledger and in the bridge. Completes from initiatives already
used on this source; free text is still accepted. */}
<Field label="tag" stack>
<TagInput value={opTag} onChange={setOpTag} tags={knownTags}
className={`${TEXT} w-full`} />
{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" stack>
<input value={note} onChange={e => setNote(e.target.value)}
placeholder="optional" className={`${TEXT} w-full`} />
</Field>
<RequestPreview payload={buildPayload(activeOp)} />
</div>
)
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'}>
<div className="flex flex-col 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>
{/* ── What you are doing, to what, and the button that does it ────────
One strip across the top of the panel, in reading order: the
operation, how it maps over the slices, what is selected, and Post.
The operation used to be a row of tabs buried in the middle column
with no heading over it, so the panel never said what it was about
to do until you read the button. */}
<div className="flex items-center gap-3 flex-wrap px-3 py-2 border-b border-gray-200 bg-gray-50/60">
<div className="inline-flex rounded border border-gray-200 divide-x divide-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>
{hasSlice && multi && (
<ApplyModeChooser op={activeOp} applyMode={applyMode} setApplyMode={setApplyMode} count={slices.length} />
)}
<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>
{hasSlice && (
<SliceSummary slices={slices} viewScope={viewScope} distinctSlices={distinctSlices}
currentTotals={currentTotals} open={peek} onToggle={() => setPeek(o => !o)} />
)}
{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>
} />
{hasSlice && (
<div className="ml-auto flex items-center gap-3">
{opBusy && (
<span className="text-gray-500 leading-snug">
Writing rows — the pivot updates when it finishes.
</span>
)}
<Submit onClick={() => submitOp(activeOp)} busy={!!opBusy}>
{opBusy ? POSTING : POST}
</Submit>
</div>
</Block>
)}
</div>
{peek && hasSlice && (
<div className="px-3 py-2 border-b border-gray-200 bg-gray-50/60">
<SelectionList
slices={slices}
viewScope={viewScope}
currentTotals={currentTotals}
onRemove={(i) => setSlices(prev => prev.filter((_, x) => x !== i))}
onClear={() => setSlices([])}
/>
</div>
)}
{!hasSlice && (
<p className="text-gray-600 italic leading-relaxed p-3">
Click a pivot row to select a slice.<br />
<span className="text-gray-500">Ctrl/⌘-click to add more.</span>
</p>
)}
{/* ── The work itself ─────────────────────────────────────────────────
The ledger is the panel's subject, so it gets the remaining width;
the label fields get a track wide enough to type a tag into. Grid
tracks rather than flex items: both used to carry `flex-1`, so the
leftover width was split evenly between them and, once the window
narrowed, the ledger's table overflowed its shrunken box and painted
over the column beside it. */}
{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 className={horizontal ? 'p-3 gap-5 min-w-0' : 'flex flex-col p-3 gap-3 min-w-0'}
style={horizontal
? { display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) minmax(15rem, 20rem)', alignItems: 'start' }
: undefined}>
<Block horizontal={horizontal} grow>{ledger}</Block>
<Block title="Label this change" horizontal={horizontal}>{labelZone}</Block>
</div>
)}
</div>
)